package classes import ( "context" "errors" "fmt" "net/url" "strings" "git.golem15.com/golem15/sm-seo-plugin/classes/schema" "git.golem15.com/golem15/sm-seo-plugin/models" "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" ElementSchema = "golem15-seo-schema" ElementGraph = "golem15-seo-graph" ElementParent = "golem15-seo-parent" ) // 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, }, { Name: "seoschema", Element: ElementSchema, Scripts: elementScripts, Styles: elementStyles, Options: []string{"kind", "contentType"}, CheckOptions: checkKind, Decode: decodeSchema(app), }, { Name: "seoparent", Element: ElementParent, Scripts: elementScripts, Styles: elementStyles, Options: []string{"contentType"}, Decode: decodeParent(app), }, } } // checkKind accepts the content kinds of pact.ContentDescriptor. func checkKind(options map[string]any) error { if raw, ok := options["kind"]; ok { switch s, _ := raw.(string); s { case "", schema.KindArticle, schema.KindCollection, schema.KindProfile, schema.KindPage: default: return fmt.Errorf("kind must be article, collection, profile or page, got %v", raw) } } return nil } // optionString reads a string option. func optionString(options map[string]any, key string) string { s, _ := options[key].(string) return s } // SchemaRefusal renders a refused seo_schema value with the schema.* copy // in the request locale (UI-SPEC C17). func (s *Service) SchemaRefusal(ctx context.Context, kind string, err error) string { var se *schema.Error if !errors.As(err, &se) { return s.PhraseCtx(ctx, "golem15.seo::lang.schema.invalid_json", map[string]string{"error": err.Error()}) } switch se.Code { case schema.CodeInvalidJSON: return s.PhraseCtx(ctx, "golem15.seo::lang.schema.invalid_json", map[string]string{"error": se.Detail}) case schema.CodeTypeUnknown: return s.PhraseCtx(ctx, "golem15.seo::lang.schema.type_unknown", map[string]string{"type": kind}) case schema.CodeFieldInvalid: label, message := "golem15.seo::lang.schema.field_price", "golem15.seo::lang.schema.price_invalid" if se.Field == "currency" { label, message = "golem15.seo::lang.schema.field_currency", "golem15.seo::lang.schema.currency_invalid" } return s.PhraseCtx(ctx, "golem15.seo::lang.schema.field_invalid", map[string]string{ "field": s.PhraseCtx(ctx, label, nil), "message": s.PhraseCtx(ctx, message, nil), }) } keys := map[string]string{ schema.CodeNotObject: "golem15.seo::lang.schema.not_object", schema.CodeTooLarge: "golem15.seo::lang.schema.too_large", schema.CodeTooDeep: "golem15.seo::lang.schema.too_deep", schema.CodeScriptTag: "golem15.seo::lang.schema.script_tag", } return s.PhraseCtx(ctx, keys[se.Code], nil) } // SchemaVariant is the registry kind of a record of contentType, "" for // other types. func SchemaVariant(record any) string { if p, ok := record.(*models.Page); ok && p != nil { return p.Kind } return "" } // decodeSchema checks and normalises the seoschema value (E8): a JSON // string or "" (UI-SPEC C17); every refusal answers the schema.* copy. func decodeSchema(app func() *backpack.App) func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) { return func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { raw, ok := stringValue(in.Value) if !ok { return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"golem15.seo::lang.schema.not_object"}} } kind := optionString(in.Options, "kind") stored, _, err := schema.Normalize(kind, SchemaVariant(in.Record), raw) if err != nil { svc, serr := NewService(app()) if serr != nil { return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{err.Error()}} } return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{svc.SchemaRefusal(ctx, kind, err)}} } return pact.FormFieldDecoded{Value: stored}, nil } } // decodeParent accepts "" (the first parent) or one of the record's // parents as :. func decodeParent(app func() *backpack.App) func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) { return func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { value := in.Value if m, isMap := value.(map[string]any); isMap { // The loaded {selected, options} sent back unchanged. value = m["selected"] } raw, ok := stringValue(value) raw = strings.TrimSpace(raw) if !ok { return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"golem15.seo::lang.api.value_invalid"}} } if raw == "" { return pact.FormFieldDecoded{Value: ""}, nil } svc, err := NewService(app()) if err != nil { return pact.FormFieldDecoded{}, err } id := "" if in.Record != nil && in.DB != nil { id, _ = RecordKey(in.DB, in.Record) } for _, opt := range svc.ParentOptions(ctx, optionString(in.Options, "contentType"), id, "") { if opt.Value == raw { return pact.FormFieldDecoded{Value: raw}, nil } } return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"golem15.seo::lang.api.value_invalid"}} } } // 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 "/\x", // which browsers read as another host) 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, "//") && !strings.HasPrefix(v, `/\`) } u, err := url.Parse(v) return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" } // SchemaProblem is a refused seo_schema value as an API problem. func SchemaProblem(kind string, err error) models.Problem { var se *schema.Error if !errors.As(err, &se) { return models.Problem{Key: "golem15.seo::lang.schema.invalid_json", Params: map[string]string{"error": err.Error()}} } switch se.Code { case schema.CodeInvalidJSON: return models.Problem{Key: "golem15.seo::lang.schema.invalid_json", Params: map[string]string{"error": se.Detail}} case schema.CodeTypeUnknown: return models.Problem{Key: "golem15.seo::lang.schema.type_unknown", Params: map[string]string{"type": kind}} case schema.CodeFieldInvalid: if se.Field == "currency" { return models.Problem{Key: "golem15.seo::lang.schema.currency_invalid"} } return models.Problem{Key: "golem15.seo::lang.schema.price_invalid"} case schema.CodeNotObject: return models.Problem{Key: "golem15.seo::lang.schema.not_object"} case schema.CodeTooLarge: return models.Problem{Key: "golem15.seo::lang.schema.too_large"} case schema.CodeTooDeep: return models.Problem{Key: "golem15.seo::lang.schema.too_deep"} } return models.Problem{Key: "golem15.seo::lang.schema.script_tag"} }