mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
Fixes all our Go file imports to match the preferred spec that we've _mostly_ been using. For example: ``` import ( "context" "time" "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" "gopkg.in/natefinch/lumberjack.v2" "cdr.dev/slog/v3" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/serpent" ) ``` 3 groups: standard library, 3rd partly libs, Coder libs. This PR makes the change across the codebase. The PR in the stack above modifies our formatting to maintain this state of affairs, and is a separate PR so it's possible to review that one in detail.
451 lines
16 KiB
Go
451 lines
16 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"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"
|
|
markdown "github.com/coder/coder/v2/coderd/render"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/wsjson"
|
|
"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.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,
|
|
}
|
|
|
|
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 /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")
|
|
)
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
go httpapi.Heartbeat(ctx, conn)
|
|
defer conn.Close(websocket.StatusNormalClosure, "connection closed")
|
|
|
|
encoder := wsjson.NewEncoder[codersdk.GetInboxNotificationResponse](conn, websocket.MessageText)
|
|
defer encoder.Close(websocket.StatusNormalClosure)
|
|
|
|
// Log the request immediately instead of after it completes.
|
|
if rl := loggermw.RequestLoggerFromContext(ctx); rl != nil {
|
|
rl.WriteLog(ctx, http.StatusAccepted)
|
|
}
|
|
|
|
for {
|
|
select {
|
|
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 /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
|
|
}
|
|
|
|
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 /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
|
|
}
|
|
|
|
err := api.Database.UpdateInboxNotificationReadStatus(ctx, database.UpdateInboxNotificationReadStatusParams{
|
|
ID: notificationID,
|
|
ReadAt: func() sql.NullTime {
|
|
if body.IsRead {
|
|
return sql.NullTime{
|
|
Time: dbtime.Now(),
|
|
Valid: true,
|
|
}
|
|
}
|
|
|
|
return sql.NullTime{}
|
|
}(),
|
|
})
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
updatedNotification, err := api.Database.GetInboxNotificationByID(ctx, notificationID)
|
|
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
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UpdateInboxNotificationReadStatusResponse{
|
|
Notification: convertInboxNotificationResponse(ctx, api.Logger, updatedNotification),
|
|
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 /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)
|
|
)
|
|
|
|
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)
|
|
}
|