diff options
| author | grm <grm@eyesin.space> | 2026-09-12 11:43:14 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-12 11:43:14 +0300 |
| commit | f0eaf46755e04b806e2df07a2fabd4060a72d54c (patch) | |
| tree | f6d8f9fda0bb6e2fa7ae6304d4ad0c12217f3233 | |
| parent | 3eb04b1a2bdf9e53231fe862cfd76327371a9741 (diff) | |
| download | blogspace-f0eaf46755e04b806e2df07a2fabd4060a72d54c.tar.gz blogspace-f0eaf46755e04b806e2df07a2fabd4060a72d54c.tar.bz2 blogspace-f0eaf46755e04b806e2df07a2fabd4060a72d54c.zip | |
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | cmd/blogspace/main.go | 23 | ||||
| -rw-r--r-- | cmd/blogspace/seed.go | 16 | ||||
| -rw-r--r-- | internal/config/config.go | 7 | ||||
| -rw-r--r-- | internal/store/blogs.go | 36 | ||||
| -rw-r--r-- | internal/store/users.go | 5 | ||||
| -rw-r--r-- | internal/web/handlers_admin.go | 5 | ||||
| -rw-r--r-- | internal/web/handlers_auth.go | 16 | ||||
| -rw-r--r-- | internal/web/handlers_blog.go | 3 | ||||
| -rw-r--r-- | internal/web/handlers_pages.go | 5 | ||||
| -rw-r--r-- | internal/web/routes.go | 19 | ||||
| -rw-r--r-- | internal/web/server.go | 43 | ||||
| -rw-r--r-- | internal/web/templates/admin/index.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/settings.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/layouts/blog.html | 2 | ||||
| -rw-r--r-- | internal/web/web_test.go | 20 |
16 files changed, 167 insertions, 48 deletions
@@ -9,6 +9,7 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones. - **Design** form: background colour/image, fonts, widths, header, menu position, footer. - Changes are live immediately: save, then refresh the blog tab. - A **superadmin** creates bloggers, resets passwords, disables or deletes accounts. +- The **root domain is itself a blog**, owned by the superadmin and managed like any other. - Images are stored in Postgres; everything is in one database. ## Local development @@ -22,7 +23,8 @@ make seed # (another terminal) demo data: superadmin admin/admin, blogger al Then open: -- Dashboard: http://blogspace.localhost:8080 (log in as `admin` or `alice`) +- Root blog (the superadmin's): http://blogspace.localhost:8080 — "Log in" link in its footer +- Dashboard: http://blogspace.localhost:8080/login (log in as `admin` or `alice`) - Alice's blog: http://alice.blogspace.localhost:8080 Chrome and Firefox resolve any `*.localhost` name to your machine, so no DNS or @@ -101,10 +103,15 @@ internal/web/ host router, handlers, templates, static CSS, theme ### How requests are routed -`Host == BASE_DOMAIN` → management site (`/login`, `/dashboard`, `/b/<sub>/…`, `/admin/`). +`Host == BASE_DOMAIN` (or `www.`) → management site (`/login`, `/dashboard`, `/b/<sub>/…`, `/admin/`) +**plus** the root blog's public pages on every other path. `Host == <sub>.BASE_DOMAIN` → that blog's public pages (`/`, `/<page>`, `/<page>/<post>`, `/feed.xml`, `/media/<id>`). Anything else → 404. +The root blog is a normal `blogs` row with subdomain `www`; it is created on first +start for the first superadmin and managed at `/b/www/`. Page slugs that would be +shadowed by management routes (`login`, `admin`, `b`, …) are rejected for every blog. + ### Auth notes Sessions are HS256 JWTs in an `HttpOnly` cookie. The token carries the user's diff --git a/cmd/blogspace/main.go b/cmd/blogspace/main.go index 3a60bd4..cf240a8 100644 --- a/cmd/blogspace/main.go +++ b/cmd/blogspace/main.go @@ -65,6 +65,9 @@ func run(ctx context.Context, cfg *config.Config, cmd string) error { if err := bootstrapSuperadmin(ctx, cfg, st); err != nil { return err } + if err := ensureRootBlog(ctx, cfg, st); err != nil { + return err + } return serve(ctx, cfg, st) default: return fmt.Errorf("unknown command %q (serve|seed|migrate)", cmd) @@ -119,6 +122,26 @@ func bootstrapSuperadmin(ctx context.Context, cfg *config.Config, st *store.Stor return nil } +// ensureRootBlog makes sure the blog served on the base domain exists; it is owned by the first superadmin. +func ensureRootBlog(ctx context.Context, cfg *config.Config, st *store.Store) error { + _, err := st.BlogBySubdomain(ctx, config.RootSubdomain) + if err == nil { + return nil + } + if !errors.Is(err, store.ErrNotFound) { + return err + } + admin, err := st.FirstSuperadmin(ctx) + if err != nil { + return fmt.Errorf("root blog needs a superadmin: %w", err) + } + if _, err := st.CreateBlog(ctx, admin.ID, config.RootSubdomain, cfg.BaseDomain); err != nil { + return fmt.Errorf("create root blog: %w", err) + } + log.Printf("created root blog %s owned by %q", cfg.BaseDomain, admin.Username) + return nil +} + func serve(ctx context.Context, cfg *config.Config, st *store.Store) error { srv := &http.Server{ Addr: cfg.Addr, diff --git a/cmd/blogspace/seed.go b/cmd/blogspace/seed.go index 847324c..010eff5 100644 --- a/cmd/blogspace/seed.go +++ b/cmd/blogspace/seed.go @@ -24,6 +24,9 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error { if err := bootstrapSuperadmin(ctx, cfg, st); err != nil { return err } + if err := ensureRootBlog(ctx, cfg, st); err != nil { + return err + } if _, err := st.UserByUsername(ctx, "alice"); err == nil { log.Println("seed: alice already exists, nothing to do") return nil @@ -103,6 +106,19 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error { return err } } + // a welcome post on the root blog (the superadmin's own blog on the base domain) + root, err := st.BlogBySubdomain(ctx, config.RootSubdomain) + if err != nil { + return err + } + rootHome, err := st.HomePage(ctx, root.ID) + if err != nil { + return err + } + welcome := "This is the blog on the **root domain**, owned by the superadmin. It works like every other blog.\n\nOther blogs live on subdomains, for example [alice](" + cfg.BlogURL("alice") + ")." + if _, err := st.CreatePost(ctx, &store.Post{PageID: rootHome.ID, Title: "Welcome to Blogspace", Slug: "welcome", BodyMD: welcome, BodyHTML: markdown.Render(welcome), Published: true}); err != nil { + return err + } log.Printf("seed: superadmin %q (password %q in dev), blogger alice / alicealice at %s", cfg.SuperadminUsername, "admin", cfg.BlogURL("alice")) return nil } diff --git a/internal/config/config.go b/internal/config/config.go index 4972178..7a3a79c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -53,8 +53,15 @@ func Load() (*Config, error) { return c, nil } +// RootSubdomain is the pseudo-subdomain of the blog served on the base domain +// itself (and on www.). It is owned by the superadmin. +const RootSubdomain = "www" + // BlogURL returns the public URL for a blog subdomain. func (c *Config) BlogURL(sub string) string { + if sub == RootSubdomain { + return c.RootURL() + } return "http://" + sub + "." + c.HostWithPort() } diff --git a/internal/store/blogs.go b/internal/store/blogs.go index 57a7c10..e6fbc56 100644 --- a/internal/store/blogs.go +++ b/internal/store/blogs.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "time" + + "github.com/jackc/pgx/v5" ) type Blog struct { @@ -41,21 +43,43 @@ func (s *Store) CreateBlogger(ctx context.Context, username, passwordHash, subdo if err != nil { return nil, nil, err } - b, err := scanBlog(tx.QueryRow(ctx, `INSERT INTO blogs (owner_id, subdomain, title) VALUES ($1,$2,$3) RETURNING `+blogCols, - u.ID, subdomain, title)) + b, err := createBlog(ctx, tx, u.ID, subdomain, title) if err != nil { return nil, nil, err } - _, err = tx.Exec(ctx, `INSERT INTO pages (blog_id, slug, title, nav_order, is_home) VALUES ($1,'home','Home',0,true)`, b.ID) - if err != nil { - return nil, nil, wrap(err) - } if err := tx.Commit(ctx); err != nil { return nil, nil, err } return u, b, nil } +// CreateBlog creates a blog with a default home page for an existing user. +func (s *Store) CreateBlog(ctx context.Context, ownerID int64, subdomain, title string) (*Blog, error) { + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + b, err := createBlog(ctx, tx, ownerID, subdomain, title) + if err != nil { + return nil, err + } + return b, tx.Commit(ctx) +} + +func createBlog(ctx context.Context, tx pgx.Tx, ownerID int64, subdomain, title string) (*Blog, error) { + b, err := scanBlog(tx.QueryRow(ctx, `INSERT INTO blogs (owner_id, subdomain, title) VALUES ($1,$2,$3) RETURNING `+blogCols, + ownerID, subdomain, title)) + if err != nil { + return nil, err + } + _, err = tx.Exec(ctx, `INSERT INTO pages (blog_id, slug, title, nav_order, is_home) VALUES ($1,'home','Home',0,true)`, b.ID) + if err != nil { + return nil, wrap(err) + } + return b, nil +} + func (s *Store) BlogByID(ctx context.Context, id int64) (*Blog, error) { return scanBlog(s.db.QueryRow(ctx, `SELECT `+blogCols+` FROM blogs WHERE id=$1`, id)) } diff --git a/internal/store/users.go b/internal/store/users.go index 2910888..3ea9bdb 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -47,6 +47,11 @@ func (s *Store) UserByUsername(ctx context.Context, username string) (*User, err return scanUser(s.db.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE username=$1`, username)) } +// FirstSuperadmin returns the oldest superadmin account. +func (s *Store) FirstSuperadmin(ctx context.Context) (*User, error) { + return scanUser(s.db.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE role=$1 ORDER BY id LIMIT 1`, RoleSuperadmin)) +} + func (s *Store) CountSuperadmins(ctx context.Context) (int, error) { var n int err := s.db.QueryRow(ctx, `SELECT count(*) FROM users WHERE role=$1`, RoleSuperadmin).Scan(&n) 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 <name>.%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}}<tr{{if .Disabled}} class="disabled"{{end}}> <td>{{.Username}}{{if .Disabled}} <span class="tag">disabled</span>{{end}}</td> <td>{{.Role}}</td> - <td>{{if .Subdomain}}<a href="/b/{{deref .Subdomain}}/">{{deref .BlogTitle}}</a> <a href="{{$.Data.cfg.BlogURL (deref .Subdomain)}}" target="_blank" class="muted">{{deref .Subdomain}} ↗</a>{{else}}<span class="muted">—</span>{{end}}</td> + <td>{{if .Subdomain}}<a href="/b/{{deref .Subdomain}}/">{{deref .BlogTitle}}</a> <a href="{{$.Data.cfg.BlogURL (deref .Subdomain)}}" target="_blank" class="muted">{{if eq (deref .Subdomain) "www"}}{{$.Data.cfg.BaseDomain}}{{else}}{{deref .Subdomain}}{{end}} ↗</a>{{else}}<span class="muted">—</span>{{end}}</td> <td class="nowrap">{{date .CreatedAt}}</td> <td class="nowrap"> {{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 @@ <input type="hidden" name="_csrf" value="{{.CSRF}}"> <label>Blog title<br><input name="title" value="{{.Blog.Title}}" required maxlength="120"></label> <label>Tagline <span class="muted">(shown under the title)</span><br><input name="tagline" value="{{.Blog.Tagline}}" maxlength="300"></label> - <p class="muted">Address: <code>{{.BlogURL}}</code> — only the administrator can change this.</p> + <p class="muted">Address: <code>{{.BlogURL}}</code>{{if ne .Blog.Subdomain "www"}} — only the administrator can change this{{end}}.</p> <button type="submit">Save</button> </form> </div> 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 @@ <div class="site-footer"> <div class="wrap footer-inner"> {{if .Data.theme.FooterText}}<p>{{.Data.theme.FooterText}}</p>{{end}} - <p class="small"><a href="/feed.xml">RSS</a> · {{.Blog.Title}}</p> + <p class="small"><a href="/feed.xml">RSS</a> · {{.Blog.Title}}{{if .Data.isRoot}} · <a href="/login">Log in</a>{{end}}</p> </div> </div> </body> 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" { |
