feat: verification files API
- GET /_seo/api/v1/verification/{name} serves a verification file stored
in the SEO settings with its content type, nosniff and a sandboxing CSP
(D-05, D-103); the name pattern is enforced in the route and the
lookup, and no route is registered at the site root
This commit is contained in:
53
classes/verification.go
Normal file
53
classes/verification.go
Normal file
@@ -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
|
||||
}
|
||||
38
controllers/api/verification.go
Normal file
38
controllers/api/verification.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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":"<users><user>ABC</user></users>"}]`).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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user