Files
warmbly/internal/api/handler/slack.go
T

242 lines
7.4 KiB
Go

// Slack app endpoints: the three request URLs Slack calls (verified against
// SLACK_SIGNING_SECRET before anything is parsed) and the dashboard's Slack
// panel (status, channels, settings, member links).
package handler
import (
"context"
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/slackapp"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// slackMaxBody caps what is read from Slack before the signature is checked.
const slackMaxBody = 1 << 20
// slackIngress reads the capped raw body, verifies Slack's signature over it,
// then hands it to serve. Nothing is parsed before verification.
func (h *Handler) slackIngress(c *gin.Context, serve func(ctx context.Context, body []byte) (any, error)) {
if h.SlackService == nil || !h.SlackService.Interactive() {
errx.JSON(c, slackapp.ErrSlackNotConfigured)
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, slackMaxBody+1))
if err != nil || len(body) > slackMaxBody {
errx.JSON(c, errx.New(errx.BadRequest, "Request body is missing or too large."))
return
}
if err := h.SlackService.Verify(c.GetHeader("X-Slack-Request-Timestamp"), c.GetHeader("X-Slack-Signature"), body); err != nil {
errx.JSON(c, errx.New(errx.Unauthorized, "Invalid Slack request signature."))
return
}
out, err := serve(c.Request.Context(), body)
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "Unreadable Slack payload."))
return
}
if out == nil {
c.Status(http.StatusOK)
return
}
c.JSON(http.StatusOK, out)
}
// SlackEvents — POST /api/v1/integrations/slack/events
func (h *Handler) SlackEvents(c *gin.Context) {
h.slackIngress(c, func(ctx context.Context, body []byte) (any, error) {
return h.SlackService.HandleEvents(ctx, body)
})
}
// SlackInteractivity — POST /api/v1/integrations/slack/interactivity
func (h *Handler) SlackInteractivity(c *gin.Context) {
h.slackIngress(c, func(ctx context.Context, body []byte) (any, error) {
return h.SlackService.HandleInteractivity(ctx, body)
})
}
// SlackCommands — POST /api/v1/integrations/slack/commands
func (h *Handler) SlackCommands(c *gin.Context) {
h.slackIngress(c, func(ctx context.Context, body []byte) (any, error) {
return h.SlackService.HandleCommand(ctx, body)
})
}
// slackCaller resolves the signed-in user and, when required, the org.
func (h *Handler) slackCaller(c *gin.Context, needOrg bool) (uuid.UUID, uuid.UUID, bool) {
if h.SlackService == nil {
errx.JSON(c, slackapp.ErrSlackNotConfigured)
return uuid.Nil, uuid.Nil, false
}
userID, err := middleware.GetUserUUID(c)
if err != nil {
errx.JSON(c, errx.ErrUnauthorized)
return uuid.Nil, uuid.Nil, false
}
if !needOrg {
return uuid.Nil, userID, true
}
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return uuid.Nil, uuid.Nil, false
}
return *orgID, userID, true
}
// GetSlackStatus — GET /v1/integrations/slack/status
func (h *Handler) GetSlackStatus(c *gin.Context) {
orgID, userID, ok := h.slackCaller(c, true)
if !ok {
return
}
member, xerr := h.OrganizationService.GetMembership(c.Request.Context(), orgID, userID)
if xerr != nil || member == nil {
errx.JSON(c, errx.New(errx.Forbidden, "not a member of this organization"))
return
}
st, xerr := h.SlackService.Status(c.Request.Context(), orgID, userID, member.Permissions.HasPermission(models.PermManageSettings))
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, st)
}
// ListSlackChannels — GET /v1/integrations/slack/channels?q=
func (h *Handler) ListSlackChannels(c *gin.Context) {
orgID, _, ok := h.slackCaller(c, true)
if !ok {
return
}
list, more, xerr := h.SlackService.Channels(c.Request.Context(), orgID, c.Query("q"))
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"data": list, "pagination": gin.H{"next_cursor": nil, "has_more": more}})
}
// UpdateSlackSettings — PUT /v1/integrations/slack/settings
func (h *Handler) UpdateSlackSettings(c *gin.Context) {
orgID, _, ok := h.slackCaller(c, true)
if !ok {
return
}
var req models.SlackSettings
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.InvalidBody(err))
return
}
st, xerr := h.SlackService.UpdateSettings(c.Request.Context(), orgID, req)
if xerr != nil {
errx.JSON(c, xerr)
return
}
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityIntegration, nil, nil, map[string]string{"slack": "settings"})
c.JSON(http.StatusOK, st)
}
// PreviewSlackLink — GET /v1/integrations/slack/link/:code
func (h *Handler) PreviewSlackLink(c *gin.Context) {
_, userID, ok := h.slackCaller(c, false)
if !ok {
return
}
p, xerr := h.SlackService.PreviewLink(c.Request.Context(), userID, c.Param("code"))
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, p)
}
// ConfirmSlackLink — POST /v1/integrations/slack/link
func (h *Handler) ConfirmSlackLink(c *gin.Context) {
_, userID, ok := h.slackCaller(c, false)
if !ok {
return
}
var req struct {
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.InvalidBody(err))
return
}
link, xerr := h.SlackService.ConfirmLink(c.Request.Context(), userID, req.Code)
if xerr != nil {
errx.JSON(c, xerr)
return
}
// The org comes from the code, so the audit row is written against it.
if h.AuditService != nil {
h.AuditService.LogAction(c.Request.Context(), link.OrganizationID, userID, models.AuditActionCreate, models.AuditEntityIntegration,
&link.ConnectionID, c.ClientIP(), c.Request.UserAgent(), nil, map[string]string{"slack_link": "linked"})
}
c.JSON(http.StatusCreated, link)
}
// UpdateMySlackLink — PATCH /v1/integrations/slack/link
func (h *Handler) UpdateMySlackLink(c *gin.Context) {
orgID, userID, ok := h.slackCaller(c, true)
if !ok {
return
}
var req struct {
DMNotifications *bool `json:"dm_notifications" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.InvalidBody(err))
return
}
link, xerr := h.SlackService.UpdateMyLink(c.Request.Context(), orgID, userID, *req.DMNotifications)
if xerr != nil {
errx.JSON(c, xerr)
return
}
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityIntegration, &link.ConnectionID, nil, map[string]string{"slack_link": "dm_notifications"})
c.JSON(http.StatusOK, link)
}
// DeleteMySlackLink — DELETE /v1/integrations/slack/link
func (h *Handler) DeleteMySlackLink(c *gin.Context) {
orgID, userID, ok := h.slackCaller(c, true)
if !ok {
return
}
if xerr := h.SlackService.UnlinkMine(c.Request.Context(), orgID, userID); xerr != nil {
errx.JSON(c, xerr)
return
}
h.auditOrg(c, models.AuditActionDelete, models.AuditEntityIntegration, nil, nil, map[string]string{"slack_link": "unlinked"})
c.Status(http.StatusNoContent)
}
// RemoveSlackLink — DELETE /v1/integrations/slack/links/:id
func (h *Handler) RemoveSlackLink(c *gin.Context) {
orgID, _, ok := h.slackCaller(c, true)
if !ok {
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.ErrUuid)
return
}
link, xerr := h.SlackService.RemoveLink(c.Request.Context(), orgID, id)
if xerr != nil {
errx.JSON(c, xerr)
return
}
h.auditOrg(c, models.AuditActionDelete, models.AuditEntityIntegration, &link.ConnectionID, nil, map[string]string{"slack_link": "removed"})
c.Status(http.StatusNoContent)
}