aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-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
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}} &#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" {