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:
2026-10-27 11:52:55 +01:00
parent 51413fbda4
commit ec74062624
4 changed files with 139 additions and 0 deletions

53
classes/verification.go Normal file
View 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
}

View 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))
}
}

View File

@@ -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())
}
}
}

View File

@@ -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
}