From f0eaf46755e04b806e2df07a2fabd4060a72d54c Mon Sep 17 00:00:00 2001 From: grm Date: Sat, 12 Sep 2026 11:43:14 +0300 Subject: Serve the superadmin's blog on the root domain The base domain (and www.) now serves a regular blog owned by the first superadmin, created automatically on startup, alongside the management routes. Literal management paths take precedence over the blog's page wildcards, and the slugs they would shadow are reserved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/handlers_admin.go | 5 +++ internal/web/handlers_auth.go | 16 +++------- internal/web/handlers_blog.go | 3 +- internal/web/handlers_pages.go | 5 +-- internal/web/routes.go | 19 ++++++++---- internal/web/server.go | 43 ++++++++++++++++---------- internal/web/templates/admin/index.html | 2 +- internal/web/templates/dashboard/settings.html | 2 +- internal/web/templates/layouts/blog.html | 2 +- internal/web/web_test.go | 20 ++++++++++++ 10 files changed, 77 insertions(+), 40 deletions(-) (limited to 'internal/web') diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go index 30fdc2e..4287411 100644 --- a/internal/web/handlers_admin.go +++ b/internal/web/handlers_admin.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/gramanas/blogspace/internal/auth" + "github.com/gramanas/blogspace/internal/config" "github.com/gramanas/blogspace/internal/store" ) @@ -146,6 +147,10 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) { if u == nil { return } + if b, err := s.st.BlogByOwner(r.Context(), u.ID); err == nil && b.Subdomain == config.RootSubdomain { + s.plainError(w, http.StatusBadRequest, "This user owns the root blog and cannot be deleted.") + return + } if err := s.st.DeleteUser(r.Context(), u.ID); err != nil { // cascades to blog, pages, posts, images s.serverError(w, err) return diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go index 4554d94..5d82ead 100644 --- a/internal/web/handlers_auth.go +++ b/internal/web/handlers_auth.go @@ -10,14 +10,6 @@ import ( "github.com/gramanas/blogspace/internal/store" ) -func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { - if currentUser(r) != nil { - http.Redirect(w, r, "/dashboard", http.StatusSeeOther) - return - } - http.Redirect(w, r, "/login", http.StatusSeeOther) -} - func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { if currentUser(r) != nil { http.Redirect(w, r, "/dashboard", http.StatusSeeOther) @@ -67,13 +59,13 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { u := currentUser(r) - if u.IsSuperadmin() { - http.Redirect(w, r, "/admin/", http.StatusSeeOther) - return - } blog, err := s.st.BlogByOwner(r.Context(), u.ID) if err != nil { if errors.Is(err, store.ErrNotFound) { + if u.IsSuperadmin() { + http.Redirect(w, r, "/admin/", http.StatusSeeOther) + return + } s.plainError(w, http.StatusNotFound, "You have no blog yet. Ask the administrator to create one.") return } diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go index b768c4d..27e67e6 100644 --- a/internal/web/handlers_blog.go +++ b/internal/web/handlers_blog.go @@ -7,6 +7,7 @@ import ( "strconv" "time" + "github.com/gramanas/blogspace/internal/config" "github.com/gramanas/blogspace/internal/store" ) @@ -26,7 +27,7 @@ func (s *Server) blogView(r *http.Request) (map[string]any, error) { nav = append(nav, p) } } - return map[string]any{"theme": theme, "css": theme.CSS(), "nav": nav}, nil + return map[string]any{"theme": theme, "css": theme.CSS(), "nav": nav, "isRoot": blog.Subdomain == config.RootSubdomain}, nil } func (s *Server) handleBlogHome(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go index 1b79280..ac50513 100644 --- a/internal/web/handlers_pages.go +++ b/internal/web/handlers_pages.go @@ -11,8 +11,9 @@ import ( "github.com/gramanas/blogspace/internal/store" ) -// Page slugs that would collide with blog routes. -var reservedPageSlugs = map[string]bool{"media": true, "static": true, "feed.xml": true} +// Page slugs that would collide with blog routes, or with management routes on the root domain. +var reservedPageSlugs = map[string]bool{"media": true, "static": true, "feed.xml": true, + "login": true, "logout": true, "dashboard": true, "account": true, "admin": true, "b": true, "healthz": true} func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) { pages, err := s.st.ListPages(r.Context(), currentBlog(r).ID) diff --git a/internal/web/routes.go b/internal/web/routes.go index c889aa1..02d536a 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -9,7 +9,6 @@ func urlQuery(s string) string { return url.QueryEscape(s) } func (s *Server) rootRoutes() http.Handler { m := http.NewServeMux() - m.HandleFunc("GET /{$}", s.handleIndex) m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) m.HandleFunc("GET /login", s.handleLoginForm) m.HandleFunc("POST /login", s.handleLogin) @@ -56,17 +55,25 @@ func (s *Server) rootRoutes() http.Handler { m.HandleFunc("GET /media/{id}", s.handleMedia) m.Handle("GET /static/{file}", s.staticHandler()) + // The root domain also serves the superadmin's blog. Literal routes above win + // over these wildcards; see reservedPageSlugs for the names that are shadowed. + s.blogRoutes(m, s.hostBlog) return s.session(m) } -func (s *Server) blogRoutes() http.Handler { +// blogRoutes registers the public blog pages; wrap resolves the blog into the request context. +func (s *Server) blogRoutes(m *http.ServeMux, wrap func(http.HandlerFunc) http.HandlerFunc) { + m.HandleFunc("GET /{$}", wrap(s.handleBlogHome)) + m.HandleFunc("GET /feed.xml", wrap(s.handleBlogFeed)) + m.HandleFunc("GET /{page}", wrap(s.handleBlogPage)) + m.HandleFunc("GET /{page}/{post}", wrap(s.handleBlogPost)) +} + +func (s *Server) subdomainRoutes() http.Handler { m := http.NewServeMux() - m.HandleFunc("GET /{$}", s.handleBlogHome) - m.HandleFunc("GET /feed.xml", s.handleBlogFeed) m.HandleFunc("GET /media/{id}", s.handleMedia) m.Handle("GET /static/{file}", s.staticHandler()) - m.HandleFunc("GET /{page}", s.handleBlogPage) - m.HandleFunc("GET /{page}/{post}", s.handleBlogPost) + s.blogRoutes(m, s.hostBlog) return m } diff --git a/internal/web/server.go b/internal/web/server.go index e8a5e6d..68dba3b 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -29,33 +29,26 @@ type Server struct { func NewServer(cfg *config.Config, st *store.Store) *Server { s := &Server{cfg: cfg, st: st, tpl: newTemplates(cfg.Dev, funcs)} s.root = s.rootRoutes() - s.blog = s.blogRoutes() + s.blog = s.subdomainRoutes() return s } // ServeHTTP dispatches on the Host header: the base domain is the management -// site, one label below it is a blog, anything else is a 404. +// site plus the superadmin's root blog, one label below it is a blog, anything +// else is a 404. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { host := hostname(r.Host) switch { - case host == s.cfg.BaseDomain, host == "www."+s.cfg.BaseDomain: - s.root.ServeHTTP(w, r) + case host == s.cfg.BaseDomain, host == config.RootSubdomain+"."+s.cfg.BaseDomain: + // The root blog is looked up lazily by hostBlog so management pages work even without one. + s.root.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxHostSub, config.RootSubdomain))) case strings.HasSuffix(host, "."+s.cfg.BaseDomain): sub := strings.TrimSuffix(host, "."+s.cfg.BaseDomain) if strings.Contains(sub, ".") || reservedSubdomains[sub] { http.NotFound(w, r) return } - blog, err := s.st.BlogBySubdomain(r.Context(), sub) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - s.plainError(w, http.StatusNotFound, "No blog here (yet).") - return - } - s.serverError(w, err) - return - } - s.blog.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxBlog, blog))) + s.blog.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxHostSub, sub))) default: s.plainError(w, http.StatusNotFound, fmt.Sprintf("Unknown host %q. Blogs live at .%s", host, s.cfg.BaseDomain)) } @@ -73,8 +66,9 @@ func hostname(h string) string { type ctxKey int const ( - ctxUser ctxKey = iota - ctxBlog + ctxUser ctxKey = iota + ctxBlog // the blog a handler operates on (public page, or /b/{sub}/ management) + ctxHostSub // subdomain derived from the Host header ) func currentUser(r *http.Request) *store.User { @@ -89,6 +83,23 @@ func currentBlog(r *http.Request) *store.Blog { // ---- middleware ---------------------------------------------------------- +// hostBlog resolves the blog named by the Host header into the context for public pages. +func (s *Server) hostBlog(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sub, _ := r.Context().Value(ctxHostSub).(string) + blog, err := s.st.BlogBySubdomain(r.Context(), sub) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + s.plainError(w, http.StatusNotFound, "No blog here (yet).") + return + } + s.serverError(w, err) + return + } + next(w, r.WithContext(context.WithValue(r.Context(), ctxBlog, blog))) + } +} + // session loads the user from the JWT cookie (if any) into the context. func (s *Server) session(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/templates/admin/index.html b/internal/web/templates/admin/index.html index c74d426..bea9266 100644 --- a/internal/web/templates/admin/index.html +++ b/internal/web/templates/admin/index.html @@ -7,7 +7,7 @@ {{range .Data.users}} {{.Username}}{{if .Disabled}} disabled{{end}} {{.Role}} - {{if .Subdomain}}{{deref .BlogTitle}} {{deref .Subdomain}} ↗{{else}}—{{end}} + {{if .Subdomain}}{{deref .BlogTitle}} {{if eq (deref .Subdomain) "www"}}{{$.Data.cfg.BaseDomain}}{{else}}{{deref .Subdomain}}{{end}} ↗{{else}}—{{end}} {{date .CreatedAt}} {{if ne .ID $.User.ID}} diff --git a/internal/web/templates/dashboard/settings.html b/internal/web/templates/dashboard/settings.html index 24dffb4..9b2b325 100644 --- a/internal/web/templates/dashboard/settings.html +++ b/internal/web/templates/dashboard/settings.html @@ -6,7 +6,7 @@ -

