diff --git a/classes/verification.go b/classes/verification.go new file mode 100644 index 0000000..574eba9 --- /dev/null +++ b/classes/verification.go @@ -0,0 +1,53 @@ +package classes + +import ( + "context" + "regexp" + "strings" +) + +// VerificationNamePattern is the form of a verification file name (D-05, +// T-32-21): no slashes, an html, txt or xml extension. +const VerificationNamePattern = `[A-Za-z0-9._-]+\.(html|txt|xml)` + +var verificationName = regexp.MustCompile(`^` + VerificationNamePattern + `$`) + +// VerificationFile is one stored search-engine verification file. +type VerificationFile struct { + Name string + Body string + ContentType string +} + +// verificationTypes are the content types per extension. +var verificationTypes = map[string]string{ + ".html": "text/html; charset=utf-8", + ".txt": "text/plain; charset=utf-8", + ".xml": "application/xml; charset=utf-8", +} + +// VerificationFileByName returns the verification file stored in the SEO +// settings under name (D-05, D-103), found false for an unknown or +// malformed name. The frontend serves it at the site root (Phase 33); the +// plugin never registers a root route for it. +func (s *Service) VerificationFileByName(ctx context.Context, name string) (VerificationFile, bool, error) { + if !verificationName.MatchString(name) { + return VerificationFile{}, false, nil + } + db, err := s.DB(ctx) + if err != nil { + return VerificationFile{}, false, err + } + settings, err := LoadSettings(db) + if err != nil { + return VerificationFile{}, false, err + } + for _, f := range settings.Files() { + if strings.TrimSpace(f.Filename) != name { + continue + } + ext := name[strings.LastIndex(name, "."):] + return VerificationFile{Name: name, Body: f.Body, ContentType: verificationTypes[strings.ToLower(ext)]}, true, nil + } + return VerificationFile{}, false, nil +} diff --git a/controllers/api/verification.go b/controllers/api/verification.go new file mode 100644 index 0000000..a855372 --- /dev/null +++ b/controllers/api/verification.go @@ -0,0 +1,38 @@ +package api + +import ( + "net/http" + + "git.golem15.com/golem15/sm-seo-plugin/classes" + "git.golem15.com/golem15/summercms/modules/wire" +) + +// Verification serves GET /_seo/api/v1/verification/{name} (D-05, D-103): +// the body of a search-engine verification file stored in the SEO +// settings, with the content type of its extension, for the frontend to +// serve at the site root. The body is never run: it carries nosniff and a +// sandboxing Content-Security-Policy. An unknown or malformed name is 404. +func Verification(service func() *classes.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + svc := service() + if svc == nil { + wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "SEO is not available"}) + return + } + file, found, err := svc.VerificationFileByName(r.Context(), r.PathValue("name")) + if err != nil { + log.Error("golem15.seo: verification file", "error", err) + wire.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal Server Error"}) + return + } + if !found { + wire.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "Not Found"}) + return + } + w.Header().Set("Content-Type", file.ContentType) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(file.Body)) + } +} diff --git a/robots_test.go b/robots_test.go index dda4472..1aac318 100644 --- a/robots_test.go +++ b/robots_test.go @@ -191,3 +191,45 @@ func TestRobotsScreen(t *testing.T) { t.Fatalf("tester context = %s, want the crawler list", body) } } + +// get runs an anonymous GET. +func (e *testEnv) get(t *testing.T, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.RemoteAddr = "198.51.100.11:4000" + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + return rec +} + +// TestVerificationFile checks the verification-file API (D-05, D-103, +// T-32-21): a stored file is served with its content type through +// /_seo/api/v1/verification/{name}; an unknown or malformed name is 404, +// and nothing is registered at the site root. +func TestVerificationFile(t *testing.T) { + env := bootEnv(t) + if err := env.db.Exec(`UPDATE golem15_seo_settings SET verify_files = ? WHERE id = 1`, + `[{"filename":"google1234.html","body":"google-site-verification: google1234.html"},{"filename":"BingSiteAuth.xml","body":"ABC"}]`).Error; err != nil { + t.Fatal(err) + } + rec := env.get(t, "/_seo/api/v1/verification/google1234.html") + if rec.Code != http.StatusOK || rec.Body.String() != "google-site-verification: google1234.html" { + t.Fatalf("verification file = %d %q, want the stored body", rec.Code, rec.Body.String()) + } + if h := rec.Header(); h.Get("Content-Type") != "text/html; charset=utf-8" || h.Get("X-Content-Type-Options") != "nosniff" || h.Get("X-Robots-Tag") != "noindex" || !strings.Contains(h.Get("Content-Security-Policy"), "sandbox") { + t.Fatalf("verification headers = %v", h) + } + if rec := env.get(t, "/_seo/api/v1/verification/BingSiteAuth.xml"); rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), "application/xml") { + t.Fatalf("xml verification file = %d %v", rec.Code, rec.Header()) + } + for _, path := range []string{ + "/_seo/api/v1/verification/missing.html", + "/_seo/api/v1/verification/google1234.php", + "/_seo/api/v1/verification/..%2Fgoogle1234.html", + "/google1234.html", + } { + if rec := env.get(t, path); rec.Code != http.StatusNotFound { + t.Fatalf("GET %s = %d %s, want 404", path, rec.Code, rec.Body.String()) + } + } +} diff --git a/routes.go b/routes.go index 600370c..e9e68a3 100644 --- a/routes.go +++ b/routes.go @@ -4,6 +4,7 @@ import ( "net/http" "time" + "git.golem15.com/golem15/sm-seo-plugin/classes" apicontrollers "git.golem15.com/golem15/sm-seo-plugin/controllers/api" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/surf" @@ -28,6 +29,11 @@ func (p *Plugin) Routes(r pact.Router) error { r.Group("/_seo/api/v1", nil, func(g pact.Router) { g.Get("/resolve", noindex(apicontrollers.Resolve(p.Service)), "throttle:"+BucketResolve) g.Post("/notfound", noindex(apicontrollers.NotFound(p.Service)), "throttle:"+BucketNotFound) + // Verification files are served here only; the frontend maps them + // at the site root (D-103). A root route would capture every + // one-segment page of a site served at GET /. + g.Get("/verification/{name}", noindex(apicontrollers.Verification(p.Service)), "throttle:"+BucketResolve) + g.Where("name", classes.VerificationNamePattern) }) return nil }