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
}