aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md11
-rw-r--r--cmd/blogspace/main.go23
-rw-r--r--cmd/blogspace/seed.go16
-rw-r--r--internal/config/config.go7
-rw-r--r--internal/store/blogs.go36
-rw-r--r--internal/store/users.go5
-rw-r--r--internal/web/handlers_admin.go5
-rw-r--r--internal/web/handlers_auth.go16
-rw-r--r--internal/web/handlers_blog.go3
-rw-r--r--internal/web/handlers_pages.go5
-rw-r--r--internal/web/routes.go19
-rw-r--r--internal/web/server.go43
-rw-r--r--internal/web/templates/admin/index.html2
-rw-r--r--internal/web/templates/dashboard/settings.html2
-rw-r--r--internal/web/templates/layouts/blog.html2
-rw-r--r--internal/web/web_test.go20
16 files changed, 167 insertions, 48 deletions
diff --git a/README.md b/README.md
index c88918c..9c32133 100644
--- a/README.md
+++ b/README.md
@@ -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}} &#8599;</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}} &#8599;</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> &middot; {{.Blog.Title}}</p>
+ <p class="small"><a href="/feed.xml">RSS</a> &middot; {{.Blog.Title}}{{if .Data.isRoot}} &middot; <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" {