aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web')
-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
10 files changed, 77 insertions, 40 deletions
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" {