308aada5f9
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
175 lines
5.1 KiB
Go
175 lines
5.1 KiB
Go
// Package previewfeedback validates task-owned rendered-page feedback at every
|
|
// persistence and delivery boundary.
|
|
package previewfeedback
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
const (
|
|
MaxItems = 100
|
|
MaxCommentBytes = 64 * 1024
|
|
MaxCaptureFieldBytes = 256 * 1024
|
|
MaxCollectionPayloadBytes = 1024 * 1024
|
|
MaxScreenshots = 10
|
|
MaxScreenshotBytes int64 = 10 * 1024 * 1024
|
|
MaxScreenshotPixels int64 = 16_000_000
|
|
MaxPromptBytes = 1024 * 1024
|
|
MaxSourceLabelBytes = 4 * 1024
|
|
MaxPageIdentityBytes = 16 * 1024
|
|
)
|
|
|
|
var (
|
|
ErrCommentRequired = errors.New("preview feedback comment is required")
|
|
ErrCommentTooLarge = errors.New("preview feedback comment is too large")
|
|
ErrCaptureTooLarge = errors.New("preview feedback capture is too large")
|
|
ErrCaptureInvalid = errors.New("preview feedback capture is invalid")
|
|
ErrCollectionTooLarge = errors.New("preview feedback collection is too large")
|
|
ErrTooManyItems = errors.New("too many preview feedback items")
|
|
ErrTooManyScreenshots = errors.New("too many preview feedback screenshots")
|
|
ErrSourceIdentityLarge = errors.New("preview feedback source identity is too large")
|
|
)
|
|
|
|
func ValidateComment(comment string) error {
|
|
if strings.TrimSpace(comment) == "" {
|
|
return ErrCommentRequired
|
|
}
|
|
if len(comment) > MaxCommentBytes {
|
|
return ErrCommentTooLarge
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateItem enforces kind-specific capture fields and bounded source data.
|
|
func ValidateItem(item *models.TaskPreviewFeedback) error {
|
|
if item == nil {
|
|
return ErrCaptureInvalid
|
|
}
|
|
if err := ValidateComment(item.Comment); err != nil {
|
|
return err
|
|
}
|
|
if err := validateSource(item); err != nil {
|
|
return err
|
|
}
|
|
if err := validateOptionalJSON(item.CaptureRect); err != nil {
|
|
return err
|
|
}
|
|
switch item.Kind {
|
|
case models.TaskPreviewFeedbackText:
|
|
return validateTextItem(item)
|
|
case models.TaskPreviewFeedbackElement:
|
|
return validateElementItem(item)
|
|
case models.TaskPreviewFeedbackScreenshot:
|
|
return validateScreenshotItem(item)
|
|
default:
|
|
return ErrCaptureInvalid
|
|
}
|
|
}
|
|
|
|
func validateSource(item *models.TaskPreviewFeedback) error {
|
|
if strings.TrimSpace(item.SourceLabel) == "" || strings.TrimSpace(item.PageRoute) == "" {
|
|
return ErrCaptureInvalid
|
|
}
|
|
if len(item.SourceLabel) > MaxSourceLabelBytes || len(item.SourcePath) > MaxPageIdentityBytes ||
|
|
len(item.PageRoute) > MaxPageIdentityBytes || len(item.PageTitle) > MaxPageIdentityBytes {
|
|
return ErrSourceIdentityLarge
|
|
}
|
|
switch item.SourceKind {
|
|
case models.TaskPreviewFeedbackBrowser:
|
|
if item.SourcePath != "" {
|
|
return ErrCaptureInvalid
|
|
}
|
|
case models.TaskPreviewFeedbackHTMLFile:
|
|
if strings.TrimSpace(item.SourcePath) == "" {
|
|
return ErrCaptureInvalid
|
|
}
|
|
default:
|
|
return ErrCaptureInvalid
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateTextItem(item *models.TaskPreviewFeedback) error {
|
|
if strings.TrimSpace(item.SelectedText) == "" || len(item.TextAnchor) == 0 ||
|
|
len(item.ElementSnapshot) != 0 || item.ScreenshotAttachmentID != "" {
|
|
return ErrCaptureInvalid
|
|
}
|
|
if len(item.SelectedText) > MaxCaptureFieldBytes {
|
|
return ErrCaptureTooLarge
|
|
}
|
|
return validateRequiredJSON(item.TextAnchor)
|
|
}
|
|
|
|
func validateElementItem(item *models.TaskPreviewFeedback) error {
|
|
if item.SelectedText != "" || len(item.TextAnchor) != 0 || len(item.ElementSnapshot) == 0 ||
|
|
len(item.CaptureRect) == 0 || item.ScreenshotAttachmentID != "" {
|
|
return ErrCaptureInvalid
|
|
}
|
|
return validateRequiredJSON(item.ElementSnapshot)
|
|
}
|
|
|
|
func validateScreenshotItem(item *models.TaskPreviewFeedback) error {
|
|
if item.SelectedText != "" || len(item.TextAnchor) != 0 || len(item.ElementSnapshot) != 0 ||
|
|
len(item.CaptureRect) == 0 || item.ScreenshotAttachmentID == "" {
|
|
return ErrCaptureInvalid
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateCollection applies count and aggregate non-image payload limits.
|
|
func ValidateCollection(items []*models.TaskPreviewFeedback) error {
|
|
if len(items) > MaxItems {
|
|
return ErrTooManyItems
|
|
}
|
|
total := 0
|
|
screenshots := 0
|
|
for _, item := range items {
|
|
if err := ValidateItem(item); err != nil {
|
|
return err
|
|
}
|
|
if item.Kind == models.TaskPreviewFeedbackScreenshot {
|
|
screenshots++
|
|
}
|
|
total += itemPayloadBytes(item)
|
|
}
|
|
if screenshots > MaxScreenshots {
|
|
return ErrTooManyScreenshots
|
|
}
|
|
if total > MaxCollectionPayloadBytes {
|
|
return ErrCollectionTooLarge
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRequiredJSON(value json.RawMessage) error {
|
|
if len(value) == 0 {
|
|
return ErrCaptureInvalid
|
|
}
|
|
return validateOptionalJSON(value)
|
|
}
|
|
|
|
func validateOptionalJSON(value json.RawMessage) error {
|
|
if len(value) == 0 {
|
|
return nil
|
|
}
|
|
if len(value) > MaxCaptureFieldBytes {
|
|
return ErrCaptureTooLarge
|
|
}
|
|
if !json.Valid(value) {
|
|
return fmt.Errorf("%w: malformed JSON", ErrCaptureInvalid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func itemPayloadBytes(item *models.TaskPreviewFeedback) int {
|
|
return len(item.Comment) + len(item.SourceSessionID) + len(item.SourceLabel) +
|
|
len(item.SourcePath) + len(item.PageRoute) + len(item.PageTitle) +
|
|
len(item.SelectedText) + len(item.TextAnchor) + len(item.ElementSnapshot) +
|
|
len(item.CaptureRect)
|
|
}
|