diff options
| -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" { |
