7.4 KB
input.go
package validate

import (
	"encoding/json"
	"fmt"
	"net/http"
	"regexp"

	"github.com/readysite/readysite/website/internal/content/schema"
	"github.com/readysite/readysite/website/models"
)

// slugPattern matches valid slugs: lowercase alphanumeric and hyphens, 1-50 chars
var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$|^[a-z0-9]$`)

// reservedSlugs are routes that cannot be used as page/collection IDs
var reservedSlugs = map[string]bool{
	"admin": true, "api": true, "auth": true, "setup": true,
	"files": true, "preview": true, "static": true, "health": true,
}

// Slug checks if a slug is valid for use as an ID.
// Returns an error if invalid, nil if valid.
func Slug(slug string) error {
	if slug == "" {
		return nil // Empty is OK, will be auto-generated
	}
	if len(slug) > 50 {
		return fmt.Errorf("slug must be 50 characters or less")
	}
	if !slugPattern.MatchString(slug) {
		return fmt.Errorf("slug must contain only lowercase letters, numbers, and hyphens")
	}
	if reservedSlugs[slug] {
		return fmt.Errorf("'%s' is a reserved name and cannot be used", slug)
	}
	return nil
}

// SlugAvailable checks if a slug is available for a given resource type.
// resourceType should be "page" or "collection".
func SlugAvailable(slug, resourceType string) error {
	if slug == "" {
		return nil
	}
	var exists bool
	switch resourceType {
	case "page":
		_, err := models.Pages.Get(slug)
		exists = err == nil
	case "collection":
		_, err := models.Collections.Get(slug)
		exists = err == nil
	}
	if exists {
		return fmt.Errorf("a %s with this slug already exists", resourceType)
	}
	return nil
}

// Input validation limits
const (
	MaxTitleLength       = 200
	MaxDescriptionLength = 1000
	MaxHTMLLength        = 1024 * 1024 // 1MB
	MaxNameLength        = 100
	MaxSchemaLength      = 64 * 1024 // 64KB
)

// Title validates a title field.
func Title(title string) error {
	if title == "" {
		return fmt.Errorf("title is required")
	}
	if len(title) > MaxTitleLength {
		return fmt.Errorf("title must be %d characters or less", MaxTitleLength)
	}
	return nil
}

// Description validates a description field.
func Description(description string) error {
	if len(description) > MaxDescriptionLength {
		return fmt.Errorf("description must be %d characters or less", MaxDescriptionLength)
	}
	return nil
}

// HTML validates HTML content.
func HTML(html string) error {
	if len(html) > MaxHTMLLength {
		return fmt.Errorf("content is too large (max %d bytes)", MaxHTMLLength)
	}
	return nil
}

// Name validates a name field (for collections, partials, etc.).
func Name(name string) error {
	if name == "" {
		return fmt.Errorf("name is required")
	}
	if len(name) > MaxNameLength {
		return fmt.Errorf("name must be %d characters or less", MaxNameLength)
	}
	return nil
}

// ValidFieldTypes is the set of valid field types.
var ValidFieldTypes = map[string]bool{
	schema.Text:     true,
	schema.Number:   true,
	schema.Bool:     true,
	schema.Date:     true,
	schema.Email:    true,
	schema.URL:      true,
	schema.Select:   true,
	schema.Relation: true,
	schema.File:     true,
	schema.JSON:     true,
	schema.GeoPoint: true,
	schema.Editor:   true,
	schema.Autodate: true,
}

// Schema validates a collection schema JSON.
func Schema(schemaJSON string) error {
	if schemaJSON == "" {
		return nil // Empty schema is valid
	}

	if len(schemaJSON) > MaxSchemaLength {
		return fmt.Errorf("schema is too large (max %d bytes)", MaxSchemaLength)
	}

	// Parse as JSON array
	var fields []schema.Field
	if err := json.Unmarshal([]byte(schemaJSON), &fields); err != nil {
		return fmt.Errorf("invalid JSON: %v. Schema must be a JSON array like [{\"name\":\"title\",\"type\":\"text\"}]", err)
	}

	// Validate each field
	fieldNames := make(map[string]bool)
	for i, field := range fields {
		// Check required fields
		if field.Name == "" {
			return fmt.Errorf("field %d: 'name' is required", i+1)
		}
		if field.Type == "" {
			return fmt.Errorf("field '%s': 'type' is required", field.Name)
		}

		// Check valid type
		if !ValidFieldTypes[field.Type] {
			return fmt.Errorf("field '%s': invalid type '%s'. Valid types: text, number, bool, date, email, url, select, relation, file, json, geopoint, editor, autodate", field.Name, field.Type)
		}

		// Check for duplicate names
		if fieldNames[field.Name] {
			return fmt.Errorf("duplicate field name: '%s'", field.Name)
		}
		fieldNames[field.Name] = true

		// Type-specific validation
		if err := validateFieldOptions(field); err != nil {
			return err
		}
	}

	return nil
}

// validateFieldOptions checks type-specific requirements.
func validateFieldOptions(field schema.Field) error {
	switch field.Type {
	case schema.Select:
		// Select requires values option
		if field.Options == nil {
			return fmt.Errorf("field '%s': select type requires 'options.values' array", field.Name)
		}
		values, ok := field.Options["values"]
		if !ok {
			return fmt.Errorf("field '%s': select type requires 'options.values' array", field.Name)
		}
		// Validate values is an array
		switch v := values.(type) {
		case []any:
			if len(v) == 0 {
				return fmt.Errorf("field '%s': select 'values' array cannot be empty", field.Name)
			}
		case []string:
			if len(v) == 0 {
				return fmt.Errorf("field '%s': select 'values' array cannot be empty", field.Name)
			}
		case map[string]any:
			// Also accept object format {"key": "Label"} for labeled options
			if len(v) == 0 {
				return fmt.Errorf("field '%s': select 'values' cannot be empty", field.Name)
			}
		default:
			return fmt.Errorf("field '%s': select 'values' must be an array or object", field.Name)
		}

	case schema.Relation:
		// Relation requires collection option
		if field.Options == nil {
			return fmt.Errorf("field '%s': relation type requires 'options.collection' string", field.Name)
		}
		collection, ok := field.Options["collection"]
		if !ok {
			return fmt.Errorf("field '%s': relation type requires 'options.collection' string", field.Name)
		}
		if _, ok := collection.(string); !ok {
			return fmt.Errorf("field '%s': 'options.collection' must be a string", field.Name)
		}
	}

	return nil
}

// PageInput validates all page input fields.
// Returns the first validation error, or nil if all valid.
func PageInput(title, description, html string) error {
	if err := Title(title); err != nil {
		return err
	}
	if err := Description(description); err != nil {
		return err
	}
	if err := HTML(html); err != nil {
		return err
	}
	return nil
}

// CollectionInput validates all collection input fields.
func CollectionInput(name, description, schema string) error {
	if err := Name(name); err != nil {
		return err
	}
	if err := Description(description); err != nil {
		return err
	}
	if err := Schema(schema); err != nil {
		return err
	}
	return nil
}

// ParseFormData converts HTTP form data to a map based on collection schema fields.
// It handles type conversion for Number and Bool fields.
func ParseFormData(collection *models.Collection, r *http.Request) (map[string]any, error) {
	data := make(map[string]any)
	fields, err := schema.GetFields(collection)
	if err != nil {
		return nil, err
	}

	for _, field := range fields {
		value := r.FormValue(field.Name)
		if value != "" {
			switch field.Type {
			case schema.Number:
				// Try to parse as number
				var num float64
				if err := json.Unmarshal([]byte(value), &num); err == nil {
					data[field.Name] = num
				}
			case schema.Bool:
				data[field.Name] = value == "on" || value == "true"
			default:
				data[field.Name] = value
			}
		}
	}

	return data, nil
}
← Back