package classes import ( "context" "net/url" "strings" "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) // Where a change addressed by URL is written (D-11). const ( WrittenToRecord = "record" WrittenToRule = "rule" ) // Owner is the owner of a URL's SEO (D-11, P-25): the record behind the // URL in the matched locale, or an exact URL rule. URL is the address in // the site's form (normalised path with its query); RuleID is the rule's // id once it exists. Published is true for a published record. type Owner struct { WrittenTo string `json:"written_to"` URL string `json:"url"` Type string `json:"type,omitempty"` ID string `json:"id,omitempty"` Locale string `json:"locale,omitempty"` RuleID uint `json:"rule_id,omitempty"` Published bool `json:"-"` } // FindOwner resolves the owner of rawURL (a path or an absolute URL of the // site) inside db: the URL is normalised with the site's address rules, // then the signpost patterns (MatchPath) name the record behind it. An // exact match without a query is the record's SEO in the matched locale; // anything else, or forceRule, is an exact URL rule on the normalised // address. ErrBadURL refuses an input that is not a path or http(s) URL. func (s *Service) FindOwner(ctx context.Context, db *gorm.DB, rawURL string, forceRule bool) (Owner, pact.MenuItemType, error) { path, _, err := parseTarget(rawURL) if err != nil { return Owner{}, pact.MenuItemType{}, err } settings, err := LoadSettings(db) if err != nil { return Owner{}, pact.MenuItemType{}, err } norm, _ := Normalize(&url.URL{Path: path, RawQuery: rawQueryOf(rawURL)}, NormalizeOptionsOf(settings)) address := joinQuery(norm.Path, norm.RawQuery) if !forceRule && norm.RawQuery == "" { if reg := s.Registry(); reg != nil { unprefixed := s.UnprefixedLocale() key, match, err := reg.MatchPath(ctx, trimSlash(norm.Path), unprefixed) if err != nil { return Owner{}, pact.MenuItemType{}, err } if match != nil && match.Exact { typ, ok := reg.ContentType(key) if ok && typ.Content != nil { owner := Owner{WrittenTo: WrittenToRecord, URL: address, Type: key, ID: match.ID, Locale: match.Locale} if rec, err := reg.Describe(ctx, key, match.ID, unprefixed); err == nil && rec != nil { owner.Published = rec.Published } return owner, typ, nil } } } } owner := Owner{WrittenTo: WrittenToRule, URL: address} rule, found, err := ExactRule(db, address) if err != nil { return Owner{}, pact.MenuItemType{}, err } if found { owner.RuleID = rule.ID } return owner, pact.MenuItemType{}, nil } // ExactRule returns the exact URL rule of address; found is false when // there is none. func ExactRule(db *gorm.DB, address string) (models.Rule, bool, error) { var rows []models.Rule if err := db.Where("url = ? AND match = ?", address, models.MatchExact).Limit(1).Find(&rows).Error; err != nil { return models.Rule{}, false, err } if len(rows) == 0 { return models.Rule{}, false, nil } return rows[0], true, nil } // URLSEOWrite is one change of a URL's SEO (D-11): the address, the // locale of a record's entry (empty takes the locale the URL matched), // the values to write (column -> value; an absent column is left as it // is, an empty value inherits), and ForceRule to write a URL rule even // when a record is behind the URL. type URLSEOWrite struct { URL string Locale string Values map[string]string ForceRule bool } // URLSEOResult is where a URL change was written and the stored values // of the owner after the write. type URLSEOResult struct { Owner Values map[string]string `json:"values"` } // SetURLSEO writes in.Values to the owner of in.URL inside tx (D-11): the // record's SEO entry when a record is behind the URL, else an exact URL // rule, created when missing. Robots values are brought to the C5 grammar, // a canonical must be a path or an http(s) URL, and the focus keyphrase // belongs to records only; problems are refused with a *ProblemsError. // History records the write with the via of ctx. func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (URLSEOResult, error) { owner, typ, err := s.FindOwner(ctx, tx, in.URL, in.ForceRule) if err != nil { return URLSEOResult{}, err } values := map[string]string{} problems := map[string][]models.Problem{} known := map[string]bool{} for _, column := range EntryColumns { known[column] = true } for column, raw := range in.Values { v := strings.TrimSpace(raw) switch { case !known[column]: problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.field_unknown"}) continue case column == "keyphrase" && owner.WrittenTo == WrittenToRule && v != "": problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.keyphrase_record_only"}) continue case column == "robots" && v != "": normal, err := NormalizeRobots(v) if err != nil { problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.robots.invalid"}) continue } v = normal case column == "canonical" && v != "" && !ValidURLRef(v): problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.canonical_invalid"}) continue case column == "og_image" && !ValidImageURL(v): problems[column] = append(problems[column], models.Problem{Key: errImageURL}) continue } values[column] = v } if len(problems) > 0 { return URLSEOResult{}, &ProblemsError{Problems: problems} } if owner.WrittenTo == WrittenToRecord { locale := owner.Locale if l := strings.TrimSpace(in.Locale); l != "" { locale = l } owner.Locale = locale write := RecordValues{} for column, v := range values { write[column] = map[string]string{locale: v} } _, after, err := s.SaveRecord(ctx, tx, RecordWrite{Type: typ, ID: owner.ID, Values: write}) if err != nil { return URLSEOResult{}, err } out := map[string]string{} e := after[locale] for _, column := range EntryColumns { out[column] = EntryValue(e, column) } return URLSEOResult{Owner: owner, Values: out}, nil } rule := models.Rule{URL: owner.URL, Match: models.MatchExact} if owner.RuleID != 0 { if rule, err = LoadRule(tx, owner.RuleID); err != nil { return URLSEOResult{}, err } } for column, v := range values { SetRuleValue(&rule, column, v) } if err := s.SaveRule(ctx, tx, &rule); err != nil { return URLSEOResult{}, err } owner.RuleID = rule.ID out := map[string]string{} for _, p := range models.RuleProperties { out[p.Column] = rule.Value(p.Column) } return URLSEOResult{Owner: owner, Values: out}, nil }