Files
coder/coderd/x/chatd/recording.go
T

244 lines
7.6 KiB
Go

package chatd
import (
"context"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
type recordingResult struct {
recordingFileID string
thumbnailFileID string
}
// stopAndStoreRecording stops the desktop recording, downloads the
// multipart response containing the MP4 and optional thumbnail, and
// stores them in chat_files. Only called when the subagent completed
// successfully. Returns file IDs on success, empty fields on any
// failure. All errors are logged but not propagated; recording is
// best-effort.
func (p *Server) stopAndStoreRecording(
ctx context.Context,
conn workspacesdk.AgentConn,
recordingID string,
ownerID uuid.UUID,
workspaceID uuid.NullUUID,
chatID uuid.UUID,
) recordingResult {
var result recordingResult
select {
case p.recordingSem <- struct{}{}:
defer func() { <-p.recordingSem }()
case <-ctx.Done():
p.logger.Warn(ctx, "context canceled waiting for recording semaphore", slog.Error(ctx.Err()))
return result
}
resp, err := conn.StopDesktopRecording(ctx,
workspacesdk.StopDesktopRecordingRequest{RecordingID: recordingID})
if err != nil {
p.logger.Warn(ctx, "failed to stop desktop recording",
slog.Error(err))
return result
}
defer resp.Body.Close()
_, params, err := mime.ParseMediaType(resp.ContentType)
if err != nil {
p.logger.Warn(ctx, "failed to parse content type from recording response",
slog.F("content_type", resp.ContentType),
slog.Error(err))
return result
}
boundary := params["boundary"]
if boundary == "" {
p.logger.Warn(ctx, "missing boundary in recording response content type",
slog.F("content_type", resp.ContentType))
return result
}
if !workspaceID.Valid {
p.logger.Warn(ctx, "chat has no workspace, cannot store recording")
return result
}
// The chatd actor is used here because the recording is stored on
// behalf of the chat system, not a specific user request.
//nolint:gocritic // AsChatd is required to read the workspace for org lookup.
ws, err := p.db.GetWorkspaceByID(dbauthz.AsChatd(ctx), workspaceID.UUID)
if err != nil {
p.logger.Warn(ctx, "failed to resolve workspace for recording",
slog.Error(err))
return result
}
mr := multipart.NewReader(resp.Body, boundary)
// Context cancellation is checked between parts. Within a
// part read, cancellation relies on Go's HTTP transport closing
// the underlying connection when the context is done, which
// interrupts the blocked io.ReadAll.
// First pass: parse all multipart parts into memory.
// The agent sends at most two parts: one video/mp4 and one
// optional image/jpeg thumbnail. Cap the number of parts to
// prevent a malicious or broken agent from forcing the server
// into an unbounded parsing loop.
const maxParts = 2
var videoData, thumbnailData []byte
for range maxParts {
if ctx.Err() != nil {
p.logger.Warn(ctx, "context canceled while reading recording parts", slog.Error(ctx.Err()))
break
}
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
p.logger.Warn(ctx, "error reading next multipart part", slog.Error(err))
break
}
contentType := part.Header.Get("Content-Type")
// Select the read limit based on content type so that
// thumbnails (image/jpeg) do not allocate up to
// MaxRecordingSize (100 MB) before the size check rejects
// them. Unknown types use a small default since they are
// discarded below.
maxSize := int64(1 << 20) // 1 MB default for unknown types
switch contentType {
case "video/mp4":
maxSize = int64(workspacesdk.MaxRecordingSize)
case "image/jpeg":
maxSize = int64(workspacesdk.MaxThumbnailSize)
}
data, err := io.ReadAll(io.LimitReader(part, maxSize+1))
if err != nil {
p.logger.Warn(ctx, "failed to read recording part data",
slog.F("content_type", contentType),
slog.Error(err))
continue
}
if int64(len(data)) > maxSize {
p.logger.Warn(ctx, "recording part exceeds maximum size, skipping",
slog.F("content_type", contentType),
slog.F("size", len(data)),
slog.F("max_size", maxSize))
continue
}
if len(data) == 0 {
p.logger.Warn(ctx, "recording part is empty, skipping",
slog.F("content_type", contentType))
continue
}
switch contentType {
case "video/mp4":
if videoData != nil {
p.logger.Warn(ctx, "duplicate video/mp4 part in recording response, skipping")
continue
}
videoData = data
case "image/jpeg":
if thumbnailData != nil {
p.logger.Warn(ctx, "duplicate image/jpeg part in recording response, skipping")
continue
}
thumbnailData = data
default:
p.logger.Debug(ctx, "skipping unknown part content type",
slog.F("content_type", contentType))
}
}
// Second pass: store the collected data in the database and
// link it to the parent chat atomically. Insert + link must
// happen in the same transaction so that we never end up with
// chat_files rows that lack chat_file_links entries (which the
// purge job would treat as orphans and which users cannot view).
// Errors inside the transaction cause both inserts to be rolled
// back, so recording remains best-effort end-to-end.
txResult := struct {
recordingFileID string
thumbnailFileID string
}{}
//nolint:gocritic // AsChatd is required to insert and link chat files from the recording pipeline.
txErr := p.db.InTx(func(tx database.Store) error {
var fileIDs []uuid.UUID
if videoData != nil {
row, err := tx.InsertChatFile(dbauthz.AsChatd(ctx), database.InsertChatFileParams{
OwnerID: ownerID,
OrganizationID: ws.OrganizationID,
Name: fmt.Sprintf("recording-%s.mp4", p.clock.Now().UTC().Format("2006-01-02T15-04-05Z")),
Mimetype: "video/mp4",
Data: videoData,
})
if err != nil {
return xerrors.Errorf("insert recording: %w", err)
}
txResult.recordingFileID = row.ID.String()
fileIDs = append(fileIDs, row.ID)
}
if thumbnailData != nil && txResult.recordingFileID != "" {
row, err := tx.InsertChatFile(dbauthz.AsChatd(ctx), database.InsertChatFileParams{
OwnerID: ownerID,
OrganizationID: ws.OrganizationID,
Name: fmt.Sprintf("thumbnail-%s.jpg", p.clock.Now().UTC().Format("2006-01-02T15-04-05Z")),
Mimetype: "image/jpeg",
Data: thumbnailData,
})
if err != nil {
return xerrors.Errorf("insert thumbnail: %w", err)
}
txResult.thumbnailFileID = row.ID.String()
fileIDs = append(fileIDs, row.ID)
}
if len(fileIDs) == 0 {
return nil
}
rejected, err := tx.LinkChatFiles(dbauthz.AsChatd(ctx), database.LinkChatFilesParams{
ChatID: chatID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: fileIDs,
})
if err != nil {
return xerrors.Errorf("link recording files: %w", err)
}
if rejected > 0 {
// The cap would be exceeded. Rolling back ensures the
// files are not persisted as orphans. MaxChatFileIDs is
// 20 today; hitting the cap with a 1-2 file batch means
// the chat is already saturated, and silently dropping
// the recording is preferable to leaving an unreachable
// blob.
return xerrors.Errorf("chat file link cap exceeded: %d file(s) rejected", rejected)
}
return nil
}, nil)
if txErr != nil {
p.logger.Warn(ctx, "failed to store and link recording",
slog.F("chat_id", chatID),
slog.Error(txErr))
return result
}
result.recordingFileID = txResult.recordingFileID
result.thumbnailFileID = txResult.thumbnailFileID
return result
}