aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-12 12:15:47 +0300
committergrm <grm@eyesin.space>2026-09-12 12:15:47 +0300
commit3073532f723b976a2f54666f779e9a045bacb7f6 (patch)
tree442181bd2b5ac48a2ec5621203f81e64cb1769c5
parentf82c2256d619e92cf0e928deb3b23b735071aaf3 (diff)
downloadblogspace-3073532f723b976a2f54666f779e9a045bacb7f6.tar.gz
blogspace-3073532f723b976a2f54666f779e9a045bacb7f6.tar.bz2
blogspace-3073532f723b976a2f54666f779e9a045bacb7f6.zip
Rename /login to /webadmin and reach it from every blog
The login URL is less guessable, bloggers can type /webadmin on their own blog and get bounced to the root login page (and back to their dashboard after logging in), and the public root blog no longer advertises the admin entry point in its footer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
-rw-r--r--AGENTS.md6
-rw-r--r--README.md12
-rw-r--r--internal/web/handlers_auth.go17
-rw-r--r--internal/web/handlers_blog.go3
-rw-r--r--internal/web/handlers_pages.go2
-rw-r--r--internal/web/routes.go6
-rw-r--r--internal/web/server.go2
-rw-r--r--internal/web/templates/auth/login.html2
-rw-r--r--internal/web/templates/layouts/blog.html2
-rw-r--r--internal/web/web_test.go23
10 files changed, 52 insertions, 23 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 9c45974..cfe3d06 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,7 +7,7 @@ the same commit. It is the first thing a future session reads.
## What this is
Blogspace: a multi-tenant blog host. One Go binary + Postgres. The base domain
-serves the superadmin's own blog plus the management site (`/login`,
+serves the superadmin's own blog plus the management site (`/webadmin`,
`/dashboard`, `/b/<sub>/…`, `/admin/`); every other blog is served at
`<sub>.BASE_DOMAIN`. See `README.md` for the user-facing description, config
table and deployment notes.
@@ -62,7 +62,9 @@ internal/web/ server.go (host router, middleware, render helpers)
(`handlers_pages.go`) and to `TestRootRoutePrecedence`. `/static/{file}`
is a single segment on purpose (a `/static/` prefix pattern conflicts
with `/{page}/{post}`); static files must stay flat.
-- **Auth**: HS256 JWT in `session` cookie (`HttpOnly`, `SameSite=Lax`,
+- **Auth**: login lives only at `/webadmin` on the root domain (deliberately
+ not `/login`, and not linked from public pages); `/webadmin` on a blog host
+ redirects there with `next=/b/<sub>/`. HS256 JWT in `session` cookie (`HttpOnly`, `SameSite=Lax`,
7 days). Claims carry `uid` + `ver` (= `users.token_version`); the
session middleware re-loads the user every request and drops the session
if disabled or version mismatch. Password change / reset / disable bump
diff --git a/README.md b/README.md
index 9c32133..3e0542e 100644
--- a/README.md
+++ b/README.md
@@ -23,8 +23,9 @@ make seed # (another terminal) demo data: superadmin admin/admin, blogger al
Then open:
-- 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`)
+- Root blog (the superadmin's): http://blogspace.localhost:8080
+- Dashboard: http://blogspace.localhost:8080/webadmin (log in as `admin` or `alice`).
+ `/webadmin` on any blog (e.g. http://alice.blogspace.localhost:8080/webadmin) redirects here too.
- Alice's blog: http://alice.blogspace.localhost:8080
Chrome and Firefox resolve any `*.localhost` name to your machine, so no DNS or
@@ -103,14 +104,15 @@ internal/web/ host router, handlers, templates, static CSS, theme
### How requests are routed
-`Host == BASE_DOMAIN` (or `www.`) → management site (`/login`, `/dashboard`, `/b/<sub>/…`, `/admin/`)
+`Host == BASE_DOMAIN` (or `www.`) → management site (`/webadmin`, `/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>`).
+`Host == <sub>.BASE_DOMAIN` → that blog's public pages (`/`, `/<page>`, `/<page>/<post>`, `/feed.xml`, `/media/<id>`),
+plus `/webadmin`, which redirects to the login page on the base domain.
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.
+shadowed by management routes (`webadmin`, `admin`, `b`, …) are rejected for every blog.
### Auth notes
diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go
index 5d82ead..7c1d1e8 100644
--- a/internal/web/handlers_auth.go
+++ b/internal/web/handlers_auth.go
@@ -11,11 +11,22 @@ import (
)
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
+ next := safeNext(r.URL.Query().Get("next"))
if currentUser(r) != nil {
- http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
+ if next == "" {
+ next = "/dashboard"
+ }
+ http.Redirect(w, r, next, http.StatusSeeOther)
return
}
- s.render(w, r, "auth/login.html", map[string]any{"next": safeNext(r.URL.Query().Get("next"))})
+ s.render(w, r, "auth/login.html", map[string]any{"next": next})
+}
+
+// handleWebadminRedirect serves /webadmin on a blog's own host: the login page
+// lives on the root domain, so bounce there and come back to this blog's dashboard.
+func (s *Server) handleWebadminRedirect(w http.ResponseWriter, r *http.Request) {
+ sub, _ := r.Context().Value(ctxHostSub).(string)
+ http.Redirect(w, r, s.cfg.RootURL()+"/webadmin?next="+urlQuery("/b/"+sub+"/"), http.StatusSeeOther)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
@@ -54,7 +65,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
auth.ClearSessionCookie(w)
- http.Redirect(w, r, "/login", http.StatusSeeOther)
+ http.Redirect(w, r, "/webadmin", http.StatusSeeOther)
}
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go
index 27e67e6..b768c4d 100644
--- a/internal/web/handlers_blog.go
+++ b/internal/web/handlers_blog.go
@@ -7,7 +7,6 @@ import (
"strconv"
"time"
- "github.com/gramanas/blogspace/internal/config"
"github.com/gramanas/blogspace/internal/store"
)
@@ -27,7 +26,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, "isRoot": blog.Subdomain == config.RootSubdomain}, nil
+ return map[string]any{"theme": theme, "css": theme.CSS(), "nav": nav}, 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 ac50513..db46097 100644
--- a/internal/web/handlers_pages.go
+++ b/internal/web/handlers_pages.go
@@ -13,7 +13,7 @@ import (
// 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}
+ "webadmin": 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 02d536a..e135229 100644
--- a/internal/web/routes.go
+++ b/internal/web/routes.go
@@ -10,8 +10,8 @@ func urlQuery(s string) string { return url.QueryEscape(s) }
func (s *Server) rootRoutes() http.Handler {
m := http.NewServeMux()
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)
+ m.HandleFunc("GET /webadmin", s.handleLoginForm)
+ m.HandleFunc("POST /webadmin", s.handleLogin)
m.HandleFunc("POST /logout", s.handleLogout)
m.HandleFunc("GET /dashboard", s.requireAuth(s.handleDashboard))
m.HandleFunc("GET /account/password", s.requireAuth(s.handlePasswordForm))
@@ -71,6 +71,8 @@ func (s *Server) blogRoutes(m *http.ServeMux, wrap func(http.HandlerFunc) http.H
func (s *Server) subdomainRoutes() http.Handler {
m := http.NewServeMux()
+ // Bloggers type /webadmin on their own blog; send them to the real login page.
+ m.HandleFunc("GET /webadmin", s.handleWebadminRedirect)
m.HandleFunc("GET /media/{id}", s.handleMedia)
m.Handle("GET /static/{file}", s.staticHandler())
s.blogRoutes(m, s.hostBlog)
diff --git a/internal/web/server.go b/internal/web/server.go
index 68dba3b..cbeac5d 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -129,7 +129,7 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
if u == nil {
- http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
+ http.Redirect(w, r, "/webadmin?next="+r.URL.Path, http.StatusSeeOther)
return
}
if r.Method == http.MethodPost {
diff --git a/internal/web/templates/auth/login.html b/internal/web/templates/auth/login.html
index 2229849..2aec3b1 100644
--- a/internal/web/templates/auth/login.html
+++ b/internal/web/templates/auth/login.html
@@ -2,7 +2,7 @@
{{define "content"}}
<div class="card narrow">
<h1>Log in</h1>
- <form method="post" action="/login">
+ <form method="post" action="/webadmin">
<input type="hidden" name="next" value="{{.Data.next}}">
<label>Username<br><input name="username" value="{{.Data.username}}" required autofocus autocomplete="username"></label>
<label>Password<br><input type="password" name="password" required autocomplete="current-password"></label>
diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html
index ff66c59..7e3128e 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}}{{if .Data.isRoot}} &middot; <a href="/login">Log in</a>{{end}}</p>
+ <p class="small"><a href="/feed.xml">RSS</a> &middot; {{.Blog.Title}}</p>
</div>
</div>
</body>
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index dcd766c..6e53bae 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -32,14 +32,14 @@ func TestHostRoutingWithoutDB(t *testing.T) {
t.Errorf("host %q: got %d, want 404", host, rec.Code)
}
}
- // root domain (and www) reach the management mux: /login renders without DB access
+ // root domain (and www) reach the management mux: /webadmin renders without DB access
for _, host := range []string{"example.com", "www.example.com", "example.com:8080"} {
rec := httptest.NewRecorder()
- req := httptest.NewRequest("GET", "/login", nil)
+ req := httptest.NewRequest("GET", "/webadmin", nil)
req.Host = host
s.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Log in") {
- t.Errorf("host %q /login: got %d", host, rec.Code)
+ t.Errorf("host %q /webadmin: got %d", host, rec.Code)
}
}
}
@@ -48,7 +48,7 @@ func TestHostRoutingWithoutDB(t *testing.T) {
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} {
+ for path, want := range map[string]int{"/webadmin": 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"
@@ -57,7 +57,7 @@ func TestRootRoutePrecedence(t *testing.T) {
t.Errorf("%s: got %d, want %d", path, rec.Code, want)
}
}
- for _, slug := range []string{"login", "admin", "b", "media", "feed.xml"} {
+ for _, slug := range []string{"webadmin", "admin", "b", "media", "feed.xml"} {
if !reservedPageSlugs[slug] {
t.Errorf("page slug %q should be reserved", slug)
}
@@ -91,3 +91,16 @@ func TestAllTemplatesParse(t *testing.T) {
}
}
}
+
+// /webadmin on a blog's own host bounces to the root login page and back to that blog's dashboard.
+func TestSubdomainWebadminRedirect(t *testing.T) {
+ cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20}
+ s := NewServer(cfg, nil)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/webadmin", nil)
+ req.Host = "alice.example.com"
+ s.ServeHTTP(rec, req)
+ if want := "http://example.com/webadmin?next=%2Fb%2Falice%2F"; rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != want {
+ t.Errorf("got %d %q, want 303 %q", rec.Code, rec.Header().Get("Location"), want)
+ }
+}