Address: {{.BlogURL}} — only the administrator can change this.

+

Address: {{.BlogURL}}{{if ne .Blog.Subdomain "www"}} — only the administrator can change this{{end}}.

diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html index 7e3128e..ff66c59 100644 --- a/internal/web/templates/layouts/blog.html +++ b/internal/web/templates/layouts/blog.html @@ -27,7 +27,7 @@ diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 42e37a7..dcd766c 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -44,6 +44,26 @@ func TestHostRoutingWithoutDB(t *testing.T) { } } +// On the root domain the literal management routes must win over the blog's /{page} wildcards. +func TestRootRoutePrecedence(t *testing.T) { + cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20} + s := NewServer(cfg, nil) + for path, want := range map[string]int{"/login": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/account/password": 303} { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", path, nil) + req.Host = "example.com" + s.ServeHTTP(rec, req) + if rec.Code != want { + t.Errorf("%s: got %d, want %d", path, rec.Code, want) + } + } + for _, slug := range []string{"login", "admin", "b", "media", "feed.xml"} { + if !reservedPageSlugs[slug] { + t.Errorf("page slug %q should be reserved", slug) + } + } +} + func TestThemeNormalizeAndCSS(t *testing.T) { th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","nav_position":"left-sidebar"}`)) if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.NavPosition != "left-sidebar" { -- cgit v1.2.3