diff options
Diffstat (limited to 'internal')
| -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 |
13 files changed, 119 insertions, 46 deletions
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" { |
