package classes import ( "context" "fmt" "net/url" "strings" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/pact" ) // The elements of the SEO field types and widgets (assets/src/elements). const ( ElementText = "golem15-seo-text" ElementRobots = "golem15-seo-robots" ElementSERP = "golem15-seo-serp" ElementImage = "golem15-seo-image" ElementRobotsTxt = "golem15-seo-robotstxt" ElementRobotsTester = "golem15-seo-robotstester" ) // The element bundle every SEO field type loads. var ( elementScripts = []string{"assets/dist/seo.js"} elementStyles = []string{"assets/dist/seo.css"} ) // errRobotsInvalid is the refusal of a malformed robots value. const errRobotsInvalid = "golem15.seo::lang.robots.invalid" // errRobotsTooLarge refuses a robots.txt over MaxRobotsBytes. const errRobotsTooLarge = "golem15.seo::lang.robots.too_large" // FieldTypes returns the SEO admin field types: // - seotext and mlseotext (E1): text with a live length counter; option // measure: title (input) or description (textarea); // - seorobots and mlseorobots (E2): the robots directives editor; option // scope: record, rule or type; Decode refuses a malformed value; // - seoimage (E7): an uploaded social image stored in the media bucket, // its URL as the value (D-100); uploads need permission; // - robotstxt (E4): the robots.txt editor with live lint; Decode refuses // a file over 500 KB (D-22). // // app is asked for the booted application at request time. func FieldTypes(app func() *backpack.App, permission string) []pact.FormFieldType { text := func(name string, translatable bool) pact.FormFieldType { return pact.FormFieldType{ Name: name, Element: ElementText, Translatable: translatable, Scripts: elementScripts, Styles: elementStyles, Options: []string{"measure"}, CheckOptions: checkMeasure, Decode: decodeText, } } robots := func(name string, translatable bool) pact.FormFieldType { return pact.FormFieldType{ Name: name, Element: ElementRobots, Translatable: translatable, Scripts: elementScripts, Styles: elementStyles, Options: []string{"scope"}, CheckOptions: checkScope, Decode: decodeRobots, } } return []pact.FormFieldType{ text("seotext", false), text("mlseotext", true), robots("seorobots", false), robots("mlseorobots", true), { Name: "seoimage", Element: ElementImage, Scripts: elementScripts, Styles: elementStyles, Decode: decodeImage, Upload: UploadImage(app, permission), }, { Name: "robotstxt", Element: ElementRobotsTxt, Scripts: elementScripts, Styles: elementStyles, Decode: decodeRobotsTxt, }, } } // decodeRobotsTxt stores a robots.txt with Unix line ends and refuses one // over MaxRobotsBytes (robots.too_large); lint findings are warnings in // the element and never block a save. func decodeRobotsTxt(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { s, ok := stringValue(in.Value) if !ok { return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"The " + in.Field + " field must be text."}} } s = strings.ReplaceAll(s, "\r\n", "\n") if len(s) > MaxRobotsBytes { return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{errRobotsTooLarge}} } return pact.FormFieldDecoded{Value: s}, nil } func checkMeasure(options map[string]any) error { if raw, ok := options["measure"]; ok { if s, _ := raw.(string); s != "title" && s != "description" { return fmt.Errorf("measure must be title or description, got %v", raw) } } return nil } func checkScope(options map[string]any) error { if raw, ok := options["scope"]; ok { if s, _ := raw.(string); s != "record" && s != "rule" && s != "type" { return fmt.Errorf("scope must be record, rule or type, got %v", raw) } } return nil } // stringValue reads a submitted value as text; nil is "". func stringValue(v any) (string, bool) { switch s := v.(type) { case nil: return "", true case string: return s, true } return "", false } func decodeText(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { s, ok := stringValue(in.Value) if !ok { return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"The " + in.Field + " field must be text."}} } return pact.FormFieldDecoded{Value: strings.TrimSpace(s)}, nil } // decodeRobots stores a robots value in the fixed order; a malformed value // (reachable only through MCP or an import) is refused. func decodeRobots(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { s, ok := stringValue(in.Value) if !ok { return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{errRobotsInvalid}} } normal, err := NormalizeRobots(s) if err != nil { return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{errRobotsInvalid}} } return pact.FormFieldDecoded{Value: normal}, nil } // decodeImage accepts an empty value, a path or an http(s) URL. func decodeImage(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { s, ok := stringValue(in.Value) s = strings.TrimSpace(s) if !ok || !ValidImageURL(s) { return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{errImageURL}} } return pact.FormFieldDecoded{Value: s}, nil } // ValidURLRef reports whether v is a path ("/x", not "//x") or an absolute // http(s) URL with a host, without spaces or control characters. func ValidURLRef(v string) bool { if v == "" || strings.ContainsFunc(v, func(r rune) bool { return r <= ' ' || r == 0x7f }) { return false } if strings.HasPrefix(v, "/") { return !strings.HasPrefix(v, "//") } u, err := url.Parse(v) return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" }