mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
`inbox_notification:read` and `inbox_notification:update` existed but weren't in the public scope catalog, so only `coder:all` tokens could use the inbox. The inbox endpoints also returned 500s or a clean socket close when a scope was missing, instead of a 403. - Add `inbox_notification:read` and `inbox_notification:update` to the public scope catalog. `create` stays internal, so there is no `inbox_notification:*`. No migration is needed. - Every inbox endpoint checks scopes up front and returns 403 naming the missing scope. The watch endpoint checks before the websocket upgrade. - `read-status` requires both `read` and `update`. It returns 404 unless the notification belongs to the caller, so an owner can no longer mark another user's notification read.
505 lines
18 KiB
Go
505 lines
18 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/httpmw/loggermw"
|
|
"github.com/coder/coder/v2/coderd/notifications"
|
|
"github.com/coder/coder/v2/coderd/pubsub"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
markdown "github.com/coder/coder/v2/coderd/render"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/websocket"
|
|
)
|
|
|
|
const (
|
|
notificationFormatMarkdown = "markdown"
|
|
notificationFormatPlaintext = "plaintext"
|
|
)
|
|
|
|
var fallbackIcons = map[uuid.UUID]string{
|
|
// workspace related notifications
|
|
notifications.TemplateWorkspaceCreated: codersdk.InboxNotificationFallbackIconWorkspace,
|
|
notifications.TemplateWorkspaceManuallyUpdated: codersdk.InboxNotificationFallbackIconWorkspace,
|
|
notifications.TemplateWorkspaceDeleted: codersdk.InboxNotificationFallbackIconWorkspace,
|
|
notifications.TemplateWorkspaceAutobuildFailed: codersdk.InboxNotificationFallbackIconWorkspace,
|
|
notifications.TemplateWorkspaceDormant: codersdk.InboxNotificationFallbackIconWorkspace,
|
|
notifications.TemplateWorkspaceAutoUpdated: codersdk.InboxNotificationFallbackIconWorkspace,
|
|
notifications.TemplateWorkspaceMarkedForDeletion: codersdk.InboxNotificationFallbackIconWorkspace,
|
|
notifications.TemplateWorkspaceAutostopReminder: codersdk.InboxNotificationFallbackIconWorkspace,
|
|
notifications.TemplateWorkspaceManualBuildFailed: codersdk.InboxNotificationFallbackIconWorkspace,
|
|
notifications.TemplateWorkspaceOutOfMemory: codersdk.InboxNotificationFallbackIconWorkspace,
|
|
notifications.TemplateWorkspaceOutOfDisk: codersdk.InboxNotificationFallbackIconWorkspace,
|
|
|
|
// account related notifications
|
|
notifications.TemplateUserAccountCreated: codersdk.InboxNotificationFallbackIconAccount,
|
|
notifications.TemplateUserAccountDeleted: codersdk.InboxNotificationFallbackIconAccount,
|
|
notifications.TemplateUserAccountSuspended: codersdk.InboxNotificationFallbackIconAccount,
|
|
notifications.TemplateUserAccountActivated: codersdk.InboxNotificationFallbackIconAccount,
|
|
notifications.TemplateYourAccountSuspended: codersdk.InboxNotificationFallbackIconAccount,
|
|
notifications.TemplateYourAccountActivated: codersdk.InboxNotificationFallbackIconAccount,
|
|
notifications.TemplateUserRequestedOneTimePasscode: codersdk.InboxNotificationFallbackIconAccount,
|
|
|
|
// template related notifications
|
|
notifications.TemplateTemplateDeleted: codersdk.InboxNotificationFallbackIconTemplate,
|
|
notifications.TemplateTemplateDeprecated: codersdk.InboxNotificationFallbackIconTemplate,
|
|
notifications.TemplateWorkspaceBuildsFailedReport: codersdk.InboxNotificationFallbackIconTemplate,
|
|
|
|
// chat related notifications
|
|
notifications.TemplateChatAutoArchiveDigest: codersdk.InboxNotificationFallbackIconOther,
|
|
notifications.TemplateChatShared: codersdk.InboxNotificationFallbackIconOther,
|
|
}
|
|
|
|
func ensureNotificationIcon(notif codersdk.InboxNotification) codersdk.InboxNotification {
|
|
if notif.Icon != "" {
|
|
return notif
|
|
}
|
|
|
|
fallbackIcon, ok := fallbackIcons[notif.TemplateID]
|
|
if !ok {
|
|
fallbackIcon = codersdk.InboxNotificationFallbackIconOther
|
|
}
|
|
|
|
notif.Icon = fallbackIcon
|
|
return notif
|
|
}
|
|
|
|
// convertInboxNotificationResponse works as a util function to transform a database.InboxNotification to codersdk.InboxNotification
|
|
func convertInboxNotificationResponse(ctx context.Context, logger slog.Logger, notif database.InboxNotification) codersdk.InboxNotification {
|
|
convertedNotif := codersdk.InboxNotification{
|
|
ID: notif.ID,
|
|
UserID: notif.UserID,
|
|
TemplateID: notif.TemplateID,
|
|
Targets: notif.Targets,
|
|
Title: notif.Title,
|
|
Content: notif.Content,
|
|
Icon: notif.Icon,
|
|
Actions: func() []codersdk.InboxNotificationAction {
|
|
var actionsList []codersdk.InboxNotificationAction
|
|
err := json.Unmarshal([]byte(notif.Actions), &actionsList)
|
|
if err != nil {
|
|
logger.Error(ctx, "unmarshal inbox notification actions", slog.Error(err))
|
|
}
|
|
return actionsList
|
|
}(),
|
|
ReadAt: func() *time.Time {
|
|
if !notif.ReadAt.Valid {
|
|
return nil
|
|
}
|
|
return ¬if.ReadAt.Time
|
|
}(),
|
|
CreatedAt: notif.CreatedAt,
|
|
}
|
|
|
|
return ensureNotificationIcon(convertedNotif)
|
|
}
|
|
|
|
// watchInboxNotifications watches for new inbox notifications and sends them to the client.
|
|
// The client can specify a list of target IDs to filter the notifications.
|
|
// @Summary Watch for new inbox notifications
|
|
// @ID watch-for-new-inbox-notifications
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Notifications
|
|
// @Param targets query string false "Comma-separated list of target IDs to filter notifications"
|
|
// @Param templates query string false "Comma-separated list of template IDs to filter notifications"
|
|
// @Param read_status query string false "Filter notifications by read status. Possible values: read, unread, all"
|
|
// @Param format query string false "Define the output format for notifications title and body." enums(plaintext,markdown)
|
|
// @Success 200 {object} codersdk.GetInboxNotificationResponse
|
|
// @Router /api/v2/notifications/inbox/watch [get]
|
|
func (api *API) watchInboxNotifications(rw http.ResponseWriter, r *http.Request) {
|
|
p := httpapi.NewQueryParamParser()
|
|
vals := r.URL.Query()
|
|
|
|
var (
|
|
ctx = r.Context()
|
|
apikey = httpmw.APIKey(r)
|
|
|
|
targets = p.UUIDs(vals, []uuid.UUID{}, "targets")
|
|
templates = p.UUIDs(vals, []uuid.UUID{}, "templates")
|
|
readStatus = p.String(vals, "all", "read_status")
|
|
format = p.String(vals, notificationFormatMarkdown, "format")
|
|
logger = api.Logger.Named("inbox_notifications_watcher")
|
|
)
|
|
p.ErrorExcessParams(vals)
|
|
if len(p.Errors) > 0 {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Query parameters have invalid values.",
|
|
Validations: p.Errors,
|
|
})
|
|
return
|
|
}
|
|
|
|
if !slices.Contains([]string{
|
|
string(database.InboxNotificationReadStatusAll),
|
|
string(database.InboxNotificationReadStatusRead),
|
|
string(database.InboxNotificationReadStatusUnread),
|
|
}, readStatus) {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "starting_before query parameter should be any of 'all', 'read', 'unread'.",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Authorize before websocket.Accept: after the upgrade, a refusal can only
|
|
// be sent as a close frame, not a 403.
|
|
if !api.authorizeInbox(rw, r, apikey.UserID, policy.ActionRead) {
|
|
return
|
|
}
|
|
|
|
notificationCh := make(chan codersdk.InboxNotification, 10)
|
|
|
|
closeInboxNotificationsSubscriber, err := api.Pubsub.SubscribeWithErr(pubsub.InboxNotificationForOwnerEventChannel(apikey.UserID),
|
|
pubsub.HandleInboxNotificationEvent(
|
|
func(ctx context.Context, payload pubsub.InboxNotificationEvent, err error) {
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "inbox notification event", slog.Error(err))
|
|
return
|
|
}
|
|
|
|
// HandleInboxNotificationEvent cb receives all the inbox notifications - without any filters excepted the user_id.
|
|
// Based on query parameters defined above and filters defined by the client - we then filter out the
|
|
// notifications we do not want to forward and discard it.
|
|
|
|
// filter out notifications that don't match the targets
|
|
if len(targets) > 0 {
|
|
for _, target := range targets {
|
|
if isFound := slices.Contains(payload.InboxNotification.Targets, target); !isFound {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// filter out notifications that don't match the templates
|
|
if len(templates) > 0 {
|
|
if isFound := slices.Contains(templates, payload.InboxNotification.TemplateID); !isFound {
|
|
return
|
|
}
|
|
}
|
|
|
|
// filter out notifications that don't match the read status
|
|
if readStatus != "" {
|
|
if readStatus == string(database.InboxNotificationReadStatusRead) {
|
|
if payload.InboxNotification.ReadAt == nil {
|
|
return
|
|
}
|
|
} else if readStatus == string(database.InboxNotificationReadStatusUnread) {
|
|
if payload.InboxNotification.ReadAt != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// keep a safe guard in case of latency to push notifications through websocket
|
|
select {
|
|
case notificationCh <- ensureNotificationIcon(payload.InboxNotification):
|
|
default:
|
|
api.Logger.Error(ctx, "failed to push consumed notification into websocket handler, check latency")
|
|
}
|
|
},
|
|
))
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "subscribe to inbox notification event", slog.Error(err))
|
|
return
|
|
}
|
|
defer closeInboxNotificationsSubscriber()
|
|
|
|
conn, err := websocket.Accept(rw, r, nil)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to upgrade connection to websocket.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
_ = conn.CloseRead(context.Background())
|
|
|
|
ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText)
|
|
defer wsNetConn.Close()
|
|
|
|
ctx = api.wsWatcher.Watch(ctx, logger, conn)
|
|
|
|
encoder := json.NewEncoder(wsNetConn)
|
|
|
|
// Log the request immediately instead of after it completes.
|
|
if rl := loggermw.RequestLoggerFromContext(ctx); rl != nil {
|
|
rl.WriteLog(ctx, http.StatusAccepted)
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-api.ctx.Done():
|
|
return
|
|
|
|
case <-ctx.Done():
|
|
return
|
|
|
|
case notif := <-notificationCh:
|
|
unreadCount, err := api.Database.CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID)
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "failed to count unread inbox notifications", slog.Error(err))
|
|
return
|
|
}
|
|
|
|
// By default, notifications are stored as markdown
|
|
// We can change the format based on parameter if required
|
|
if format == notificationFormatPlaintext {
|
|
notif.Title, err = markdown.PlaintextFromMarkdown(notif.Title)
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "failed to convert notification title to plain text", slog.Error(err))
|
|
return
|
|
}
|
|
|
|
notif.Content, err = markdown.PlaintextFromMarkdown(notif.Content)
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "failed to convert notification content to plain text", slog.Error(err))
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := encoder.Encode(codersdk.GetInboxNotificationResponse{
|
|
Notification: notif,
|
|
UnreadCount: int(unreadCount),
|
|
}); err != nil {
|
|
api.Logger.Error(ctx, "encode notification", slog.Error(err))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// listInboxNotifications lists the notifications for the user.
|
|
// @Summary List inbox notifications
|
|
// @ID list-inbox-notifications
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Notifications
|
|
// @Param targets query string false "Comma-separated list of target IDs to filter notifications"
|
|
// @Param templates query string false "Comma-separated list of template IDs to filter notifications"
|
|
// @Param read_status query string false "Filter notifications by read status. Possible values: read, unread, all"
|
|
// @Param starting_before query string false "ID of the last notification from the current page. Notifications returned will be older than the associated one" format(uuid)
|
|
// @Success 200 {object} codersdk.ListInboxNotificationsResponse
|
|
// @Router /api/v2/notifications/inbox [get]
|
|
func (api *API) listInboxNotifications(rw http.ResponseWriter, r *http.Request) {
|
|
p := httpapi.NewQueryParamParser()
|
|
vals := r.URL.Query()
|
|
|
|
var (
|
|
ctx = r.Context()
|
|
apikey = httpmw.APIKey(r)
|
|
|
|
targets = p.UUIDs(vals, nil, "targets")
|
|
templates = p.UUIDs(vals, nil, "templates")
|
|
readStatus = p.String(vals, "all", "read_status")
|
|
startingBefore = p.UUID(vals, uuid.Nil, "starting_before")
|
|
)
|
|
p.ErrorExcessParams(vals)
|
|
if len(p.Errors) > 0 {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Query parameters have invalid values.",
|
|
Validations: p.Errors,
|
|
})
|
|
return
|
|
}
|
|
|
|
if !slices.Contains([]string{
|
|
string(database.InboxNotificationReadStatusAll),
|
|
string(database.InboxNotificationReadStatusRead),
|
|
string(database.InboxNotificationReadStatusUnread),
|
|
}, readStatus) {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "starting_before query parameter should be any of 'all', 'read', 'unread'.",
|
|
})
|
|
return
|
|
}
|
|
|
|
if !api.authorizeInbox(rw, r, apikey.UserID, policy.ActionRead) {
|
|
return
|
|
}
|
|
|
|
createdBefore := dbtime.Now()
|
|
if startingBefore != uuid.Nil {
|
|
lastNotif, err := api.Database.GetInboxNotificationByID(ctx, startingBefore)
|
|
if err == nil {
|
|
createdBefore = lastNotif.CreatedAt
|
|
}
|
|
}
|
|
|
|
notifs, err := api.Database.GetFilteredInboxNotificationsByUserID(ctx, database.GetFilteredInboxNotificationsByUserIDParams{
|
|
UserID: apikey.UserID,
|
|
Templates: templates,
|
|
Targets: targets,
|
|
ReadStatus: database.InboxNotificationReadStatus(readStatus),
|
|
CreatedAtOpt: createdBefore,
|
|
})
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "failed to get filtered inbox notifications", slog.Error(err))
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to get filtered inbox notifications.",
|
|
})
|
|
return
|
|
}
|
|
|
|
unreadCount, err := api.Database.CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID)
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "failed to count unread inbox notifications", slog.Error(err))
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to count unread inbox notifications.",
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ListInboxNotificationsResponse{
|
|
Notifications: func() []codersdk.InboxNotification {
|
|
notificationsList := make([]codersdk.InboxNotification, 0, len(notifs))
|
|
for _, notification := range notifs {
|
|
notificationsList = append(notificationsList, convertInboxNotificationResponse(ctx, api.Logger, notification))
|
|
}
|
|
return notificationsList
|
|
}(),
|
|
UnreadCount: int(unreadCount),
|
|
})
|
|
}
|
|
|
|
// updateInboxNotificationReadStatus changes the read status of a notification.
|
|
// @Summary Update read status of a notification
|
|
// @ID update-read-status-of-a-notification
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Notifications
|
|
// @Param id path string true "id of the notification"
|
|
// @Success 200 {object} codersdk.Response
|
|
// @Router /api/v2/notifications/inbox/{id}/read-status [put]
|
|
func (api *API) updateInboxNotificationReadStatus(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
apikey = httpmw.APIKey(r)
|
|
)
|
|
|
|
notificationID, ok := httpmw.ParseUUIDParam(rw, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var body codersdk.UpdateInboxNotificationReadStatusRequest
|
|
if !httpapi.Read(ctx, rw, r, &body) {
|
|
return
|
|
}
|
|
|
|
// Check read too: the notification is read before it is updated.
|
|
if !api.authorizeInbox(rw, r, apikey.UserID, policy.ActionUpdate, policy.ActionRead) {
|
|
return
|
|
}
|
|
|
|
// RBAC may grant access to other users' inboxes; only act on the caller's.
|
|
notification, err := api.Database.GetInboxNotificationByID(ctx, notificationID)
|
|
if httpapi.Is404Error(err) || (err == nil && notification.UserID != apikey.UserID) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "failed to get notification by id", slog.Error(err))
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to get notification by id.",
|
|
})
|
|
return
|
|
}
|
|
|
|
readAt := sql.NullTime{}
|
|
if body.IsRead {
|
|
readAt = sql.NullTime{Time: dbtime.Now(), Valid: true}
|
|
}
|
|
err = api.Database.UpdateInboxNotificationReadStatus(ctx, database.UpdateInboxNotificationReadStatusParams{
|
|
ID: notificationID,
|
|
ReadAt: readAt,
|
|
})
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "failed to update inbox notification read status", slog.Error(err))
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to update inbox notification read status.",
|
|
})
|
|
return
|
|
}
|
|
notification.ReadAt = readAt
|
|
|
|
unreadCount, err := api.Database.CountUnreadInboxNotificationsByUserID(ctx, apikey.UserID)
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "failed to call count unread inbox notifications", slog.Error(err))
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to call count unread inbox notifications.",
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UpdateInboxNotificationReadStatusResponse{
|
|
Notification: convertInboxNotificationResponse(ctx, api.Logger, notification),
|
|
UnreadCount: int(unreadCount),
|
|
})
|
|
}
|
|
|
|
// markAllInboxNotificationsAsRead marks as read all unread notifications for authenticated user.
|
|
// @Summary Mark all unread notifications as read
|
|
// @ID mark-all-unread-notifications-as-read
|
|
// @Security CoderSessionToken
|
|
// @Tags Notifications
|
|
// @Success 204
|
|
// @Router /api/v2/notifications/inbox/mark-all-as-read [put]
|
|
func (api *API) markAllInboxNotificationsAsRead(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
apikey = httpmw.APIKey(r)
|
|
)
|
|
|
|
if !api.authorizeInbox(rw, r, apikey.UserID, policy.ActionUpdate) {
|
|
return
|
|
}
|
|
|
|
err := api.Database.MarkAllInboxNotificationsAsRead(ctx, database.MarkAllInboxNotificationsAsReadParams{
|
|
UserID: apikey.UserID,
|
|
ReadAt: sql.NullTime{Time: dbtime.Now(), Valid: true},
|
|
})
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "failed to mark all unread notifications as read", slog.Error(err))
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to mark all unread notifications as read.",
|
|
})
|
|
return
|
|
}
|
|
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// authorizeInbox writes a 403 naming the first refused action's scope unless
|
|
// the caller may perform every action on their own inbox.
|
|
func (api *API) authorizeInbox(rw http.ResponseWriter, r *http.Request, userID uuid.UUID, actions ...policy.Action) bool {
|
|
inbox := rbac.ResourceInboxNotification.WithOwner(userID.String())
|
|
for _, action := range actions {
|
|
if !api.Authorize(r, action, inbox) {
|
|
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
|
|
Message: "Forbidden.",
|
|
Detail: fmt.Sprintf("This request requires the %s:%s scope.", inbox.Type, action),
|
|
})
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|