aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorgramanas <grm@eyesin.space>2026-09-12 11:24:17 +0300
committergramanas <grm@eyesin.space>2026-09-12 11:24:17 +0300
commit3eb04b1a2bdf9e53231fe862cfd76327371a9741 (patch)
treeb38b2d82a47233fd8e0bb18c59e4a8f3dd2412d7
downloadblogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.tar.gz
blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.tar.bz2
blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.zip
Initial multi-tenant blog host
Go + Postgres application serving a management dashboard on the base domain and one public blog per subdomain. Markdown posts organised in pages, form-based theme customisation, image uploads stored in Postgres, JWT cookie sessions with CSRF, superadmin user management, RSS feeds. Docker/compose deployment and a Makefile-driven dev environment with seed data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
-rw-r--r--.dockerignore4
-rw-r--r--.env.example13
-rw-r--r--.gitignore2
-rw-r--r--Dockerfile12
-rw-r--r--Makefile45
-rw-r--r--README.md113
-rw-r--r--cmd/blogspace/main.go142
-rw-r--r--cmd/blogspace/seed.go108
-rw-r--r--compose.dev.yml20
-rw-r--r--compose.yml33
-rw-r--r--go.mod28
-rw-r--r--go.sum65
-rw-r--r--internal/auth/auth_test.go46
-rw-r--r--internal/auth/cookie.go23
-rw-r--r--internal/auth/csrf.go20
-rw-r--r--internal/auth/jwt.go41
-rw-r--r--internal/auth/password.go13
-rw-r--r--internal/config/config.go90
-rw-r--r--internal/db/db.go43
-rw-r--r--internal/db/migrations/00001_init.sql68
-rw-r--r--internal/markdown/render.go38
-rw-r--r--internal/markdown/render_test.go29
-rw-r--r--internal/slug/slug.go54
-rw-r--r--internal/slug/slug_test.go34
-rw-r--r--internal/store/blogs.go84
-rw-r--r--internal/store/images.go58
-rw-r--r--internal/store/pages.go124
-rw-r--r--internal/store/posts.go105
-rw-r--r--internal/store/store.go34
-rw-r--r--internal/store/users.go99
-rw-r--r--internal/web/handlers_admin.go154
-rw-r--r--internal/web/handlers_auth.go131
-rw-r--r--internal/web/handlers_blog.go150
-rw-r--r--internal/web/handlers_dashboard.go43
-rw-r--r--internal/web/handlers_design.go155
-rw-r--r--internal/web/handlers_media.go38
-rw-r--r--internal/web/handlers_pages.go161
-rw-r--r--internal/web/handlers_posts.go158
-rw-r--r--internal/web/routes.go81
-rw-r--r--internal/web/server.go251
-rw-r--r--internal/web/static/blog.css50
-rw-r--r--internal/web/static/dashboard.css87
-rw-r--r--internal/web/templates.go117
-rw-r--r--internal/web/templates/admin/delete_user.html12
-rw-r--r--internal/web/templates/admin/index.html28
-rw-r--r--internal/web/templates/admin/new_user.html15
-rw-r--r--internal/web/templates/auth/login.html12
-rw-r--r--internal/web/templates/blog/404.html5
-rw-r--r--internal/web/templates/blog/page.html19
-rw-r--r--internal/web/templates/blog/post.html8
-rw-r--r--internal/web/templates/dashboard/confirm.html17
-rw-r--r--internal/web/templates/dashboard/design.html87
-rw-r--r--internal/web/templates/dashboard/images.html22
-rw-r--r--internal/web/templates/dashboard/overview.html35
-rw-r--r--internal/web/templates/dashboard/page_form.html21
-rw-r--r--internal/web/templates/dashboard/pages.html24
-rw-r--r--internal/web/templates/dashboard/password.html13
-rw-r--r--internal/web/templates/dashboard/post_form.html26
-rw-r--r--internal/web/templates/dashboard/posts.html25
-rw-r--r--internal/web/templates/dashboard/settings.html13
-rw-r--r--internal/web/templates/layouts/blog.html38
-rw-r--r--internal/web/templates/layouts/dashboard.html17
-rw-r--r--internal/web/templates/partials/dashnav.html27
-rw-r--r--internal/web/templates/partials/imagepick.html11
-rw-r--r--internal/web/templates/partials/mdhelp.html9
-rw-r--r--internal/web/theme.go212
-rw-r--r--internal/web/web_test.go73
67 files changed, 3933 insertions, 0 deletions
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..d6fc617
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,4 @@
+.git
+bin
+.env
+*.md
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..bc29fa2
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,13 @@
+# Copy to .env for `docker compose up`.
+BASE_DOMAIN=example.com
+# Port the proxy connects to on localhost (container always listens on 8080).
+APP_PORT=8080
+# Long random string; `openssl rand -hex 32`
+JWT_SECRET=change-me-to-a-long-random-string
+# Created on first start if no superadmin exists yet.
+SUPERADMIN_USERNAME=admin
+SUPERADMIN_PASSWORD=change-me
+POSTGRES_PASSWORD=change-me-too
+MAX_UPLOAD_MB=5
+# Only when running the dashboard on a non-standard port (dev): appended to generated blog links.
+#PUBLIC_PORT=8080
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c38aa27
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/bin/
+/.env
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..cc06148
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,12 @@
+FROM golang:1.26-alpine AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /blogspace ./cmd/blogspace
+
+FROM gcr.io/distroless/static-debian12:nonroot
+COPY --from=build /blogspace /blogspace
+EXPOSE 8080
+ENTRYPOINT ["/blogspace"]
+CMD ["serve"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..9eb6b2d
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,45 @@
+# Local development helpers. Requires Go and Docker.
+export DEV ?= 1
+export BASE_DOMAIN ?= blogspace.localhost
+export PUBLIC_PORT ?= 8080
+export ADDR ?= :8080
+export DATABASE_URL ?= postgres://blogspace:blogspace@localhost:5432/blogspace?sslmode=disable
+export TEST_DATABASE_URL ?= $(DATABASE_URL)
+
+.PHONY: dev run db-up db-down db-reset seed test build docker-build up down fmt
+
+dev: db-up run ## start postgres and run the app with live template reload
+
+run: ## run the app (assumes postgres is up)
+ go run ./cmd/blogspace serve
+
+db-up: ## start dev postgres and wait until it accepts connections
+ docker compose -f compose.dev.yml up -d --wait
+
+db-down:
+ docker compose -f compose.dev.yml down
+
+db-reset: ## drop all dev data and start fresh
+ docker compose -f compose.dev.yml down -v
+ $(MAKE) db-up
+
+seed: db-up ## insert demo superadmin (admin/admin) and blogger (alice/alicealice)
+ go run ./cmd/blogspace seed
+
+test:
+ go test ./...
+
+build:
+ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/blogspace ./cmd/blogspace
+
+fmt:
+ gofmt -w .
+
+docker-build:
+ docker build -t blogspace .
+
+up: ## production-style: app + db in docker (needs .env)
+ docker compose up --build -d
+
+down:
+ docker compose down
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c88918c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,113 @@
+# Blogspace
+
+A small multi-tenant blog host. One Go binary + Postgres. Bloggers log in at
+`example.com` to manage their blog; each blog is served at `<name>.example.com`.
+Server-rendered HTML, no JavaScript required, works on old browsers and phones.
+
+- Posts and page intros are written in **Markdown** (sanitized on save).
+- Every blog has **pages** (Home, About, News, …); each page holds posts.
+- **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.
+- Images are stored in Postgres; everything is in one database.
+
+## Local development
+
+Requirements: Go 1.26+, Docker (for Postgres).
+
+```sh
+make dev # starts Postgres in Docker, runs the app on :8080 with template hot-reload
+make seed # (another terminal) demo data: superadmin admin/admin, blogger alice/alicealice
+```
+
+Then open:
+
+- Dashboard: http://blogspace.localhost:8080 (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
+`/etc/hosts` changes are needed. If your browser does not, add lines like
+`127.0.0.1 blogspace.localhost alice.blogspace.localhost` to `/etc/hosts`, or set
+`BASE_DOMAIN=lvh.me` (a public name that resolves to 127.0.0.1).
+
+Port already taken? `make dev ADDR=:8090 PUBLIC_PORT=8090`.
+
+Other targets: `make test`, `make db-reset` (wipe dev data), `make build`.
+In dev mode (`DEV=1`) templates and CSS are read from disk on each request, so
+edits under `internal/web/templates` and `internal/web/static` show up on reload;
+Go changes need a restart.
+
+## Configuration (environment)
+
+| Variable | Default | Meaning |
+|---|---|---|
+| `BASE_DOMAIN` | `blogspace.localhost` | Root domain; blogs are `<name>.BASE_DOMAIN` |
+| `ADDR` | `:8080` | Listen address |
+| `PUBLIC_PORT` | — | Appended to generated blog links (dev only; unset behind a proxy on :80/:443) |
+| `DATABASE_URL` | local dev DSN | Postgres connection string |
+| `JWT_SECRET` | — | **Required** outside dev; long random string (`openssl rand -hex 32`) |
+| `SUPERADMIN_USERNAME` / `SUPERADMIN_PASSWORD` | `admin` / — | Created on first start if no superadmin exists |
+| `MAX_UPLOAD_MB` | `5` | Image upload limit |
+| `DEV` | `false` | Hot-reload templates, allow missing secrets |
+
+Migrations run automatically at startup.
+
+## Deployment (Docker)
+
+```sh
+cp .env.example .env # edit BASE_DOMAIN, JWT_SECRET, passwords
+docker compose up --build -d
+```
+
+The app listens on `127.0.0.1:8080` (see `APP_PORT`); put a reverse proxy in
+front that terminates TLS and forwards **both** the root domain and the wildcard
+with the original `Host` header. DNS needs two records: `A example.com` and
+`A *.example.com` (or CNAMEs) pointing at the proxy.
+
+nginx example:
+
+```nginx
+server {
+ listen 443 ssl;
+ server_name example.com *.example.com; # wildcard certificate
+ client_max_body_size 8m; # >= MAX_UPLOAD_MB
+ location / {
+ proxy_pass http://127.0.0.1:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+```
+
+Caddy: `example.com, *.example.com { reverse_proxy 127.0.0.1:8080 }` (wildcard
+certificates need the DNS challenge).
+
+Backups: dump the Postgres volume (`docker compose exec db pg_dump -U blogspace blogspace > backup.sql`).
+Images live in the database, so that one dump is everything.
+
+## Layout
+
+```
+cmd/blogspace/ main (serve | seed | migrate)
+internal/config/ environment → Config
+internal/db/ pgx pool + goose migrations (embedded SQL)
+internal/store/ models and queries (users, blogs, pages, posts, images)
+internal/auth/ bcrypt, JWT cookie sessions, CSRF tokens
+internal/markdown/ goldmark + bluemonday
+internal/slug/ title → slug
+internal/web/ host router, handlers, templates, static CSS, theme
+```
+
+### How requests are routed
+
+`Host == BASE_DOMAIN` → management site (`/login`, `/dashboard`, `/b/<sub>/…`, `/admin/`).
+`Host == <sub>.BASE_DOMAIN` → that blog's public pages (`/`, `/<page>`, `/<page>/<post>`, `/feed.xml`, `/media/<id>`).
+Anything else → 404.
+
+### Auth notes
+
+Sessions are HS256 JWTs in an `HttpOnly` cookie. The token carries the user's
+`token_version`; changing a password or disabling a user bumps it, which logs
+out every existing session. Every POST carries a `_csrf` field derived from the
+same secret, so old browsers without `SameSite` support are protected too.
diff --git a/cmd/blogspace/main.go b/cmd/blogspace/main.go
new file mode 100644
index 0000000..3a60bd4
--- /dev/null
+++ b/cmd/blogspace/main.go
@@ -0,0 +1,142 @@
+// Command blogspace runs the multi-blog server.
+//
+// blogspace start the HTTP server (default)
+// blogspace seed insert demo users/blogs for local development
+// blogspace migrate apply migrations and exit
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/gramanas/blogspace/internal/auth"
+ "github.com/gramanas/blogspace/internal/config"
+ "github.com/gramanas/blogspace/internal/db"
+ "github.com/gramanas/blogspace/internal/store"
+ "github.com/gramanas/blogspace/internal/web"
+)
+
+func main() {
+ log.SetFlags(log.LstdFlags | log.Lmsgprefix)
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatalf("config: %v", err)
+ }
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ cmd := "serve"
+ if len(os.Args) > 1 {
+ cmd = os.Args[1]
+ }
+ if err := run(ctx, cfg, cmd); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func run(ctx context.Context, cfg *config.Config, cmd string) error {
+ if err := waitForDB(ctx, cfg.DatabaseURL); err != nil {
+ return err
+ }
+ if err := db.Migrate(ctx, cfg.DatabaseURL); err != nil {
+ return fmt.Errorf("migrate: %w", err)
+ }
+ pool, err := db.Open(ctx, cfg.DatabaseURL)
+ if err != nil {
+ return err
+ }
+ defer pool.Close()
+ st := store.New(pool)
+
+ switch cmd {
+ case "migrate":
+ log.Println("migrations applied")
+ return nil
+ case "seed":
+ return seed(ctx, cfg, st)
+ case "serve":
+ if err := bootstrapSuperadmin(ctx, cfg, st); err != nil {
+ return err
+ }
+ return serve(ctx, cfg, st)
+ default:
+ return fmt.Errorf("unknown command %q (serve|seed|migrate)", cmd)
+ }
+}
+
+// waitForDB retries the connection so `docker compose up` ordering doesn't matter.
+func waitForDB(ctx context.Context, url string) error {
+ deadline := time.Now().Add(60 * time.Second)
+ for {
+ pool, err := db.Open(ctx, url)
+ if err == nil {
+ pool.Close()
+ return nil
+ }
+ if time.Now().After(deadline) {
+ return fmt.Errorf("database not reachable: %w", err)
+ }
+ log.Printf("waiting for database: %v", err)
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(2 * time.Second):
+ }
+ }
+}
+
+// bootstrapSuperadmin creates the first superadmin from the environment when none exists.
+func bootstrapSuperadmin(ctx context.Context, cfg *config.Config, st *store.Store) error {
+ n, err := st.CountSuperadmins(ctx)
+ if err != nil {
+ return err
+ }
+ if n > 0 {
+ return nil
+ }
+ pw := cfg.SuperadminPassword
+ if pw == "" {
+ if !cfg.Dev {
+ return errors.New("no superadmin exists: set SUPERADMIN_USERNAME and SUPERADMIN_PASSWORD for the first start")
+ }
+ pw = "admin"
+ }
+ hash, err := auth.HashPassword(pw)
+ if err != nil {
+ return err
+ }
+ if _, err := st.CreateUser(ctx, cfg.SuperadminUsername, hash, store.RoleSuperadmin); err != nil {
+ return fmt.Errorf("create superadmin: %w", err)
+ }
+ log.Printf("created superadmin %q", cfg.SuperadminUsername)
+ return nil
+}
+
+func serve(ctx context.Context, cfg *config.Config, st *store.Store) error {
+ srv := &http.Server{
+ Addr: cfg.Addr,
+ Handler: web.NewServer(cfg, st),
+ ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: 60 * time.Second,
+ WriteTimeout: 60 * time.Second,
+ IdleTimeout: 120 * time.Second,
+ }
+ errc := make(chan error, 1)
+ go func() { errc <- srv.ListenAndServe() }()
+ log.Printf("listening on %s — dashboard at %s, blogs at http://<name>.%s (dev=%v)", cfg.Addr, cfg.RootURL(), cfg.HostWithPort(), cfg.Dev)
+ select {
+ case err := <-errc:
+ return err
+ case <-ctx.Done():
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ return srv.Shutdown(shutdownCtx)
+ }
+}
diff --git a/cmd/blogspace/seed.go b/cmd/blogspace/seed.go
new file mode 100644
index 0000000..847324c
--- /dev/null
+++ b/cmd/blogspace/seed.go
@@ -0,0 +1,108 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "log"
+
+ "github.com/gramanas/blogspace/internal/auth"
+ "github.com/gramanas/blogspace/internal/config"
+ "github.com/gramanas/blogspace/internal/markdown"
+ "github.com/gramanas/blogspace/internal/slug"
+ "github.com/gramanas/blogspace/internal/store"
+ "github.com/gramanas/blogspace/internal/web"
+)
+
+// seed creates demo content for local development. It is idempotent: it
+// skips anything that already exists.
+func seed(ctx context.Context, cfg *config.Config, st *store.Store) error {
+ if err := bootstrapSuperadmin(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
+ } else if !errors.Is(err, store.ErrNotFound) {
+ return err
+ }
+ hash, err := auth.HashPassword("alicealice")
+ if err != nil {
+ return err
+ }
+ _, blog, err := st.CreateBlogger(ctx, "alice", hash, "alice", "Alice's Corner")
+ if err != nil {
+ return err
+ }
+ if err := st.UpdateBlogSettings(ctx, blog.ID, blog.Title, "Notes on gardening, code and everything in between"); err != nil {
+ return err
+ }
+ home, err := st.HomePage(ctx, blog.ID)
+ if err != nil {
+ return err
+ }
+ pages := map[string]*store.Page{"home": home}
+ for _, p := range []store.Page{
+ {Slug: "about", Title: "About", IntroMD: "Hi, I'm **Alice**. I grow tomatoes and write Go. This page is a single-post page: just the intro text.\n\nYou can reach me on the [Contact](/contact) page.", ShowInNav: true},
+ {Slug: "news", Title: "News", IntroMD: "Short updates, newest first.", ShowInNav: true},
+ {Slug: "contact", Title: "Contact", IntroMD: "Email: alice@example.com\n\nOr find me at the Saturday market, stall 12.", ShowInNav: true},
+ } {
+ p.BlogID = blog.ID
+ p.IntroHTML = markdown.Render(p.IntroMD)
+ created, err := st.CreatePage(ctx, &p)
+ if err != nil {
+ return err
+ }
+ pages[p.Slug] = created
+ }
+
+ // a small gradient PNG so the background-image option has something to use
+ img := image.NewRGBA(image.Rect(0, 0, 256, 256))
+ for y := 0; y < 256; y++ {
+ for x := 0; x < 256; x++ {
+ img.Set(x, y, color.RGBA{R: uint8(200 + x/8), G: uint8(210 + y/8), B: 220, A: 255})
+ }
+ }
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ return err
+ }
+ bg, err := st.CreateImage(ctx, blog.ID, "gradient.png", "image/png", buf.Bytes())
+ if err != nil {
+ return err
+ }
+ theme := web.DefaultTheme()
+ theme.BgImage = bg.ID.String()
+ theme.BgMode = "tile"
+ theme.FooterText = "© Alice — made with Blogspace"
+ if err := st.UpdateBlogTheme(ctx, blog.ID, theme.JSON()); err != nil {
+ return err
+ }
+
+ // a second page of posts on the home page, to show pagination
+ for i := 0; i < 10; i++ {
+ body := fmt.Sprintf("Filler post number %d, here to demonstrate pagination.", i+1)
+ post := &store.Post{PageID: home.ID, Title: fmt.Sprintf("Filler post %d", i+1), Slug: fmt.Sprintf("filler-%d", i+1), BodyMD: body, BodyHTML: markdown.Render(body), Published: true}
+ if _, err := st.CreatePost(ctx, post); err != nil {
+ return err
+ }
+ }
+ posts := []struct{ page, title, body string }{
+ {"home", "Welcome to my corner of the web", "This is the **first post**. It lives on the home page.\n\nThings I plan to write about:\n\n- tomatoes\n- Go\n- the occasional recipe\n\n![gradient](/media/" + bg.ID.String() + ")\n\nThat image above was uploaded through the dashboard."},
+ {"home", "Why I still like plain HTML", "No frameworks, no build step. Just `<p>` tags and a stylesheet.\n\n> Simplicity is prerequisite for reliability. — Dijkstra\n\n```go\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n```"},
+ {"news", "Site is up", "The blog is live. Expect sporadic updates."},
+ {"news", "Tomato season", "First ripe tomato of the year! Table for the record:\n\n| Variety | Days |\n|---|---|\n| Cherry | 62 |\n| Beefsteak | 85 |"},
+ }
+ for _, p := range posts {
+ post := &store.Post{PageID: pages[p.page].ID, Title: p.title, Slug: slug.Make(p.title), BodyMD: p.body, BodyHTML: markdown.Render(p.body), Published: true}
+ if _, err := st.CreatePost(ctx, post); 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/compose.dev.yml b/compose.dev.yml
new file mode 100644
index 0000000..eb4d28c
--- /dev/null
+++ b/compose.dev.yml
@@ -0,0 +1,20 @@
+# Development: only Postgres runs in Docker; the app runs natively (see Makefile).
+services:
+ db:
+ image: postgres:17-alpine
+ environment:
+ POSTGRES_USER: blogspace
+ POSTGRES_PASSWORD: blogspace
+ POSTGRES_DB: blogspace
+ ports:
+ - "127.0.0.1:5432:5432"
+ volumes:
+ - pgdata-dev:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U blogspace"]
+ interval: 2s
+ timeout: 2s
+ retries: 30
+
+volumes:
+ pgdata-dev:
diff --git a/compose.yml b/compose.yml
new file mode 100644
index 0000000..4aa1dd3
--- /dev/null
+++ b/compose.yml
@@ -0,0 +1,33 @@
+# Production-style deployment: app + Postgres. Put a reverse proxy (nginx, caddy)
+# in front of 127.0.0.1:8080 that forwards example.com and *.example.com with
+# the original Host header. Configure via .env (see .env.example).
+services:
+ app:
+ build: .
+ restart: unless-stopped
+ env_file: .env
+ environment:
+ DATABASE_URL: postgres://blogspace:${POSTGRES_PASSWORD:-blogspace}@db:5432/blogspace?sslmode=disable
+ ports:
+ - "127.0.0.1:${APP_PORT:-8080}:8080"
+ depends_on:
+ db:
+ condition: service_healthy
+
+ db:
+ image: postgres:17-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: blogspace
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-blogspace}
+ POSTGRES_DB: blogspace
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U blogspace"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+
+volumes:
+ pgdata:
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..bd63c89
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,28 @@
+module github.com/gramanas/blogspace
+
+go 1.26.4
+
+require (
+ github.com/golang-jwt/jwt/v5 v5.3.1
+ github.com/google/uuid v1.6.0
+ github.com/jackc/pgx/v5 v5.11.0
+ github.com/microcosm-cc/bluemonday v1.0.27
+ github.com/pressly/goose/v3 v3.28.0
+ github.com/yuin/goldmark v1.8.6
+ golang.org/x/crypto v0.55.0
+ golang.org/x/text v0.42.0
+)
+
+require (
+ github.com/aymerick/douceur v0.2.0 // indirect
+ github.com/gorilla/css v1.0.1 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/mfridman/interpolate v0.0.2 // indirect
+ github.com/sethvargo/go-retry v0.4.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ golang.org/x/net v0.58.0 // indirect
+ golang.org/x/sync v0.23.0 // indirect
+ golang.org/x/sys v0.48.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..2abf197
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,65 @@
+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
+github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
+github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pressly/goose/v3 v3.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc=
+github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
+github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
+github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
+github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
+github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
+modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
+modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
new file mode 100644
index 0000000..d09ec3c
--- /dev/null
+++ b/internal/auth/auth_test.go
@@ -0,0 +1,46 @@
+package auth
+
+import (
+ "testing"
+ "time"
+)
+
+func TestTokenRoundTrip(t *testing.T) {
+ secret := []byte("test-secret-test-secret")
+ tok, err := IssueToken(secret, 42, 3, time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ c, err := ParseToken(secret, tok)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.UserID != 42 || c.TokenVersion != 3 {
+ t.Errorf("claims = %+v", c)
+ }
+ if _, err := ParseToken([]byte("other-secret-other-secret"), tok); err == nil {
+ t.Error("token accepted with wrong secret")
+ }
+ old, _ := IssueToken(secret, 42, 3, time.Now().Add(-8*24*time.Hour))
+ if _, err := ParseToken(secret, old); err == nil {
+ t.Error("expired token accepted")
+ }
+}
+
+func TestPasswordAndCSRF(t *testing.T) {
+ h, err := HashPassword("hunter22")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !CheckPassword(h, "hunter22") || CheckPassword(h, "hunter23") {
+ t.Error("password check wrong")
+ }
+ secret := []byte("s")
+ tok := CSRFToken(secret, 1, 0)
+ if !CheckCSRF(secret, 1, 0, tok) {
+ t.Error("valid csrf rejected")
+ }
+ if CheckCSRF(secret, 1, 1, tok) || CheckCSRF(secret, 2, 0, tok) || CheckCSRF(secret, 1, 0, "") {
+ t.Error("invalid csrf accepted")
+ }
+}
diff --git a/internal/auth/cookie.go b/internal/auth/cookie.go
new file mode 100644
index 0000000..9fd6b6e
--- /dev/null
+++ b/internal/auth/cookie.go
@@ -0,0 +1,23 @@
+package auth
+
+import (
+ "net/http"
+ "time"
+)
+
+const CookieName = "session"
+
+func SetSessionCookie(w http.ResponseWriter, token string) {
+ http.SetCookie(w, &http.Cookie{
+ Name: CookieName,
+ Value: token,
+ Path: "/",
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: int(SessionTTL / time.Second),
+ })
+}
+
+func ClearSessionCookie(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{Name: CookieName, Value: "", Path: "/", HttpOnly: true, MaxAge: -1})
+}
diff --git a/internal/auth/csrf.go b/internal/auth/csrf.go
new file mode 100644
index 0000000..da34309
--- /dev/null
+++ b/internal/auth/csrf.go
@@ -0,0 +1,20 @@
+package auth
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+)
+
+// CSRFToken derives a per-user token from the secret; it changes whenever the
+// user's token_version changes (password reset, disable) and needs no storage.
+func CSRFToken(secret []byte, userID int64, tokenVersion int) string {
+ m := hmac.New(sha256.New, secret)
+ fmt.Fprintf(m, "csrf:%d:%d", userID, tokenVersion)
+ return hex.EncodeToString(m.Sum(nil))
+}
+
+func CheckCSRF(secret []byte, userID int64, tokenVersion int, got string) bool {
+ return hmac.Equal([]byte(CSRFToken(secret, userID, tokenVersion)), []byte(got))
+}
diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go
new file mode 100644
index 0000000..a7fb52a
--- /dev/null
+++ b/internal/auth/jwt.go
@@ -0,0 +1,41 @@
+package auth
+
+import (
+ "errors"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+const SessionTTL = 7 * 24 * time.Hour
+
+type Claims struct {
+ jwt.RegisteredClaims
+ UserID int64 `json:"uid"`
+ TokenVersion int `json:"ver"`
+}
+
+func IssueToken(secret []byte, userID int64, tokenVersion int, now time.Time) (string, error) {
+ c := Claims{
+ RegisteredClaims: jwt.RegisteredClaims{
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(now.Add(SessionTTL)),
+ },
+ UserID: userID,
+ TokenVersion: tokenVersion,
+ }
+ return jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString(secret)
+}
+
+func ParseToken(secret []byte, tok string) (*Claims, error) {
+ var c Claims
+ _, err := jwt.ParseWithClaims(tok, &c, func(t *jwt.Token) (any, error) { return secret, nil },
+ jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired())
+ if err != nil {
+ return nil, err
+ }
+ if c.UserID == 0 {
+ return nil, errors.New("missing uid")
+ }
+ return &c, nil
+}
diff --git a/internal/auth/password.go b/internal/auth/password.go
new file mode 100644
index 0000000..97aba55
--- /dev/null
+++ b/internal/auth/password.go
@@ -0,0 +1,13 @@
+// Package auth implements password hashing, JWT sessions and CSRF tokens.
+package auth
+
+import "golang.org/x/crypto/bcrypt"
+
+func HashPassword(pw string) (string, error) {
+ h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
+ return string(h), err
+}
+
+func CheckPassword(hash, pw string) bool {
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..4972178
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,90 @@
+// Package config reads application settings from the environment.
+package config
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+)
+
+type Config struct {
+ Addr string // listen address, e.g. ":8080"
+ BaseDomain string // e.g. "example.com" (no port)
+ PublicPort string // port to append to generated blog URLs ("" in prod behind a proxy)
+ DatabaseURL string
+ JWTSecret []byte
+ // Bootstrap superadmin created on first start if no superadmin exists.
+ SuperadminUsername string
+ SuperadminPassword string
+ MaxUploadBytes int64
+ Dev bool // reload templates/static from disk
+}
+
+func Load() (*Config, error) {
+ c := &Config{
+ Addr: env("ADDR", ":8080"),
+ BaseDomain: strings.ToLower(strings.TrimSpace(env("BASE_DOMAIN", "blogspace.localhost"))),
+ PublicPort: env("PUBLIC_PORT", ""),
+ DatabaseURL: env("DATABASE_URL", "postgres://blogspace:blogspace@localhost:5432/blogspace?sslmode=disable"),
+ JWTSecret: []byte(env("JWT_SECRET", "")),
+ SuperadminUsername: env("SUPERADMIN_USERNAME", "admin"),
+ SuperadminPassword: env("SUPERADMIN_PASSWORD", ""),
+ Dev: envBool("DEV", false),
+ }
+ mb, err := strconv.Atoi(env("MAX_UPLOAD_MB", "5"))
+ if err != nil || mb <= 0 {
+ return nil, fmt.Errorf("MAX_UPLOAD_MB must be a positive integer")
+ }
+ c.MaxUploadBytes = int64(mb) << 20
+
+ if len(c.JWTSecret) == 0 {
+ if !c.Dev {
+ return nil, fmt.Errorf("JWT_SECRET must be set")
+ }
+ c.JWTSecret = []byte("dev-insecure-secret-change-me")
+ }
+ if len(c.JWTSecret) < 16 && !c.Dev {
+ return nil, fmt.Errorf("JWT_SECRET must be at least 16 bytes")
+ }
+ if c.BaseDomain == "" {
+ return nil, fmt.Errorf("BASE_DOMAIN must be set")
+ }
+ return c, nil
+}
+
+// BlogURL returns the public URL for a blog subdomain.
+func (c *Config) BlogURL(sub string) string {
+ return "http://" + sub + "." + c.HostWithPort()
+}
+
+// RootURL returns the public URL of the management site.
+func (c *Config) RootURL() string {
+ return "http://" + c.HostWithPort()
+}
+
+func (c *Config) HostWithPort() string {
+ if c.PublicPort != "" {
+ return c.BaseDomain + ":" + c.PublicPort
+ }
+ return c.BaseDomain
+}
+
+func env(key, def string) string {
+ if v, ok := os.LookupEnv(key); ok {
+ return v
+ }
+ return def
+}
+
+func envBool(key string, def bool) bool {
+ v, ok := os.LookupEnv(key)
+ if !ok {
+ return def
+ }
+ switch strings.ToLower(v) {
+ case "1", "true", "yes", "on":
+ return true
+ }
+ return false
+}
diff --git a/internal/db/db.go b/internal/db/db.go
new file mode 100644
index 0000000..44d489b
--- /dev/null
+++ b/internal/db/db.go
@@ -0,0 +1,43 @@
+// Package db opens the Postgres pool and applies embedded migrations.
+package db
+
+import (
+ "context"
+ "database/sql"
+ "embed"
+ "fmt"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ _ "github.com/jackc/pgx/v5/stdlib"
+ "github.com/pressly/goose/v3"
+)
+
+//go:embed migrations/*.sql
+var migrations embed.FS
+
+func Open(ctx context.Context, url string) (*pgxpool.Pool, error) {
+ pool, err := pgxpool.New(ctx, url)
+ if err != nil {
+ return nil, fmt.Errorf("connect: %w", err)
+ }
+ if err := pool.Ping(ctx); err != nil {
+ pool.Close()
+ return nil, fmt.Errorf("ping: %w", err)
+ }
+ return pool, nil
+}
+
+// Migrate applies all pending migrations using goose over database/sql.
+func Migrate(ctx context.Context, url string) error {
+ sqldb, err := sql.Open("pgx", url)
+ if err != nil {
+ return err
+ }
+ defer sqldb.Close()
+ goose.SetBaseFS(migrations)
+ goose.SetLogger(goose.NopLogger())
+ if err := goose.SetDialect("postgres"); err != nil {
+ return err
+ }
+ return goose.UpContext(ctx, sqldb, "migrations")
+}
diff --git a/internal/db/migrations/00001_init.sql b/internal/db/migrations/00001_init.sql
new file mode 100644
index 0000000..e27dad4
--- /dev/null
+++ b/internal/db/migrations/00001_init.sql
@@ -0,0 +1,68 @@
+-- +goose Up
+CREATE TABLE users (
+ id bigserial PRIMARY KEY,
+ username text NOT NULL UNIQUE,
+ password_hash text NOT NULL,
+ role text NOT NULL CHECK (role IN ('superadmin', 'blogger')),
+ disabled boolean NOT NULL DEFAULT false,
+ token_version integer NOT NULL DEFAULT 0,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE blogs (
+ id bigserial PRIMARY KEY,
+ owner_id bigint NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
+ subdomain text NOT NULL UNIQUE CHECK (subdomain ~ '^[a-z0-9](-?[a-z0-9]){0,62}$'),
+ title text NOT NULL,
+ tagline text NOT NULL DEFAULT '',
+ theme jsonb NOT NULL DEFAULT '{}'::jsonb,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE pages (
+ id bigserial PRIMARY KEY,
+ blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE,
+ slug text NOT NULL,
+ title text NOT NULL,
+ intro_md text NOT NULL DEFAULT '',
+ intro_html text NOT NULL DEFAULT '',
+ nav_order integer NOT NULL DEFAULT 0,
+ show_in_nav boolean NOT NULL DEFAULT true,
+ is_home boolean NOT NULL DEFAULT false,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (blog_id, slug)
+);
+CREATE UNIQUE INDEX pages_one_home_per_blog ON pages (blog_id) WHERE is_home;
+
+CREATE TABLE posts (
+ id bigserial PRIMARY KEY,
+ page_id bigint NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
+ slug text NOT NULL,
+ title text NOT NULL,
+ body_md text NOT NULL DEFAULT '',
+ body_html text NOT NULL DEFAULT '',
+ published boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (page_id, slug)
+);
+CREATE INDEX posts_page_created ON posts (page_id, created_at DESC);
+
+CREATE TABLE images (
+ id uuid PRIMARY KEY,
+ blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE,
+ filename text NOT NULL,
+ content_type text NOT NULL,
+ size integer NOT NULL,
+ data bytea NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX images_blog ON images (blog_id, created_at DESC);
+
+-- +goose Down
+DROP TABLE images;
+DROP TABLE posts;
+DROP TABLE pages;
+DROP TABLE blogs;
+DROP TABLE users;
diff --git a/internal/markdown/render.go b/internal/markdown/render.go
new file mode 100644
index 0000000..f9a2bfd
--- /dev/null
+++ b/internal/markdown/render.go
@@ -0,0 +1,38 @@
+// Package markdown renders untrusted Markdown to sanitized HTML.
+package markdown
+
+import (
+ "bytes"
+
+ "github.com/microcosm-cc/bluemonday"
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer/html"
+)
+
+var md = goldmark.New(
+ goldmark.WithExtensions(extension.GFM, extension.Typographer),
+ goldmark.WithParserOptions(parser.WithAutoHeadingID()),
+ goldmark.WithRendererOptions(html.WithHardWraps(), html.WithUnsafe()), // unsafe output is sanitized below
+)
+
+var policy = func() *bluemonday.Policy {
+ p := bluemonday.UGCPolicy()
+ p.AllowAttrs("id").OnElements("h1", "h2", "h3", "h4", "h5", "h6")
+ p.AllowAttrs("class").Matching(bluemonday.SpaceSeparatedTokens).OnElements("code", "pre", "span", "div", "table", "input", "li", "ul")
+ p.AllowAttrs("type", "checked", "disabled").OnElements("input")
+ p.AllowAttrs("align").OnElements("th", "td")
+ p.AllowAttrs("width", "height").OnElements("img")
+ p.RequireNoFollowOnLinks(false)
+ return p
+}()
+
+// Render converts Markdown to HTML that is safe to embed unescaped.
+func Render(src string) string {
+ var buf bytes.Buffer
+ if err := md.Convert([]byte(src), &buf); err != nil {
+ return "<p>(could not render content)</p>"
+ }
+ return policy.Sanitize(buf.String())
+}
diff --git a/internal/markdown/render_test.go b/internal/markdown/render_test.go
new file mode 100644
index 0000000..7240afc
--- /dev/null
+++ b/internal/markdown/render_test.go
@@ -0,0 +1,29 @@
+package markdown
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRenderSanitizes(t *testing.T) {
+ out := Render("# Hi\n\n**bold** <script>alert(1)</script> <a href=\"javascript:alert(1)\">x</a> <img src=\"/media/abc\" onerror=\"x()\">")
+ for _, bad := range []string{"<script", "javascript:", "onerror"} {
+ if strings.Contains(out, bad) {
+ t.Errorf("output contains %q: %s", bad, out)
+ }
+ }
+ for _, good := range []string{"<h1", "<strong>bold</strong>", `<img src="/media/abc"`} {
+ if !strings.Contains(out, good) {
+ t.Errorf("output missing %q: %s", good, out)
+ }
+ }
+}
+
+func TestRenderGFM(t *testing.T) {
+ out := Render("| a | b |\n|---|---|\n| 1 | 2 |\n\nline one\nline two\n\nhttps://example.org")
+ for _, want := range []string{"<table>", "<br>", `<a href="https://example.org"`} {
+ if !strings.Contains(out, want) {
+ t.Errorf("output missing %q: %s", want, out)
+ }
+ }
+}
diff --git a/internal/slug/slug.go b/internal/slug/slug.go
new file mode 100644
index 0000000..a3c99a4
--- /dev/null
+++ b/internal/slug/slug.go
@@ -0,0 +1,54 @@
+// Package slug turns titles into URL-safe identifiers.
+package slug
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+ "unicode"
+
+ "golang.org/x/text/runes"
+ "golang.org/x/text/transform"
+ "golang.org/x/text/unicode/norm"
+)
+
+var (
+ nonAlnum = regexp.MustCompile(`[^a-z0-9]+`)
+ validRe = regexp.MustCompile(`^[a-z0-9](?:-?[a-z0-9])*$`)
+ stripMarks = transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
+)
+
+// greek maps Greek letters to Latin so Greek titles get readable slugs.
+var greek = strings.NewReplacer(
+ "α", "a", "β", "v", "γ", "g", "δ", "d", "ε", "e", "ζ", "z", "η", "i", "θ", "th",
+ "ι", "i", "κ", "k", "λ", "l", "μ", "m", "ν", "n", "ξ", "x", "ο", "o", "π", "p",
+ "ρ", "r", "σ", "s", "ς", "s", "τ", "t", "υ", "y", "φ", "f", "χ", "ch", "ψ", "ps", "ω", "o",
+)
+
+// Make returns a slug for s; falls back to "untitled" when nothing usable remains.
+func Make(s string) string {
+ if t, _, err := transform.String(stripMarks, s); err == nil {
+ s = t
+ }
+ s = greek.Replace(strings.ToLower(s))
+ s = nonAlnum.ReplaceAllString(s, "-")
+ s = strings.Trim(s, "-")
+ if len(s) > 80 {
+ s = strings.Trim(s[:80], "-")
+ }
+ if s == "" {
+ return "untitled"
+ }
+ return s
+}
+
+// WithSuffix returns "base-n" (or base when n < 2), used to resolve collisions.
+func WithSuffix(base string, n int) string {
+ if n < 2 {
+ return base
+ }
+ return fmt.Sprintf("%s-%d", base, n)
+}
+
+// Valid reports whether s is an acceptable slug as typed by a user.
+func Valid(s string) bool { return validRe.MatchString(s) && len(s) <= 80 }
diff --git a/internal/slug/slug_test.go b/internal/slug/slug_test.go
new file mode 100644
index 0000000..9953f34
--- /dev/null
+++ b/internal/slug/slug_test.go
@@ -0,0 +1,34 @@
+package slug
+
+import "testing"
+
+func TestMake(t *testing.T) {
+ cases := map[string]string{
+ "Hello, World!": "hello-world",
+ " Ünïcödé Tïtle ": "unicode-title",
+ "!!!": "untitled",
+ "": "untitled",
+ "already-a-slug": "already-a-slug",
+ "Multiple spaces--": "multiple-spaces",
+ "Καλημέρα κόσμε": "kalimera-kosme",
+ "日本語": "untitled", // unsupported scripts fall back; user can type a slug
+ }
+ for in, want := range cases {
+ if got := Make(in); got != want {
+ t.Errorf("Make(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestValid(t *testing.T) {
+ for _, ok := range []string{"a", "abc-def", "a1-b2"} {
+ if !Valid(ok) {
+ t.Errorf("Valid(%q) = false", ok)
+ }
+ }
+ for _, bad := range []string{"", "-a", "a-", "a--b", "A", "a b", "a/b"} {
+ if Valid(bad) {
+ t.Errorf("Valid(%q) = true", bad)
+ }
+ }
+}
diff --git a/internal/store/blogs.go b/internal/store/blogs.go
new file mode 100644
index 0000000..57a7c10
--- /dev/null
+++ b/internal/store/blogs.go
@@ -0,0 +1,84 @@
+package store
+
+import (
+ "context"
+ "encoding/json"
+ "time"
+)
+
+type Blog struct {
+ ID int64
+ OwnerID int64
+ Subdomain string
+ Title string
+ Tagline string
+ ThemeJSON json.RawMessage
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+const blogCols = `id, owner_id, subdomain, title, tagline, theme, created_at, updated_at`
+
+func scanBlog(row interface{ Scan(...any) error }) (*Blog, error) {
+ var b Blog
+ err := row.Scan(&b.ID, &b.OwnerID, &b.Subdomain, &b.Title, &b.Tagline, &b.ThemeJSON, &b.CreatedAt, &b.UpdatedAt)
+ if err != nil {
+ return nil, wrap(err)
+ }
+ return &b, nil
+}
+
+// CreateBlogger creates a user, their blog and a default home page in one transaction.
+func (s *Store) CreateBlogger(ctx context.Context, username, passwordHash, subdomain, title string) (*User, *Blog, error) {
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return nil, nil, err
+ }
+ defer tx.Rollback(ctx)
+
+ u, err := scanUser(tx.QueryRow(ctx, `INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING `+userCols,
+ username, passwordHash, RoleBlogger))
+ 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))
+ 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
+}
+
+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))
+}
+
+func (s *Store) BlogBySubdomain(ctx context.Context, sub string) (*Blog, error) {
+ return scanBlog(s.db.QueryRow(ctx, `SELECT `+blogCols+` FROM blogs WHERE subdomain=$1`, sub))
+}
+
+func (s *Store) BlogByOwner(ctx context.Context, ownerID int64) (*Blog, error) {
+ return scanBlog(s.db.QueryRow(ctx, `SELECT `+blogCols+` FROM blogs WHERE owner_id=$1`, ownerID))
+}
+
+func (s *Store) UpdateBlogSettings(ctx context.Context, id int64, title, tagline string) error {
+ _, err := s.db.Exec(ctx, `UPDATE blogs SET title=$2, tagline=$3, updated_at=now() WHERE id=$1`, id, title, tagline)
+ return err
+}
+
+func (s *Store) UpdateBlogTheme(ctx context.Context, id int64, theme json.RawMessage) error {
+ _, err := s.db.Exec(ctx, `UPDATE blogs SET theme=$2, updated_at=now() WHERE id=$1`, id, theme)
+ return err
+}
+
+func (s *Store) DeleteBlog(ctx context.Context, id int64) error {
+ _, err := s.db.Exec(ctx, `DELETE FROM blogs WHERE id=$1`, id)
+ return err
+}
diff --git a/internal/store/images.go b/internal/store/images.go
new file mode 100644
index 0000000..3699e75
--- /dev/null
+++ b/internal/store/images.go
@@ -0,0 +1,58 @@
+package store
+
+import (
+ "context"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Image struct {
+ ID uuid.UUID
+ BlogID int64
+ Filename string
+ ContentType string
+ Size int
+ Data []byte // only populated by ImageData
+ CreatedAt time.Time
+}
+
+func (s *Store) CreateImage(ctx context.Context, blogID int64, filename, contentType string, data []byte) (*Image, error) {
+ img := &Image{ID: uuid.New(), BlogID: blogID, Filename: filename, ContentType: contentType, Size: len(data)}
+ err := s.db.QueryRow(ctx, `INSERT INTO images (id, blog_id, filename, content_type, size, data) VALUES ($1,$2,$3,$4,$5,$6) RETURNING created_at`,
+ img.ID, blogID, filename, contentType, len(data), data).Scan(&img.CreatedAt)
+ return img, err
+}
+
+func (s *Store) ListImages(ctx context.Context, blogID int64) ([]Image, error) {
+ rows, err := s.db.Query(ctx, `SELECT id, blog_id, filename, content_type, size, created_at FROM images WHERE blog_id=$1 ORDER BY created_at DESC`, blogID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Image
+ for rows.Next() {
+ var i Image
+ if err := rows.Scan(&i.ID, &i.BlogID, &i.Filename, &i.ContentType, &i.Size, &i.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, i)
+ }
+ return out, rows.Err()
+}
+
+// ImageData loads an image including its bytes.
+func (s *Store) ImageData(ctx context.Context, id uuid.UUID) (*Image, error) {
+ var i Image
+ err := s.db.QueryRow(ctx, `SELECT id, blog_id, filename, content_type, size, data, created_at FROM images WHERE id=$1`, id).
+ Scan(&i.ID, &i.BlogID, &i.Filename, &i.ContentType, &i.Size, &i.Data, &i.CreatedAt)
+ if err != nil {
+ return nil, wrap(err)
+ }
+ return &i, nil
+}
+
+func (s *Store) DeleteImage(ctx context.Context, blogID int64, id uuid.UUID) error {
+ _, err := s.db.Exec(ctx, `DELETE FROM images WHERE blog_id=$1 AND id=$2`, blogID, id)
+ return err
+}
diff --git a/internal/store/pages.go b/internal/store/pages.go
new file mode 100644
index 0000000..87d440f
--- /dev/null
+++ b/internal/store/pages.go
@@ -0,0 +1,124 @@
+package store
+
+import (
+ "context"
+ "time"
+)
+
+type Page struct {
+ ID int64
+ BlogID int64
+ Slug string
+ Title string
+ IntroMD string
+ IntroHTML string
+ NavOrder int
+ ShowInNav bool
+ IsHome bool
+ CreatedAt time.Time
+ PostCount int // filled by ListPages only
+}
+
+const pageCols = `id, blog_id, slug, title, intro_md, intro_html, nav_order, show_in_nav, is_home, created_at`
+
+func scanPage(row interface{ Scan(...any) error }) (*Page, error) {
+ var p Page
+ err := row.Scan(&p.ID, &p.BlogID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt)
+ if err != nil {
+ return nil, wrap(err)
+ }
+ return &p, nil
+}
+
+func (s *Store) ListPages(ctx context.Context, blogID int64) ([]Page, error) {
+ rows, err := s.db.Query(ctx, `SELECT `+pageCols+`, (SELECT count(*) FROM posts WHERE page_id=pages.id)
+ FROM pages WHERE blog_id=$1 ORDER BY nav_order, id`, blogID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Page
+ for rows.Next() {
+ var p Page
+ if err := rows.Scan(&p.ID, &p.BlogID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil {
+ return nil, err
+ }
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) PageByID(ctx context.Context, blogID, id int64) (*Page, error) {
+ return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 AND id=$2`, blogID, id))
+}
+
+func (s *Store) PageBySlug(ctx context.Context, blogID int64, slug string) (*Page, error) {
+ return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 AND slug=$2`, blogID, slug))
+}
+
+func (s *Store) HomePage(ctx context.Context, blogID int64) (*Page, error) {
+ return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 ORDER BY is_home DESC, nav_order, id LIMIT 1`, blogID))
+}
+
+func (s *Store) CreatePage(ctx context.Context, p *Page) (*Page, error) {
+ return scanPage(s.db.QueryRow(ctx, `INSERT INTO pages (blog_id, slug, title, intro_md, intro_html, nav_order, show_in_nav)
+ VALUES ($1,$2,$3,$4,$5,(SELECT coalesce(max(nav_order),-1)+1 FROM pages WHERE blog_id=$1),$6) RETURNING `+pageCols,
+ p.BlogID, p.Slug, p.Title, p.IntroMD, p.IntroHTML, p.ShowInNav))
+}
+
+func (s *Store) UpdatePage(ctx context.Context, p *Page) error {
+ _, err := s.db.Exec(ctx, `UPDATE pages SET slug=$3, title=$4, intro_md=$5, intro_html=$6, show_in_nav=$7 WHERE blog_id=$1 AND id=$2`,
+ p.BlogID, p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML, p.ShowInNav)
+ return wrap(err)
+}
+
+func (s *Store) DeletePage(ctx context.Context, blogID, id int64) error {
+ _, err := s.db.Exec(ctx, `DELETE FROM pages WHERE blog_id=$1 AND id=$2 AND NOT is_home`, blogID, id)
+ return err
+}
+
+// SetHomePage moves the home flag to the given page.
+func (s *Store) SetHomePage(ctx context.Context, blogID, id int64) error {
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=false WHERE blog_id=$1 AND is_home`, blogID); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=true WHERE blog_id=$1 AND id=$2`, blogID, id); err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+}
+
+// MovePage swaps nav_order with the neighbouring page (dir = -1 up, +1 down).
+func (s *Store) MovePage(ctx context.Context, blogID, id int64, dir int) error {
+ pages, err := s.ListPages(ctx, blogID)
+ if err != nil {
+ return err
+ }
+ idx := -1
+ for i := range pages {
+ if pages[i].ID == id {
+ idx = i
+ }
+ }
+ j := idx + dir
+ if idx < 0 || j < 0 || j >= len(pages) {
+ return nil
+ }
+ pages[idx], pages[j] = pages[j], pages[idx]
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ for i, p := range pages { // renumber everything; keeps orders dense
+ if _, err := tx.Exec(ctx, `UPDATE pages SET nav_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, p.ID, i); err != nil {
+ return err
+ }
+ }
+ return tx.Commit(ctx)
+}
diff --git a/internal/store/posts.go b/internal/store/posts.go
new file mode 100644
index 0000000..06bea7f
--- /dev/null
+++ b/internal/store/posts.go
@@ -0,0 +1,105 @@
+package store
+
+import (
+ "context"
+ "time"
+)
+
+type Post struct {
+ ID int64
+ PageID int64
+ Slug string
+ Title string
+ BodyMD string
+ BodyHTML string
+ Published bool
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ // joined
+ PageSlug string
+ PageTitle string
+}
+
+const postCols = `p.id, p.page_id, p.slug, p.title, p.body_md, p.body_html, p.published, p.created_at, p.updated_at, g.slug, g.title`
+
+func scanPost(row interface{ Scan(...any) error }) (*Post, error) {
+ var p Post
+ err := row.Scan(&p.ID, &p.PageID, &p.Slug, &p.Title, &p.BodyMD, &p.BodyHTML, &p.Published, &p.CreatedAt, &p.UpdatedAt, &p.PageSlug, &p.PageTitle)
+ if err != nil {
+ return nil, wrap(err)
+ }
+ return &p, nil
+}
+
+func (s *Store) collectPosts(ctx context.Context, q string, args ...any) ([]Post, error) {
+ rows, err := s.db.Query(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Post
+ for rows.Next() {
+ p, err := scanPost(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *p)
+ }
+ return out, rows.Err()
+}
+
+// ListPosts returns all posts of a blog for the dashboard, optionally filtered by page.
+func (s *Store) ListPosts(ctx context.Context, blogID int64, pageID int64) ([]Post, error) {
+ return s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE g.blog_id=$1 AND ($2=0 OR p.page_id=$2) ORDER BY p.created_at DESC, p.id DESC`, blogID, pageID)
+}
+
+// PublishedPosts returns a page of published posts for the public site.
+func (s *Store) PublishedPosts(ctx context.Context, pageID int64, limit, offset int) ([]Post, int, error) {
+ posts, err := s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE p.page_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $2 OFFSET $3`, pageID, limit, offset)
+ if err != nil {
+ return nil, 0, err
+ }
+ var total int
+ err = s.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE page_id=$1 AND published`, pageID).Scan(&total)
+ return posts, total, err
+}
+
+// RecentPublishedPosts returns the newest published posts across a whole blog (for feeds).
+func (s *Store) RecentPublishedPosts(ctx context.Context, blogID int64, limit int) ([]Post, error) {
+ return s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE g.blog_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $2`, blogID, limit)
+}
+
+func (s *Store) PostByID(ctx context.Context, blogID, id int64) (*Post, error) {
+ return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE g.blog_id=$1 AND p.id=$2`, blogID, id))
+}
+
+func (s *Store) PublishedPostBySlug(ctx context.Context, pageID int64, slug string) (*Post, error) {
+ return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE p.page_id=$1 AND p.slug=$2 AND p.published`, pageID, slug))
+}
+
+func (s *Store) CreatePost(ctx context.Context, p *Post) (*Post, error) {
+ var id int64
+ err := s.db.QueryRow(ctx, `INSERT INTO posts (page_id, slug, title, body_md, body_html, published) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
+ p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, p.Published).Scan(&id)
+ if err != nil {
+ return nil, wrap(err)
+ }
+ return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id WHERE p.id=$1`, id))
+}
+
+// UpdatePost updates a post; the page must belong to the same blog (checked by handler).
+func (s *Store) UpdatePost(ctx context.Context, p *Post) error {
+ _, err := s.db.Exec(ctx, `UPDATE posts SET page_id=$2, slug=$3, title=$4, body_md=$5, body_html=$6, published=$7, updated_at=now() WHERE id=$1`,
+ p.ID, p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, p.Published)
+ return wrap(err)
+}
+
+func (s *Store) DeletePost(ctx context.Context, blogID, id int64) error {
+ _, err := s.db.Exec(ctx, `DELETE FROM posts p USING pages g WHERE g.id=p.page_id AND g.blog_id=$1 AND p.id=$2`, blogID, id)
+ return err
+}
diff --git a/internal/store/store.go b/internal/store/store.go
new file mode 100644
index 0000000..472bb2a
--- /dev/null
+++ b/internal/store/store.go
@@ -0,0 +1,34 @@
+// Package store holds the data models and all SQL queries.
+package store
+
+import (
+ "errors"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+var ErrNotFound = errors.New("not found")
+var ErrConflict = errors.New("already exists")
+
+type Store struct {
+ db *pgxpool.Pool
+}
+
+func New(db *pgxpool.Pool) *Store { return &Store{db: db} }
+
+// wrap maps driver errors onto the store's sentinel errors.
+func wrap(err error) error {
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ErrNotFound
+ }
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation
+ return ErrConflict
+ }
+ return err
+}
diff --git a/internal/store/users.go b/internal/store/users.go
new file mode 100644
index 0000000..2910888
--- /dev/null
+++ b/internal/store/users.go
@@ -0,0 +1,99 @@
+package store
+
+import (
+ "context"
+ "time"
+)
+
+const (
+ RoleSuperadmin = "superadmin"
+ RoleBlogger = "blogger"
+)
+
+type User struct {
+ ID int64
+ Username string
+ PasswordHash string
+ Role string
+ Disabled bool
+ TokenVersion int
+ CreatedAt time.Time
+}
+
+func (u *User) IsSuperadmin() bool { return u.Role == RoleSuperadmin }
+
+const userCols = `id, username, password_hash, role, disabled, token_version, created_at`
+
+func scanUser(row interface{ Scan(...any) error }) (*User, error) {
+ var u User
+ err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Disabled, &u.TokenVersion, &u.CreatedAt)
+ if err != nil {
+ return nil, wrap(err)
+ }
+ return &u, nil
+}
+
+func (s *Store) CreateUser(ctx context.Context, username, passwordHash, role string) (*User, error) {
+ row := s.db.QueryRow(ctx, `INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING `+userCols,
+ username, passwordHash, role)
+ return scanUser(row)
+}
+
+func (s *Store) UserByID(ctx context.Context, id int64) (*User, error) {
+ return scanUser(s.db.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE id=$1`, id))
+}
+
+func (s *Store) UserByUsername(ctx context.Context, username string) (*User, error) {
+ return scanUser(s.db.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE username=$1`, username))
+}
+
+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)
+ return n, err
+}
+
+// SetPassword replaces the hash and bumps token_version so existing sessions die.
+func (s *Store) SetPassword(ctx context.Context, id int64, passwordHash string) error {
+ _, err := s.db.Exec(ctx, `UPDATE users SET password_hash=$2, token_version=token_version+1 WHERE id=$1`, id, passwordHash)
+ return err
+}
+
+func (s *Store) SetUserDisabled(ctx context.Context, id int64, disabled bool) error {
+ _, err := s.db.Exec(ctx, `UPDATE users SET disabled=$2, token_version=token_version+1 WHERE id=$1`, id, disabled)
+ return err
+}
+
+func (s *Store) DeleteUser(ctx context.Context, id int64) error {
+ _, err := s.db.Exec(ctx, `DELETE FROM users WHERE id=$1`, id)
+ return err
+}
+
+// UserWithBlog is a row for the admin overview.
+type UserWithBlog struct {
+ User
+ BlogID *int64
+ Subdomain *string
+ BlogTitle *string
+}
+
+func (s *Store) ListUsers(ctx context.Context) ([]UserWithBlog, error) {
+ rows, err := s.db.Query(ctx, `SELECT u.id, u.username, u.password_hash, u.role, u.disabled, u.token_version, u.created_at,
+ b.id, b.subdomain, b.title
+ FROM users u LEFT JOIN blogs b ON b.owner_id = u.id
+ ORDER BY u.role, u.username`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []UserWithBlog
+ for rows.Next() {
+ var r UserWithBlog
+ if err := rows.Scan(&r.ID, &r.Username, &r.PasswordHash, &r.Role, &r.Disabled, &r.TokenVersion, &r.CreatedAt,
+ &r.BlogID, &r.Subdomain, &r.BlogTitle); err != nil {
+ return nil, err
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go
new file mode 100644
index 0000000..30fdc2e
--- /dev/null
+++ b/internal/web/handlers_admin.go
@@ -0,0 +1,154 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/gramanas/blogspace/internal/auth"
+ "github.com/gramanas/blogspace/internal/store"
+)
+
+var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]{2,40}$`)
+var subdomainRe = regexp.MustCompile(`^[a-z0-9](-?[a-z0-9]){0,62}$`)
+
+func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
+ users, err := s.st.ListUsers(r.Context())
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ s.render(w, r, "admin/index.html", map[string]any{"users": users, "cfg": s.cfg})
+}
+
+func (s *Server) handleAdminNewUserForm(w http.ResponseWriter, r *http.Request) {
+ s.render(w, r, "admin/new_user.html", map[string]any{"cfg": s.cfg})
+}
+
+func (s *Server) handleAdminNewUser(w http.ResponseWriter, r *http.Request) {
+ f := map[string]any{
+ "username": strings.TrimSpace(r.FormValue("username")),
+ "subdomain": strings.ToLower(strings.TrimSpace(r.FormValue("subdomain"))),
+ "title": strings.TrimSpace(r.FormValue("title")),
+ "cfg": s.cfg,
+ }
+ pw := r.FormValue("password")
+ username, sub, title := f["username"].(string), f["subdomain"].(string), f["title"].(string)
+ if sub == "" {
+ sub = strings.ToLower(username)
+ f["subdomain"] = sub
+ }
+ if title == "" {
+ title = username + "'s blog"
+ }
+ var msg string
+ switch {
+ case !usernameRe.MatchString(username):
+ msg = "Username: 2-40 letters, digits, dots, dashes or underscores."
+ case len(pw) < 8:
+ msg = "Password must be at least 8 characters."
+ case !subdomainRe.MatchString(sub) || reservedSubdomains[sub]:
+ msg = "Subdomain: lowercase letters, digits and single dashes; not a reserved name."
+ }
+ if msg != "" {
+ f["error"] = msg
+ s.renderStatus(w, r, http.StatusBadRequest, "admin/new_user.html", f)
+ return
+ }
+ hash, err := auth.HashPassword(pw)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ _, blog, err := s.st.CreateBlogger(r.Context(), username, hash, sub, title)
+ if err != nil {
+ if errors.Is(err, store.ErrConflict) {
+ f["error"] = "Username or subdomain already taken."
+ s.renderStatus(w, r, http.StatusConflict, "admin/new_user.html", f)
+ return
+ }
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, "/admin/", "Created "+username+" with blog "+blog.Subdomain+".")
+}
+
+func (s *Server) adminTargetUser(w http.ResponseWriter, r *http.Request) *store.User {
+ id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ u, err := s.st.UserByID(r.Context(), id)
+ if err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ http.NotFound(w, r)
+ } else {
+ s.serverError(w, err)
+ }
+ return nil
+ }
+ if u.ID == currentUser(r).ID {
+ s.plainError(w, http.StatusBadRequest, "You cannot do that to your own account here; use Account > Password.")
+ return nil
+ }
+ return u
+}
+
+func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
+ u := s.adminTargetUser(w, r)
+ if u == nil {
+ return
+ }
+ pw := r.FormValue("password")
+ if len(pw) < 8 {
+ s.plainError(w, http.StatusBadRequest, "Password must be at least 8 characters.")
+ return
+ }
+ hash, err := auth.HashPassword(pw)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ if err := s.st.SetPassword(r.Context(), u.ID, hash); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, "/admin/", "Password reset for "+u.Username+".")
+}
+
+func (s *Server) handleAdminSetDisabled(disabled bool) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u := s.adminTargetUser(w, r)
+ if u == nil {
+ return
+ }
+ if err := s.st.SetUserDisabled(r.Context(), u.ID, disabled); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ verb := "enabled"
+ if disabled {
+ verb = "disabled"
+ }
+ redirectOK(w, r, "/admin/", u.Username+" "+verb+".")
+ }
+}
+
+func (s *Server) handleAdminDeleteUserConfirm(w http.ResponseWriter, r *http.Request) {
+ u := s.adminTargetUser(w, r)
+ if u == nil {
+ return
+ }
+ s.render(w, r, "admin/delete_user.html", map[string]any{"target": u})
+}
+
+func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
+ u := s.adminTargetUser(w, r)
+ if u == nil {
+ return
+ }
+ if err := s.st.DeleteUser(r.Context(), u.ID); err != nil { // cascades to blog, pages, posts, images
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, "/admin/", "Deleted "+u.Username+" and their blog.")
+}
diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go
new file mode 100644
index 0000000..4554d94
--- /dev/null
+++ b/internal/web/handlers_auth.go
@@ -0,0 +1,131 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gramanas/blogspace/internal/auth"
+ "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)
+ return
+ }
+ s.render(w, r, "auth/login.html", map[string]any{"next": safeNext(r.URL.Query().Get("next"))})
+}
+
+func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
+ username := strings.TrimSpace(r.FormValue("username"))
+ password := r.FormValue("password")
+ next := safeNext(r.FormValue("next"))
+ fail := func() {
+ s.renderStatus(w, r, http.StatusUnauthorized, "auth/login.html",
+ map[string]any{"error": "Wrong username or password.", "username": username, "next": next})
+ }
+ u, err := s.st.UserByUsername(r.Context(), username)
+ if err != nil {
+ if !errors.Is(err, store.ErrNotFound) {
+ s.serverError(w, err)
+ return
+ }
+ auth.CheckPassword("$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinval", password) // constant-ish time
+ fail()
+ return
+ }
+ if u.Disabled || !auth.CheckPassword(u.PasswordHash, password) {
+ fail()
+ return
+ }
+ tok, err := auth.IssueToken(s.cfg.JWTSecret, u.ID, u.TokenVersion, time.Now())
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ auth.SetSessionCookie(w, tok)
+ if next == "" {
+ next = "/dashboard"
+ }
+ http.Redirect(w, r, next, http.StatusSeeOther)
+}
+
+func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
+ auth.ClearSessionCookie(w)
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+}
+
+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) {
+ s.plainError(w, http.StatusNotFound, "You have no blog yet. Ask the administrator to create one.")
+ return
+ }
+ s.serverError(w, err)
+ return
+ }
+ http.Redirect(w, r, "/b/"+blog.Subdomain+"/", http.StatusSeeOther)
+}
+
+func (s *Server) handlePasswordForm(w http.ResponseWriter, r *http.Request) {
+ s.render(w, r, "dashboard/password.html", nil)
+}
+
+func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request) {
+ u := currentUser(r)
+ cur, pw, pw2 := r.FormValue("current"), r.FormValue("password"), r.FormValue("password2")
+ var msg string
+ switch {
+ case !auth.CheckPassword(u.PasswordHash, cur):
+ msg = "Current password is wrong."
+ case len(pw) < 8:
+ msg = "New password must be at least 8 characters."
+ case pw != pw2:
+ msg = "New passwords do not match."
+ }
+ if msg != "" {
+ s.renderStatus(w, r, http.StatusBadRequest, "dashboard/password.html", map[string]any{"error": msg})
+ return
+ }
+ hash, err := auth.HashPassword(pw)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ if err := s.st.SetPassword(r.Context(), u.ID, hash); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ // token_version changed, so re-issue the session instead of logging the user out
+ tok, err := auth.IssueToken(s.cfg.JWTSecret, u.ID, u.TokenVersion+1, time.Now())
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ auth.SetSessionCookie(w, tok)
+ redirectOK(w, r, "/dashboard", "Password changed.")
+}
+
+// safeNext only allows local paths as post-login redirect targets.
+func safeNext(n string) string {
+ if strings.HasPrefix(n, "/") && !strings.HasPrefix(n, "//") {
+ return n
+ }
+ return ""
+}
diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go
new file mode 100644
index 0000000..b768c4d
--- /dev/null
+++ b/internal/web/handlers_blog.go
@@ -0,0 +1,150 @@
+package web
+
+import (
+ "encoding/xml"
+ "errors"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/gramanas/blogspace/internal/store"
+)
+
+const postsPerPage = 10
+
+// blogView gathers what every public page needs: theme css, nav, the blog.
+func (s *Server) blogView(r *http.Request) (map[string]any, error) {
+ blog := currentBlog(r)
+ pages, err := s.st.ListPages(r.Context(), blog.ID)
+ if err != nil {
+ return nil, err
+ }
+ theme := ParseTheme(blog.ThemeJSON)
+ var nav []store.Page
+ for _, p := range pages {
+ if p.ShowInNav && (theme.NavShowHome || !p.IsHome) {
+ nav = append(nav, p)
+ }
+ }
+ return map[string]any{"theme": theme, "css": theme.CSS(), "nav": nav}, nil
+}
+
+func (s *Server) handleBlogHome(w http.ResponseWriter, r *http.Request) {
+ page, err := s.st.HomePage(r.Context(), currentBlog(r).ID)
+ if err != nil {
+ s.blogNotFound(w, r)
+ return
+ }
+ s.renderPage(w, r, page)
+}
+
+func (s *Server) handleBlogPage(w http.ResponseWriter, r *http.Request) {
+ page, err := s.st.PageBySlug(r.Context(), currentBlog(r).ID, r.PathValue("page"))
+ if err != nil {
+ s.blogNotFound(w, r)
+ return
+ }
+ if page.IsHome { // canonical URL for the home page is /
+ http.Redirect(w, r, "/", http.StatusMovedPermanently)
+ return
+ }
+ s.renderPage(w, r, page)
+}
+
+func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store.Page) {
+ v, err := s.blogView(r)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ n, _ := strconv.Atoi(r.URL.Query().Get("p"))
+ if n < 1 {
+ n = 1
+ }
+ posts, total, err := s.st.PublishedPosts(r.Context(), page.ID, postsPerPage, (n-1)*postsPerPage)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ last := (total + postsPerPage - 1) / postsPerPage
+ v["page"], v["posts"], v["pageNum"], v["lastPage"] = page, posts, n, last
+ v["base"] = "/" + page.Slug
+ if page.IsHome {
+ v["base"] = ""
+ }
+ s.render(w, r, "blog/page.html", v)
+}
+
+func (s *Server) handleBlogPost(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ page, err := s.st.PageBySlug(r.Context(), blog.ID, r.PathValue("page"))
+ if err != nil {
+ s.blogNotFound(w, r)
+ return
+ }
+ post, err := s.st.PublishedPostBySlug(r.Context(), page.ID, r.PathValue("post"))
+ if err != nil {
+ s.blogNotFound(w, r)
+ return
+ }
+ v, err := s.blogView(r)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ v["page"], v["post"] = page, post
+ s.render(w, r, "blog/post.html", v)
+}
+
+func (s *Server) blogNotFound(w http.ResponseWriter, r *http.Request) {
+ v, err := s.blogView(r)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ s.renderStatus(w, r, http.StatusNotFound, "blog/404.html", v)
+}
+
+// ---- RSS ---------------------------------------------------------------------
+
+type rss struct {
+ XMLName xml.Name `xml:"rss"`
+ Version string `xml:"version,attr"`
+ Channel struct {
+ Title string `xml:"title"`
+ Link string `xml:"link"`
+ Description string `xml:"description"`
+ Items []rssItem `xml:"item"`
+ } `xml:"channel"`
+}
+
+type rssItem struct {
+ Title string `xml:"title"`
+ Link string `xml:"link"`
+ GUID string `xml:"guid"`
+ PubDate string `xml:"pubDate"`
+ Desc string `xml:"description"`
+}
+
+func (s *Server) handleBlogFeed(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ posts, err := s.st.RecentPublishedPosts(r.Context(), blog.ID, 30)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ base := s.cfg.BlogURL(blog.Subdomain)
+ var f rss
+ f.Version = "2.0"
+ f.Channel.Title, f.Channel.Link, f.Channel.Description = blog.Title, base, blog.Tagline
+ for _, p := range posts {
+ link := base + "/" + p.PageSlug + "/" + p.Slug
+ f.Channel.Items = append(f.Channel.Items, rssItem{Title: p.Title, Link: link, GUID: link,
+ PubDate: p.CreatedAt.Format(time.RFC1123Z), Desc: p.BodyHTML})
+ }
+ w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
+ w.Write([]byte(xml.Header))
+ if err := xml.NewEncoder(w).Encode(f); err != nil && !errors.Is(err, http.ErrHandlerTimeout) {
+ return
+ }
+}
diff --git a/internal/web/handlers_dashboard.go b/internal/web/handlers_dashboard.go
new file mode 100644
index 0000000..f210891
--- /dev/null
+++ b/internal/web/handlers_dashboard.go
@@ -0,0 +1,43 @@
+package web
+
+import (
+ "net/http"
+ "strings"
+)
+
+func (s *Server) handleBlogOverview(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ pages, err := s.st.ListPages(r.Context(), blog.ID)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ posts, err := s.st.ListPosts(r.Context(), blog.ID, 0)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ if len(posts) > 5 {
+ posts = posts[:5]
+ }
+ s.render(w, r, "dashboard/overview.html", map[string]any{"pages": pages, "posts": posts})
+}
+
+func (s *Server) handleSettingsForm(w http.ResponseWriter, r *http.Request) {
+ s.render(w, r, "dashboard/settings.html", nil)
+}
+
+func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ title := strings.TrimSpace(r.FormValue("title"))
+ tagline := strings.TrimSpace(r.FormValue("tagline"))
+ if title == "" || len(title) > 120 || len(tagline) > 300 {
+ s.renderStatus(w, r, http.StatusBadRequest, "dashboard/settings.html", map[string]any{"error": "Title is required (max 120 chars); tagline max 300."})
+ return
+ }
+ if err := s.st.UpdateBlogSettings(r.Context(), blog.ID, title, tagline); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/settings", "Saved. Refresh your blog to see it.")
+}
diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go
new file mode 100644
index 0000000..601ba34
--- /dev/null
+++ b/internal/web/handlers_design.go
@@ -0,0 +1,155 @@
+package web
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "path/filepath"
+ "strconv"
+
+ "github.com/google/uuid"
+ "github.com/gramanas/blogspace/internal/store"
+)
+
+var allowedImageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true}
+
+func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ images, err := s.st.ListImages(r.Context(), blog.ID)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ s.render(w, r, "dashboard/design.html", map[string]any{"theme": ParseTheme(blog.ThemeJSON), "images": images})
+}
+
+func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
+ s.plainError(w, http.StatusBadRequest, "Upload too large or malformed form.")
+ return
+ }
+ theme := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form)
+ // Optional direct uploads from the design form.
+ for field, dst := range map[string]*string{"bg_image_file": &theme.BgImage, "header_image_file": &theme.HeaderImage} {
+ img, err := s.readUpload(r, field)
+ if err != nil {
+ s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", map[string]any{"theme": theme, "error": err.Error()})
+ return
+ }
+ if img != nil {
+ *dst = img.ID.String()
+ }
+ }
+ if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Design saved. Refresh your blog to see it.")
+}
+
+// readUpload stores the file from a multipart field, returning nil if the field is empty.
+func (s *Server) readUpload(r *http.Request, field string) (*store.Image, error) {
+ if r.MultipartForm == nil {
+ return nil, nil
+ }
+ fhs := r.MultipartForm.File[field]
+ if len(fhs) == 0 {
+ return nil, nil
+ }
+ return s.storeUpload(r, fhs[0])
+}
+
+func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader) (*store.Image, error) {
+ if fh.Size > s.cfg.MaxUploadBytes {
+ return nil, errors.New("image is too large (max " + kbString(s.cfg.MaxUploadBytes) + ")")
+ }
+ f, err := fh.Open()
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ var buf bytes.Buffer
+ if _, err := io.CopyN(&buf, f, s.cfg.MaxUploadBytes+1); err != nil && !errors.Is(err, io.EOF) {
+ return nil, err
+ }
+ if int64(buf.Len()) > s.cfg.MaxUploadBytes {
+ return nil, errors.New("image is too large (max " + kbString(s.cfg.MaxUploadBytes) + ")")
+ }
+ ct := http.DetectContentType(buf.Bytes())
+ if !allowedImageTypes[ct] {
+ return nil, errors.New("only PNG, JPEG, GIF and WebP images are accepted")
+ }
+ name := filepath.Base(fh.Filename)
+ if name == "" || name == "." || len(name) > 120 {
+ name = "image"
+ }
+ return s.st.CreateImage(r.Context(), currentBlog(r).ID, name, ct, buf.Bytes())
+}
+
+func kbString(n int64) string {
+ if n >= 1<<20 {
+ return strconv.FormatInt(n>>20, 10) + " MB"
+ }
+ return strconv.FormatInt(n>>10, 10) + " KB"
+}
+
+// ---- image library ---------------------------------------------------------
+
+func (s *Server) handleImages(w http.ResponseWriter, r *http.Request) {
+ images, err := s.st.ListImages(r.Context(), currentBlog(r).ID)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ s.render(w, r, "dashboard/images.html", map[string]any{"images": images})
+}
+
+func (s *Server) handleImageUpload(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
+ s.plainError(w, http.StatusBadRequest, "Upload too large or malformed form.")
+ return
+ }
+ img, err := s.readUpload(r, "file")
+ if err != nil {
+ s.plainError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ if img == nil {
+ s.plainError(w, http.StatusBadRequest, "Choose a file first.")
+ return
+ }
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/images", "Uploaded "+img.Filename+".")
+}
+
+func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ id, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ if err := s.st.DeleteImage(r.Context(), blog.ID, id); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ // Drop dangling references from the theme.
+ theme := ParseTheme(blog.ThemeJSON)
+ changed := false
+ if theme.BgImage == id.String() {
+ theme.BgImage, changed = "", true
+ }
+ if theme.HeaderImage == id.String() {
+ theme.HeaderImage, changed = "", true
+ }
+ if changed {
+ if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ }
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/images", "Image deleted.")
+}
diff --git a/internal/web/handlers_media.go b/internal/web/handlers_media.go
new file mode 100644
index 0000000..e651135
--- /dev/null
+++ b/internal/web/handlers_media.go
@@ -0,0 +1,38 @@
+package web
+
+import (
+ "bytes"
+ "errors"
+ "net/http"
+
+ "github.com/google/uuid"
+ "github.com/gramanas/blogspace/internal/store"
+)
+
+// handleMedia serves an uploaded image. Ids are immutable, so clients may cache forever.
+func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
+ id, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ etag := `"` + id.String() + `"`
+ if r.Header.Get("If-None-Match") == etag {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+ img, err := s.st.ImageData(r.Context(), id)
+ if err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ s.serverError(w, err)
+ return
+ }
+ w.Header().Set("Content-Type", img.ContentType)
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ w.Header().Set("ETag", etag)
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ http.ServeContent(w, r, img.Filename, img.CreatedAt, bytes.NewReader(img.Data))
+}
diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go
new file mode 100644
index 0000000..1b79280
--- /dev/null
+++ b/internal/web/handlers_pages.go
@@ -0,0 +1,161 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/gramanas/blogspace/internal/markdown"
+ "github.com/gramanas/blogspace/internal/slug"
+ "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}
+
+func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) {
+ pages, err := s.st.ListPages(r.Context(), currentBlog(r).ID)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ s.render(w, r, "dashboard/pages.html", map[string]any{"pages": pages})
+}
+
+// loadPage fetches the page named in the URL, or nil (having written the response) on failure.
+func (s *Server) loadPage(w http.ResponseWriter, r *http.Request) *store.Page {
+ id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ p, err := s.st.PageByID(r.Context(), currentBlog(r).ID, id)
+ if err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ http.NotFound(w, r)
+ } else {
+ s.serverError(w, err)
+ }
+ return nil
+ }
+ return p
+}
+
+func (s *Server) handlePageForm(w http.ResponseWriter, r *http.Request) {
+ p := &store.Page{ShowInNav: true}
+ if r.PathValue("id") != "" {
+ if p = s.loadPage(w, r); p == nil {
+ return
+ }
+ }
+ s.render(w, r, "dashboard/page_form.html", map[string]any{"page": p})
+}
+
+func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ p := &store.Page{BlogID: blog.ID}
+ if r.PathValue("id") != "" {
+ if p = s.loadPage(w, r); p == nil {
+ return
+ }
+ }
+ p.Title = strings.TrimSpace(r.FormValue("title"))
+ p.Slug = strings.TrimSpace(r.FormValue("slug"))
+ p.IntroMD = r.FormValue("intro")
+ p.ShowInNav = r.FormValue("show_in_nav") == "on"
+ autoSlug := p.Slug == ""
+ if autoSlug {
+ p.Slug = slug.Make(p.Title)
+ }
+ var msg string
+ switch {
+ case p.Title == "" || len(p.Title) > 120:
+ msg = "Title is required (max 120 characters)."
+ case !slug.Valid(p.Slug) || reservedPageSlugs[p.Slug]:
+ msg = "Slug may only contain lowercase letters, digits and dashes (and not be a reserved word)."
+ }
+ if msg != "" {
+ s.renderStatus(w, r, http.StatusBadRequest, "dashboard/page_form.html", map[string]any{"page": p, "error": msg})
+ return
+ }
+ p.IntroHTML = markdown.Render(p.IntroMD)
+ var err error
+ base := p.Slug
+ for n := 1; ; n++ { // generated slugs get -2, -3… on collision; typed ones report the conflict
+ p.Slug = slug.WithSuffix(base, n)
+ if p.ID == 0 {
+ var created *store.Page
+ if created, err = s.st.CreatePage(r.Context(), p); err == nil {
+ p = created
+ }
+ } else {
+ err = s.st.UpdatePage(r.Context(), p)
+ }
+ if !errors.Is(err, store.ErrConflict) || !autoSlug || n >= 50 {
+ break
+ }
+ }
+ if err != nil {
+ if errors.Is(err, store.ErrConflict) {
+ s.renderStatus(w, r, http.StatusConflict, "dashboard/page_form.html", map[string]any{"page": p, "error": "A page with that slug already exists."})
+ return
+ }
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/pages/"+strconv.FormatInt(p.ID, 10)+"/edit", "Saved. Refresh your blog to see it.")
+}
+
+func (s *Server) handlePageDeleteConfirm(w http.ResponseWriter, r *http.Request) {
+ p := s.loadPage(w, r)
+ if p == nil {
+ return
+ }
+ s.render(w, r, "dashboard/confirm.html", map[string]any{
+ "what": "the page \"" + p.Title + "\" and all " + strconv.Itoa(p.PostCount) + " of its posts",
+ "action": r.URL.Path,
+ "back": "/b/" + currentBlog(r).Subdomain + "/pages",
+ "isHome": p.IsHome,
+ })
+}
+
+func (s *Server) handlePageDelete(w http.ResponseWriter, r *http.Request) {
+ p := s.loadPage(w, r)
+ if p == nil {
+ return
+ }
+ if p.IsHome {
+ s.plainError(w, http.StatusBadRequest, "The home page cannot be deleted. Make another page the home page first.")
+ return
+ }
+ if err := s.st.DeletePage(r.Context(), p.BlogID, p.ID); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", "Page deleted.")
+}
+
+func (s *Server) handlePageMove(w http.ResponseWriter, r *http.Request) {
+ p := s.loadPage(w, r)
+ if p == nil {
+ return
+ }
+ dir := 1
+ if r.FormValue("dir") == "up" {
+ dir = -1
+ }
+ if err := s.st.MovePage(r.Context(), p.BlogID, p.ID, dir); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ http.Redirect(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", http.StatusSeeOther)
+}
+
+func (s *Server) handlePageHome(w http.ResponseWriter, r *http.Request) {
+ p := s.loadPage(w, r)
+ if p == nil {
+ return
+ }
+ if err := s.st.SetHomePage(r.Context(), p.BlogID, p.ID); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", "\""+p.Title+"\" is now the home page.")
+}
diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go
new file mode 100644
index 0000000..8678985
--- /dev/null
+++ b/internal/web/handlers_posts.go
@@ -0,0 +1,158 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/gramanas/blogspace/internal/markdown"
+ "github.com/gramanas/blogspace/internal/slug"
+ "github.com/gramanas/blogspace/internal/store"
+)
+
+func (s *Server) handlePosts(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ pageID, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64)
+ pages, err := s.st.ListPages(r.Context(), blog.ID)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ posts, err := s.st.ListPosts(r.Context(), blog.ID, pageID)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ s.render(w, r, "dashboard/posts.html", map[string]any{"posts": posts, "pages": pages, "pageID": pageID})
+}
+
+func (s *Server) loadPost(w http.ResponseWriter, r *http.Request) *store.Post {
+ id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ p, err := s.st.PostByID(r.Context(), currentBlog(r).ID, id)
+ if err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ http.NotFound(w, r)
+ } else {
+ s.serverError(w, err)
+ }
+ return nil
+ }
+ return p
+}
+
+func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ pages, err := s.st.ListPages(r.Context(), blog.ID)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ p := &store.Post{Published: true}
+ if r.PathValue("id") != "" {
+ if p = s.loadPost(w, r); p == nil {
+ return
+ }
+ } else if pid, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64); pid != 0 {
+ p.PageID = pid
+ } else if hp, err := s.st.HomePage(r.Context(), blog.ID); err == nil {
+ p.PageID = hp.ID
+ }
+ s.render(w, r, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages})
+}
+
+func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ pages, err := s.st.ListPages(r.Context(), blog.ID)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ p := &store.Post{}
+ if r.PathValue("id") != "" {
+ if p = s.loadPost(w, r); p == nil {
+ return
+ }
+ }
+ p.Title = strings.TrimSpace(r.FormValue("title"))
+ p.Slug = strings.TrimSpace(r.FormValue("slug"))
+ p.BodyMD = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n")
+ p.Published = r.FormValue("published") == "on"
+ p.PageID, _ = strconv.ParseInt(r.FormValue("page_id"), 10, 64)
+ autoSlug := p.Slug == ""
+ if autoSlug {
+ p.Slug = slug.Make(p.Title)
+ }
+ fail := func(status int, msg string) {
+ s.renderStatus(w, r, status, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages, "error": msg})
+ }
+ pageOK := false
+ for _, pg := range pages {
+ if pg.ID == p.PageID {
+ pageOK = true
+ }
+ }
+ switch {
+ case p.Title == "" || len(p.Title) > 200:
+ fail(http.StatusBadRequest, "Title is required (max 200 characters).")
+ return
+ case !slug.Valid(p.Slug):
+ fail(http.StatusBadRequest, "Slug may only contain lowercase letters, digits and dashes.")
+ return
+ case !pageOK:
+ fail(http.StatusBadRequest, "Pick a page for this post.")
+ return
+ case len(p.BodyMD) > 200_000:
+ fail(http.StatusBadRequest, "Post is too long (200 KB max).")
+ return
+ }
+ p.BodyHTML = markdown.Render(p.BodyMD)
+ base := p.Slug
+ for n := 1; ; n++ { // generated slugs get -2, -3… on collision; typed ones report the conflict
+ p.Slug = slug.WithSuffix(base, n)
+ if p.ID == 0 {
+ var created *store.Post
+ if created, err = s.st.CreatePost(r.Context(), p); err == nil {
+ p = created
+ }
+ } else {
+ err = s.st.UpdatePost(r.Context(), p)
+ }
+ if !errors.Is(err, store.ErrConflict) || !autoSlug || n >= 50 {
+ break
+ }
+ }
+ if err != nil {
+ if errors.Is(err, store.ErrConflict) {
+ fail(http.StatusConflict, "A post with that slug already exists on this page; choose another slug.")
+ return
+ }
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/posts/"+strconv.FormatInt(p.ID, 10)+"/edit", "Saved. Refresh your blog to see it.")
+}
+
+func (s *Server) handlePostDeleteConfirm(w http.ResponseWriter, r *http.Request) {
+ p := s.loadPost(w, r)
+ if p == nil {
+ return
+ }
+ s.render(w, r, "dashboard/confirm.html", map[string]any{
+ "what": "the post \"" + p.Title + "\"",
+ "action": r.URL.Path,
+ "back": "/b/" + currentBlog(r).Subdomain + "/posts",
+ })
+}
+
+func (s *Server) handlePostDelete(w http.ResponseWriter, r *http.Request) {
+ p := s.loadPost(w, r)
+ if p == nil {
+ return
+ }
+ if err := s.st.DeletePost(r.Context(), currentBlog(r).ID, p.ID); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/posts", "Post deleted.")
+}
diff --git a/internal/web/routes.go b/internal/web/routes.go
new file mode 100644
index 0000000..c889aa1
--- /dev/null
+++ b/internal/web/routes.go
@@ -0,0 +1,81 @@
+package web
+
+import (
+ "net/http"
+ "net/url"
+)
+
+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)
+ m.HandleFunc("POST /logout", s.handleLogout)
+ m.HandleFunc("GET /dashboard", s.requireAuth(s.handleDashboard))
+ m.HandleFunc("GET /account/password", s.requireAuth(s.handlePasswordForm))
+ m.HandleFunc("POST /account/password", s.requireAuth(s.handlePassword))
+
+ // blog management (owner or superadmin)
+ m.HandleFunc("GET /b/{sub}/{$}", s.withBlog(s.handleBlogOverview))
+ m.HandleFunc("GET /b/{sub}/settings", s.withBlog(s.handleSettingsForm))
+ m.HandleFunc("POST /b/{sub}/settings", s.withBlog(s.handleSettings))
+ m.HandleFunc("GET /b/{sub}/pages", s.withBlog(s.handlePages))
+ m.HandleFunc("GET /b/{sub}/pages/new", s.withBlog(s.handlePageForm))
+ m.HandleFunc("POST /b/{sub}/pages/new", s.withBlog(s.handlePageSave))
+ m.HandleFunc("GET /b/{sub}/pages/{id}/edit", s.withBlog(s.handlePageForm))
+ m.HandleFunc("POST /b/{sub}/pages/{id}/edit", s.withBlog(s.handlePageSave))
+ m.HandleFunc("GET /b/{sub}/pages/{id}/delete", s.withBlog(s.handlePageDeleteConfirm))
+ m.HandleFunc("POST /b/{sub}/pages/{id}/delete", s.withBlog(s.handlePageDelete))
+ m.HandleFunc("POST /b/{sub}/pages/{id}/move", s.withBlog(s.handlePageMove))
+ m.HandleFunc("POST /b/{sub}/pages/{id}/home", s.withBlog(s.handlePageHome))
+ m.HandleFunc("GET /b/{sub}/posts", s.withBlog(s.handlePosts))
+ m.HandleFunc("GET /b/{sub}/posts/new", s.withBlog(s.handlePostForm))
+ m.HandleFunc("POST /b/{sub}/posts/new", s.withBlog(s.handlePostSave))
+ m.HandleFunc("GET /b/{sub}/posts/{id}/edit", s.withBlog(s.handlePostForm))
+ m.HandleFunc("POST /b/{sub}/posts/{id}/edit", s.withBlog(s.handlePostSave))
+ m.HandleFunc("GET /b/{sub}/posts/{id}/delete", s.withBlog(s.handlePostDeleteConfirm))
+ m.HandleFunc("POST /b/{sub}/posts/{id}/delete", s.withBlog(s.handlePostDelete))
+ m.HandleFunc("GET /b/{sub}/design", s.withBlog(s.handleDesignForm))
+ m.HandleFunc("POST /b/{sub}/design", s.withBlog(s.handleDesign))
+ m.HandleFunc("GET /b/{sub}/images", s.withBlog(s.handleImages))
+ m.HandleFunc("POST /b/{sub}/images/upload", s.withBlog(s.handleImageUpload))
+ m.HandleFunc("POST /b/{sub}/images/{id}/delete", s.withBlog(s.handleImageDelete))
+
+ // superadmin
+ m.HandleFunc("GET /admin/{$}", s.requireAdmin(s.handleAdmin))
+ m.HandleFunc("GET /admin/users/new", s.requireAdmin(s.handleAdminNewUserForm))
+ m.HandleFunc("POST /admin/users/new", s.requireAdmin(s.handleAdminNewUser))
+ m.HandleFunc("POST /admin/users/{id}/reset-password", s.requireAdmin(s.handleAdminResetPassword))
+ m.HandleFunc("POST /admin/users/{id}/disable", s.requireAdmin(s.handleAdminSetDisabled(true)))
+ m.HandleFunc("POST /admin/users/{id}/enable", s.requireAdmin(s.handleAdminSetDisabled(false)))
+ m.HandleFunc("GET /admin/users/{id}/delete", s.requireAdmin(s.handleAdminDeleteUserConfirm))
+ m.HandleFunc("POST /admin/users/{id}/delete", s.requireAdmin(s.handleAdminDeleteUser))
+
+ m.HandleFunc("GET /media/{id}", s.handleMedia)
+ m.Handle("GET /static/{file}", s.staticHandler())
+ return s.session(m)
+}
+
+func (s *Server) blogRoutes() 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)
+ return m
+}
+
+func (s *Server) staticHandler() http.Handler {
+ fs := http.StripPrefix("/static/", http.FileServer(http.FS(s.tpl.AssetsFS())))
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !s.cfg.Dev {
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ }
+ fs.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/web/server.go b/internal/web/server.go
new file mode 100644
index 0000000..e8a5e6d
--- /dev/null
+++ b/internal/web/server.go
@@ -0,0 +1,251 @@
+// Package web is the HTTP layer: host routing, handlers and templates.
+package web
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "strings"
+
+ "github.com/gramanas/blogspace/internal/auth"
+ "github.com/gramanas/blogspace/internal/config"
+ "github.com/gramanas/blogspace/internal/store"
+)
+
+// Subdomains that can never be blogs (kept free for infrastructure).
+var reservedSubdomains = map[string]bool{"www": true, "admin": true, "api": true, "mail": true, "static": true, "media": true, "ftp": true, "smtp": true}
+
+type Server struct {
+ cfg *config.Config
+ st *store.Store
+ tpl *templates
+ root http.Handler
+ blog http.Handler
+}
+
+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()
+ 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.
+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 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)))
+ default:
+ s.plainError(w, http.StatusNotFound, fmt.Sprintf("Unknown host %q. Blogs live at <name>.%s", host, s.cfg.BaseDomain))
+ }
+}
+
+func hostname(h string) string {
+ if host, _, err := net.SplitHostPort(h); err == nil {
+ h = host
+ }
+ return strings.ToLower(strings.TrimSuffix(h, "."))
+}
+
+// ---- context keys --------------------------------------------------------
+
+type ctxKey int
+
+const (
+ ctxUser ctxKey = iota
+ ctxBlog
+)
+
+func currentUser(r *http.Request) *store.User {
+ u, _ := r.Context().Value(ctxUser).(*store.User)
+ return u
+}
+
+func currentBlog(r *http.Request) *store.Blog {
+ b, _ := r.Context().Value(ctxBlog).(*store.Blog)
+ return b
+}
+
+// ---- middleware ----------------------------------------------------------
+
+// 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) {
+ c, err := r.Cookie(auth.CookieName)
+ if err != nil || c.Value == "" {
+ next.ServeHTTP(w, r)
+ return
+ }
+ claims, err := auth.ParseToken(s.cfg.JWTSecret, c.Value)
+ if err != nil {
+ auth.ClearSessionCookie(w)
+ next.ServeHTTP(w, r)
+ return
+ }
+ u, err := s.st.UserByID(r.Context(), claims.UserID)
+ if err != nil || u.Disabled || u.TokenVersion != claims.TokenVersion {
+ auth.ClearSessionCookie(w)
+ next.ServeHTTP(w, r)
+ return
+ }
+ next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, u)))
+ })
+}
+
+// requireAuth redirects anonymous users to the login page.
+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)
+ return
+ }
+ if r.Method == http.MethodPost {
+ // Cap the request body before any form parsing (uploads included).
+ r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes+1<<20)
+ if err := parseForm(r); err != nil {
+ var tooBig *http.MaxBytesError
+ if errors.As(err, &tooBig) {
+ s.plainError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Upload too large: the limit is %d MB.", s.cfg.MaxUploadBytes>>20))
+ return
+ }
+ s.plainError(w, http.StatusBadRequest, "Could not read the form.")
+ return
+ }
+ if !auth.CheckCSRF(s.cfg.JWTSecret, u.ID, u.TokenVersion, r.FormValue("_csrf")) {
+ s.plainError(w, http.StatusForbidden, "Form expired or invalid. Go back, reload the page and try again.")
+ return
+ }
+ }
+ next(w, r)
+ }
+}
+
+// parseForm parses urlencoded or multipart bodies, surfacing size errors.
+func parseForm(r *http.Request) error {
+ ct := r.Header.Get("Content-Type")
+ if strings.HasPrefix(ct, "multipart/form-data") {
+ return r.ParseMultipartForm(1 << 20)
+ }
+ return r.ParseForm()
+}
+
+func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
+ return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
+ if !currentUser(r).IsSuperadmin() {
+ s.plainError(w, http.StatusForbidden, "Superadmin only.")
+ return
+ }
+ next(w, r)
+ })
+}
+
+// withBlog resolves /b/{sub}/... and enforces owner-or-superadmin.
+func (s *Server) withBlog(next http.HandlerFunc) http.HandlerFunc {
+ return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
+ u := currentUser(r)
+ blog, err := s.st.BlogBySubdomain(r.Context(), r.PathValue("sub"))
+ if err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ s.serverError(w, err)
+ return
+ }
+ if blog.OwnerID != u.ID && !u.IsSuperadmin() {
+ s.plainError(w, http.StatusForbidden, "This is not your blog.")
+ return
+ }
+ next(w, r.WithContext(context.WithValue(r.Context(), ctxBlog, blog)))
+ })
+}
+
+// ---- rendering helpers ---------------------------------------------------
+
+// view is the common data every template receives; page data goes in Data.
+type view struct {
+ User *store.User
+ CSRF string
+ Flash string
+ Error string
+ Blog *store.Blog
+ BlogURL string
+ RootURL string
+ Path string
+ Data map[string]any
+}
+
+func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
+ s.renderStatus(w, r, http.StatusOK, name, data)
+}
+
+func (s *Server) renderStatus(w http.ResponseWriter, r *http.Request, status int, name string, data map[string]any) {
+ if data == nil {
+ data = map[string]any{}
+ }
+ v := view{User: currentUser(r), Blog: currentBlog(r), RootURL: s.cfg.RootURL(), Path: r.URL.Path, Data: data}
+ if v.User != nil {
+ v.CSRF = auth.CSRFToken(s.cfg.JWTSecret, v.User.ID, v.User.TokenVersion)
+ }
+ if v.Blog != nil {
+ v.BlogURL = s.cfg.BlogURL(v.Blog.Subdomain)
+ }
+ v.Flash = r.URL.Query().Get("ok")
+ if e, ok := data["error"].(string); ok {
+ v.Error = e
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if err := s.tpl.render(w, name, v); err != nil {
+ log.Printf("render %s: %v", name, err)
+ fmt.Fprintf(w, "<pre>template error: %v</pre>", err)
+ }
+}
+
+func (s *Server) serverError(w http.ResponseWriter, err error) {
+ log.Printf("error: %v", err)
+ s.plainError(w, http.StatusInternalServerError, "Something went wrong.")
+}
+
+func (s *Server) plainError(w http.ResponseWriter, status int, msg string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ fmt.Fprintf(w, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>%d</title></head><body style="font-family:sans-serif;max-width:40em;margin:4em auto;padding:0 1em"><h1>%d %s</h1><p>%s</p><p><a href="/">Home</a></p></body></html>`,
+ status, status, http.StatusText(status), htmlEscape(msg))
+}
+
+func htmlEscape(s string) string {
+ r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
+ return r.Replace(s)
+}
+
+// redirectOK redirects with a flash message shown by the layout.
+func redirectOK(w http.ResponseWriter, r *http.Request, to, msg string) {
+ sep := "?"
+ if strings.Contains(to, "?") {
+ sep = "&"
+ }
+ http.Redirect(w, r, to+sep+"ok="+urlQuery(msg), http.StatusSeeOther)
+}
diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css
new file mode 100644
index 0000000..b09689b
--- /dev/null
+++ b/internal/web/static/blog.css
@@ -0,0 +1,50 @@
+/* Public blog base styles — colours, fonts and widths come from the inline theme <style>. */
+* { box-sizing: border-box; }
+img { max-width: 100%; height: auto; }
+.wrap { padding: 0 1em; }
+.site-header { padding: 0; }
+.header-image img { display: block; width: 100%; max-height: 320px; object-fit: cover; }
+.header-text { padding: 1.4em 1em; }
+.site-title { margin: 0; font-size: 2em; line-height: 1.2; }
+.site-title a { text-decoration: none; }
+.tagline { margin: 0.3em 0 0; opacity: 0.85; }
+.site-nav { border-bottom: 1px solid rgba(0,0,0,0.1); }
+.nav-inner { padding: 0.3em 1em; }
+.site-nav a { display: inline-block; padding: 0.5em 0.8em; text-decoration: none; font-weight: bold; }
+.site-nav a.active, .site-nav a:hover { text-decoration: underline; }
+.body-wrap { padding: 1.5em 1em; overflow: hidden; }
+.content { padding: 1.5em 2em; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.15); }
+.page-title { margin-top: 0; }
+.intro { padding-bottom: 1em; margin-bottom: 1.5em; border-bottom: 1px solid rgba(0,0,0,0.1); }
+.post { margin-bottom: 2.5em; }
+.post-title { margin: 0 0 0.1em; font-size: 1.5em; line-height: 1.25; }
+.post-title a { text-decoration: none; }
+.post-title a:hover { text-decoration: underline; }
+.post-date { margin: 0 0 0.8em; font-size: 0.85em; opacity: 0.7; }
+.post.single .post-title { font-size: 2em; }
+.post-body blockquote { margin: 1em 0; padding: 0.2em 1em; border-left: 4px solid rgba(0,0,0,0.15); opacity: 0.9; }
+.post-body pre { overflow-x: auto; padding: 0.8em 1em; background: rgba(0,0,0,0.06); border-radius: 4px; }
+.post-body code { background: rgba(0,0,0,0.06); padding: 0 0.25em; border-radius: 3px; font-size: 0.92em; }
+.post-body pre code { background: none; padding: 0; }
+.post-body table { border-collapse: collapse; }
+.post-body th, .post-body td { border: 1px solid rgba(0,0,0,0.15); padding: 0.3em 0.6em; }
+.post-body hr { border: 0; border-top: 1px solid rgba(0,0,0,0.15); }
+.pager { margin-top: 2em; padding-top: 1em; border-top: 1px solid rgba(0,0,0,0.1); text-align: center; }
+.pager a, .pager span { margin: 0 0.8em; }
+.muted { opacity: 0.7; }
+.site-footer { margin-top: 1em; padding: 1.5em 0; text-align: center; }
+.site-footer p { margin: 0.3em 0; }
+.site-footer .small { font-size: 0.85em; opacity: 0.8; }
+
+/* left sidebar layout: float the menu beside the content */
+.nav-left-sidebar .body-wrap .site-nav { float: left; width: 180px; border: 0; border-radius: 6px; padding: 0.5em 0; }
+.nav-left-sidebar .body-wrap .site-nav a { display: block; padding: 0.4em 1em; }
+.nav-left-sidebar .body-wrap .site-nav .nav-inner { padding: 0; }
+.nav-left-sidebar .body-wrap .content { margin-left: 200px; }
+@media (max-width: 700px) {
+ .content { padding: 1em; }
+ .site-title { font-size: 1.5em; }
+ .nav-left-sidebar .body-wrap .site-nav { float: none; width: auto; margin-bottom: 1em; }
+ .nav-left-sidebar .body-wrap .site-nav a { display: inline-block; }
+ .nav-left-sidebar .body-wrap .content { margin-left: 0; }
+}
diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css
new file mode 100644
index 0000000..48f9eff
--- /dev/null
+++ b/internal/web/static/dashboard.css
@@ -0,0 +1,87 @@
+/* Blogspace dashboard — plain CSS, no JS required, works down to ~320px. */
+* { box-sizing: border-box; }
+body { margin: 0; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.5; color: #222; background: #f3f4f6; }
+a { color: #1a5fb4; }
+h1 { font-size: 1.6em; margin: 0.4em 0 0.6em; }
+h2 { font-size: 1.2em; margin: 0 0 0.6em; }
+h3 { font-size: 1.05em; margin: 0 0 0.4em; }
+code { background: #eef; padding: 0 0.25em; border-radius: 3px; font-size: 0.95em; }
+.muted { color: #666; }
+.nowrap { white-space: nowrap; }
+.narrow { max-width: 480px; }
+.small { font-size: 0.9em; }
+
+.topbar { background: #1f2933; color: #fff; }
+.topbar a { color: #fff; text-decoration: none; }
+.topbar-inner { max-width: 1100px; margin: 0 auto; padding: 0.6em 1em; overflow: hidden; }
+.brand { font-weight: bold; font-size: 1.1em; margin-right: 1em; }
+.who { color: #b8c2cc; }
+.topbar .links { float: right; }
+.topbar .links a, .topbar .links form { margin-left: 1em; }
+.blogbar { background: #fff; border-bottom: 1px solid #d9dde3; }
+.blogbar .topbar-inner { padding: 0.5em 1em; }
+.blogbar a { display: inline-block; padding: 0.3em 0.6em; text-decoration: none; color: #333; border-radius: 4px; }
+.blogbar a:hover { background: #eef1f5; }
+.blogbar .blogname { margin-right: 0.6em; }
+.blogbar .view { float: right; color: #1a5fb4; font-weight: bold; }
+
+.main { max-width: 1100px; margin: 0 auto; padding: 1em; }
+.card { background: #fff; border: 1px solid #d9dde3; border-radius: 6px; padding: 1em 1.2em; margin-bottom: 1em; }
+.cols { overflow: hidden; }
+.cols .card { float: left; width: 49%; margin-right: 2%; }
+.cols .card + .card { margin-right: 0; }
+.flash { padding: 0.7em 1em; border-radius: 6px; margin-bottom: 1em; }
+.flash.ok { background: #e3f6e8; border: 1px solid #9ad3a8; color: #1c5a2a; }
+.flash.err { background: #fdecec; border: 1px solid #f0a5a5; color: #7a1c1c; }
+.tag { display: inline-block; font-size: 0.75em; background: #eef1f5; color: #555; padding: 0 0.5em; border-radius: 3px; margin-left: 0.4em; vertical-align: middle; }
+
+label { display: block; margin: 0.6em 0; }
+input[type=text], input[type=password], input:not([type]), input[type=file], select, textarea {
+ width: 100%; max-width: 100%; padding: 0.45em 0.5em; border: 1px solid #b9c0c9; border-radius: 4px; font: inherit; background: #fff; }
+input[type=color] { width: 4em; height: 2.2em; padding: 0.1em; border: 1px solid #b9c0c9; border-radius: 4px; background: #fff; }
+textarea { font-family: "Courier New", Courier, monospace; font-size: 0.95em; line-height: 1.45; }
+textarea.editor { min-height: 20em; }
+label.check { display: block; }
+label.check input { width: auto; margin-right: 0.4em; }
+.row { overflow: hidden; }
+.row label { float: left; margin-right: 1.5em; min-width: 12em; }
+.row label:last-child { margin-right: 0; }
+button, .btn { display: inline-block; font: inherit; padding: 0.45em 1em; border-radius: 4px; border: 1px solid #164b8f; background: #1a5fb4; color: #fff; cursor: pointer; text-decoration: none; }
+button:hover, .btn:hover { background: #164b8f; }
+.btn.secondary, button.secondary { background: #fff; color: #1a5fb4; }
+.btn.small { font-size: 0.75em; vertical-align: middle; }
+button.mini, .mini { font-size: 0.8em; padding: 0.15em 0.5em; background: #fff; color: #333; border-color: #b9c0c9; }
+button.mini:hover { background: #eef1f5; }
+button.danger { background: #b42318; border-color: #8a1a12; }
+a.danger, button.mini.danger { color: #b42318; }
+button.linkbtn { background: none; border: 0; padding: 0; color: inherit; font: inherit; cursor: pointer; text-decoration: none; }
+form.inline, .inline { display: inline; }
+details.inline summary { display: inline; cursor: pointer; }
+.filter { margin: 0 0 1em; }
+.filter select { width: auto; }
+
+table { border-collapse: collapse; width: 100%; }
+th, td { text-align: left; padding: 0.45em 0.5em; border-bottom: 1px solid #e5e8ec; vertical-align: middle; }
+th { font-size: 0.85em; text-transform: uppercase; color: #666; letter-spacing: 0.03em; }
+tr.disabled td { color: #999; }
+ul.plain { list-style: none; padding: 0; margin: 0; }
+ul.plain li { padding: 0.2em 0; }
+.help { background: #fbfbf4; }
+.cheat td { border: 0; padding: 0.2em 1em 0.2em 0; }
+
+.gallery { overflow: hidden; }
+.thumb { float: left; width: 200px; margin: 0 1em 1em 0; padding: 0.6em; text-align: center; }
+.thumb img { max-width: 100%; max-height: 140px; }
+.thumb .meta { font-size: 0.85em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.thumb .copy { font-size: 0.8em; margin: 0.4em 0; font-family: "Courier New", monospace; }
+.imagepick { margin-top: 0.5em; padding-top: 0.5em; border-top: 1px dashed #d9dde3; }
+.imagepick img.preview { display: block; max-width: 240px; max-height: 120px; margin-top: 0.4em; border: 1px solid #d9dde3; }
+.imagepick select { width: auto; max-width: 100%; }
+
+@media (max-width: 700px) {
+ .cols .card, .row label, .thumb { float: none; width: auto; margin-right: 0; }
+ .topbar .links, .blogbar .view { float: none; display: block; margin-top: 0.3em; }
+ .topbar .links a, .topbar .links form { margin-left: 0; margin-right: 1em; }
+ table { font-size: 0.9em; }
+ th, td { padding: 0.35em 0.3em; }
+}
diff --git a/internal/web/templates.go b/internal/web/templates.go
new file mode 100644
index 0000000..f322ccc
--- /dev/null
+++ b/internal/web/templates.go
@@ -0,0 +1,117 @@
+package web
+
+import (
+ "embed"
+ "fmt"
+ "html/template"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+)
+
+//go:embed templates static
+var assets embed.FS
+
+// templates compiles each page together with its layout. In dev mode it
+// re-reads from disk on every render so edits show up without a restart.
+type templates struct {
+ dev bool
+ fs fs.FS
+ funcs template.FuncMap
+ mu sync.Mutex
+ cache map[string]*template.Template
+}
+
+func newTemplates(dev bool, funcs template.FuncMap) *templates {
+ t := &templates{dev: dev, funcs: funcs, cache: map[string]*template.Template{}}
+ t.fs = assets
+ if dev {
+ // locate the package directory so `go run` from anywhere still finds the files
+ _, file, _, _ := runtime.Caller(0)
+ dir := filepath.Dir(file)
+ if _, err := os.Stat(filepath.Join(dir, "templates")); err == nil {
+ t.fs = os.DirFS(dir)
+ }
+ }
+ return t
+}
+
+// AssetsFS returns the static files (embedded or on disk in dev mode).
+func (t *templates) AssetsFS() fs.FS {
+ sub, _ := fs.Sub(t.fs, "static")
+ return sub
+}
+
+func layoutFor(name string) string {
+ if strings.HasPrefix(name, "blog/") {
+ return "layouts/blog.html"
+ }
+ return "layouts/dashboard.html"
+}
+
+func (t *templates) get(name string) (*template.Template, error) {
+ if !t.dev {
+ t.mu.Lock()
+ if tpl, ok := t.cache[name]; ok {
+ t.mu.Unlock()
+ return tpl, nil
+ }
+ t.mu.Unlock()
+ }
+ tpl, err := template.New("").Funcs(t.funcs).ParseFS(t.fs,
+ "templates/"+layoutFor(name), "templates/partials/*.html", "templates/"+name)
+ if err != nil {
+ return nil, fmt.Errorf("parse %s: %w", name, err)
+ }
+ if !t.dev {
+ t.mu.Lock()
+ t.cache[name] = tpl
+ t.mu.Unlock()
+ }
+ return tpl, nil
+}
+
+func (t *templates) render(w io.Writer, name string, data any) error {
+ tpl, err := t.get(name)
+ if err != nil {
+ return err
+ }
+ return tpl.ExecuteTemplate(w, "layout", data)
+}
+
+var funcs = template.FuncMap{
+ "date": func(t time.Time) string { return t.Format("2 January 2006") },
+ "rfc": func(t time.Time) string { return t.Format(time.RFC1123Z) },
+ "html": func(s string) template.HTML { return template.HTML(s) },
+ "css": func(s string) template.CSS { return template.CSS(s) },
+ "kb": func(n int) string { return fmt.Sprintf("%.0f KB", float64(n)/1024) },
+ "add": func(a, b int) int { return a + b },
+ "sub": func(a, b int) int { return a - b },
+ "deref": func(p *string) string {
+ if p == nil {
+ return ""
+ }
+ return *p
+ },
+ "lower": strings.ToLower,
+ // dict builds a map for passing several values to a partial: {{template "x" (dict "a" 1 "b" 2)}}
+ "dict": func(kv ...any) (map[string]any, error) {
+ if len(kv)%2 != 0 {
+ return nil, fmt.Errorf("dict: odd number of arguments")
+ }
+ m := make(map[string]any, len(kv)/2)
+ for i := 0; i < len(kv); i += 2 {
+ k, ok := kv[i].(string)
+ if !ok {
+ return nil, fmt.Errorf("dict: key %v is not a string", kv[i])
+ }
+ m[k] = kv[i+1]
+ }
+ return m, nil
+ },
+}
diff --git a/internal/web/templates/admin/delete_user.html b/internal/web/templates/admin/delete_user.html
new file mode 100644
index 0000000..34d3fb9
--- /dev/null
+++ b/internal/web/templates/admin/delete_user.html
@@ -0,0 +1,12 @@
+{{define "title"}}Delete user · Blogspace{{end}}
+{{define "content"}}
+<div class="card narrow">
+ <h1>Delete {{.Data.target.Username}}?</h1>
+ <p>This permanently deletes the user <strong>{{.Data.target.Username}}</strong>, their blog, and every page, post and image in it.</p>
+ <form method="post" action="/admin/users/{{.Data.target.ID}}/delete">
+ <input type="hidden" name="_csrf" value="{{.CSRF}}">
+ <button type="submit" class="danger">Yes, delete everything</button>
+ <a class="btn secondary" href="/admin/">Cancel</a>
+ </form>
+</div>
+{{end}}
diff --git a/internal/web/templates/admin/index.html b/internal/web/templates/admin/index.html
new file mode 100644
index 0000000..c74d426
--- /dev/null
+++ b/internal/web/templates/admin/index.html
@@ -0,0 +1,28 @@
+{{define "title"}}Admin · Blogspace{{end}}
+{{define "content"}}
+<h1>Users &amp; blogs <a class="btn small" href="/admin/users/new">+ New blogger</a></h1>
+<div class="card">
+<table>
+ <tr><th>User</th><th>Role</th><th>Blog</th><th>Since</th><th>Actions</th></tr>
+ {{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 class="nowrap">{{date .CreatedAt}}</td>
+ <td class="nowrap">
+ {{if ne .ID $.User.ID}}
+ <details class="inline"><summary class="mini">reset password</summary>
+ <form method="post" action="/admin/users/{{.ID}}/reset-password" class="inline">
+ <input type="hidden" name="_csrf" value="{{$.CSRF}}">
+ <input type="password" name="password" placeholder="new password" minlength="8" required size="14">
+ <button class="mini">Set</button>
+ </form></details>
+ {{if .Disabled}}<form method="post" action="/admin/users/{{.ID}}/enable" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">enable</button></form>
+ {{else}}<form method="post" action="/admin/users/{{.ID}}/disable" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">disable</button></form>{{end}}
+ <a class="danger" href="/admin/users/{{.ID}}/delete">delete</a>
+ {{else}}<span class="muted">(you)</span>{{end}}
+ </td>
+ </tr>{{end}}
+</table>
+</div>
+{{end}}
diff --git a/internal/web/templates/admin/new_user.html b/internal/web/templates/admin/new_user.html
new file mode 100644
index 0000000..87d1186
--- /dev/null
+++ b/internal/web/templates/admin/new_user.html
@@ -0,0 +1,15 @@
+{{define "title"}}New blogger · Blogspace{{end}}
+{{define "content"}}
+<div class="card narrow">
+ <h1>New blogger</h1>
+ <form method="post" action="/admin/users/new">
+ <input type="hidden" name="_csrf" value="{{.CSRF}}">
+ <label>Username<br><input name="username" value="{{.Data.username}}" required pattern="[a-zA-Z0-9_.-]{2,40}" autofocus></label>
+ <label>Password <span class="muted">(min 8 characters; they can change it later)</span><br><input type="password" name="password" required minlength="8"></label>
+ <label>Subdomain <span class="muted">(defaults to the username)</span><br>
+ <span class="nowrap"><input name="subdomain" value="{{.Data.subdomain}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="63" size="14">.{{.Data.cfg.BaseDomain}}</span></label>
+ <label>Blog title<br><input name="title" value="{{.Data.title}}" maxlength="120"></label>
+ <button type="submit">Create</button> <a href="/admin/">Cancel</a>
+ </form>
+</div>
+{{end}}
diff --git a/internal/web/templates/auth/login.html b/internal/web/templates/auth/login.html
new file mode 100644
index 0000000..2229849
--- /dev/null
+++ b/internal/web/templates/auth/login.html
@@ -0,0 +1,12 @@
+{{define "title"}}Log in · Blogspace{{end}}
+{{define "content"}}
+<div class="card narrow">
+ <h1>Log in</h1>
+ <form method="post" action="/login">
+ <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>
+ <button type="submit">Log in</button>
+ </form>
+</div>
+{{end}}
diff --git a/internal/web/templates/blog/404.html b/internal/web/templates/blog/404.html
new file mode 100644
index 0000000..b0aa6a9
--- /dev/null
+++ b/internal/web/templates/blog/404.html
@@ -0,0 +1,5 @@
+{{define "title"}}Not found · {{.Blog.Title}}{{end}}
+{{define "content"}}
+<h1>Not found</h1>
+<p>There is nothing at this address. <a href="/">Back to the front page.</a></p>
+{{end}}
diff --git a/internal/web/templates/blog/page.html b/internal/web/templates/blog/page.html
new file mode 100644
index 0000000..c1fe726
--- /dev/null
+++ b/internal/web/templates/blog/page.html
@@ -0,0 +1,19 @@
+{{define "title"}}{{if not .Data.page.IsHome}}{{.Data.page.Title}} · {{end}}{{.Blog.Title}}{{end}}
+{{define "content"}}
+{{if not .Data.page.IsHome}}<h1 class="page-title">{{.Data.page.Title}}</h1>{{end}}
+{{if .Data.page.IntroHTML}}<div class="intro">{{html .Data.page.IntroHTML}}</div>{{end}}
+{{range .Data.posts}}
+<article class="post">
+ <h2 class="post-title"><a href="/{{.PageSlug}}/{{.Slug}}">{{.Title}}</a></h2>
+ <p class="post-date">{{date .CreatedAt}}</p>
+ <div class="post-body">{{html .BodyHTML}}</div>
+</article>
+{{else}}{{if not .Data.page.IntroHTML}}<p class="muted">Nothing here yet.</p>{{end}}{{end}}
+{{if gt .Data.lastPage 1}}
+<div class="pager">
+ {{if gt .Data.pageNum 1}}<a href="{{.Data.base}}/?p={{sub .Data.pageNum 1}}">&larr; Newer</a>{{end}}
+ <span>Page {{.Data.pageNum}} of {{.Data.lastPage}}</span>
+ {{if lt .Data.pageNum .Data.lastPage}}<a href="{{.Data.base}}/?p={{add .Data.pageNum 1}}">Older &rarr;</a>{{end}}
+</div>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/blog/post.html b/internal/web/templates/blog/post.html
new file mode 100644
index 0000000..83ce782
--- /dev/null
+++ b/internal/web/templates/blog/post.html
@@ -0,0 +1,8 @@
+{{define "title"}}{{.Data.post.Title}} · {{.Blog.Title}}{{end}}
+{{define "content"}}
+<article class="post single">
+ <h1 class="post-title">{{.Data.post.Title}}</h1>
+ <p class="post-date">{{date .Data.post.CreatedAt}} &middot; <a href="{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}">{{.Data.page.Title}}</a></p>
+ <div class="post-body">{{html .Data.post.BodyHTML}}</div>
+</article>
+{{end}}
diff --git a/internal/web/templates/dashboard/confirm.html b/internal/web/templates/dashboard/confirm.html
new file mode 100644
index 0000000..eefa1da
--- /dev/null
+++ b/internal/web/templates/dashboard/confirm.html
@@ -0,0 +1,17 @@
+{{define "title"}}Confirm · Blogspace{{end}}
+{{define "content"}}
+<div class="card narrow">
+ <h1>Are you sure?</h1>
+ {{if .Data.isHome}}
+ <p>This is the home page; it cannot be deleted. Make another page the home page first.</p>
+ <p><a class="btn secondary" href="{{.Data.back}}">Back</a></p>
+ {{else}}
+ <p>You are about to permanently delete {{.Data.what}}. This cannot be undone.</p>
+ <form method="post" action="{{.Data.action}}">
+ <input type="hidden" name="_csrf" value="{{.CSRF}}">
+ <button type="submit" class="danger">Yes, delete</button>
+ <a class="btn secondary" href="{{.Data.back}}">Cancel</a>
+ </form>
+ {{end}}
+</div>
+{{end}}
diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html
new file mode 100644
index 0000000..27ce3c7
--- /dev/null
+++ b/internal/web/templates/dashboard/design.html
@@ -0,0 +1,87 @@
+{{define "title"}}Design · {{.Blog.Title}}{{end}}
+{{define "content"}}
+{{$t := .Data.theme}}
+<h1>Design</h1>
+<p class="muted">Save, then refresh <a href="{{.BlogURL}}" target="_blank">your blog &#8599;</a> to see the result. Colours are hex values like <code>#336699</code>.</p>
+<form method="post" action="/b/{{.Blog.Subdomain}}/design" enctype="multipart/form-data">
+<input type="hidden" name="_csrf" value="{{.CSRF}}">
+
+<div class="card">
+ <h2>Background</h2>
+ <div class="row">
+ <label>Colour<br><input type="color" name="bg_color" value="{{$t.BgColor}}"></label>
+ <label>Image style<br><select name="bg_mode">
+ <option value="cover"{{if eq $t.BgMode "cover"}} selected{{end}}>Stretch to fill</option>
+ <option value="fixed"{{if eq $t.BgMode "fixed"}} selected{{end}}>Fill, fixed while scrolling</option>
+ <option value="tile"{{if eq $t.BgMode "tile"}} selected{{end}}>Repeat (tile)</option>
+ </select></label>
+ </div>
+ {{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "images" .Data.images "label" "Background image")}}
+</div>
+
+<div class="card">
+ <h2>Text &amp; content area</h2>
+ <div class="row">
+ <label>Content background<br><input type="color" name="content_bg" value="{{$t.ContentBg}}"></label>
+ <label>Text colour<br><input type="color" name="text_color" value="{{$t.TextColor}}"></label>
+ <label>Link colour<br><input type="color" name="link_color" value="{{$t.LinkColor}}"></label>
+ </div>
+ <div class="row">
+ <label>Font<br><select name="font">
+ <option value="sans"{{if eq $t.Font "sans"}} selected{{end}}>Sans-serif (Helvetica/Arial)</option>
+ <option value="serif"{{if eq $t.Font "serif"}} selected{{end}}>Serif (Georgia/Times)</option>
+ <option value="mono"{{if eq $t.Font "mono"}} selected{{end}}>Monospace (Courier)</option>
+ </select></label>
+ <label>Text size<br><select name="font_size">
+ <option value="small"{{if eq $t.FontSize "small"}} selected{{end}}>Small</option>
+ <option value="normal"{{if eq $t.FontSize "normal"}} selected{{end}}>Normal</option>
+ <option value="large"{{if eq $t.FontSize "large"}} selected{{end}}>Large</option>
+ </select></label>
+ <label>Width<br><select name="content_width">
+ <option value="narrow"{{if eq $t.ContentWidth "narrow"}} selected{{end}}>Narrow</option>
+ <option value="medium"{{if eq $t.ContentWidth "medium"}} selected{{end}}>Medium</option>
+ <option value="wide"{{if eq $t.ContentWidth "wide"}} selected{{end}}>Wide</option>
+ </select></label>
+ </div>
+</div>
+
+<div class="card">
+ <h2>Header</h2>
+ <div class="row">
+ <label>Background<br><input type="color" name="header_bg" value="{{$t.HeaderBg}}"></label>
+ <label>Text colour<br><input type="color" name="header_text" value="{{$t.HeaderText}}"></label>
+ <label>Alignment<br><select name="header_align">
+ <option value="left"{{if eq $t.HeaderAlign "left"}} selected{{end}}>Left</option>
+ <option value="center"{{if eq $t.HeaderAlign "center"}} selected{{end}}>Centred</option>
+ </select></label>
+ </div>
+ <label class="check"><input type="checkbox" name="header_show_title"{{if $t.HeaderShowTitle}} checked{{end}}> Show blog title and tagline in the header</label>
+ {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "images" .Data.images "label" "Header image (banner)")}}
+</div>
+
+<div class="card">
+ <h2>Menu</h2>
+ <div class="row">
+ <label>Position<br><select name="nav_position">
+ <option value="below-header"{{if eq $t.NavPosition "below-header"}} selected{{end}}>Below the header</option>
+ <option value="top-bar"{{if eq $t.NavPosition "top-bar"}} selected{{end}}>Bar at the very top</option>
+ <option value="left-sidebar"{{if eq $t.NavPosition "left-sidebar"}} selected{{end}}>Sidebar on the left</option>
+ </select></label>
+ <label>Background<br><input type="color" name="nav_bg" value="{{$t.NavBg}}"></label>
+ <label>Text colour<br><input type="color" name="nav_text" value="{{$t.NavText}}"></label>
+ </div>
+ <label class="check"><input type="checkbox" name="nav_show_home"{{if $t.NavShowHome}} checked{{end}}> Include the home page in the menu</label>
+</div>
+
+<div class="card">
+ <h2>Footer</h2>
+ <label>Footer text<br><input name="footer_text" value="{{$t.FooterText}}" maxlength="2000" placeholder="© 2026 Me"></label>
+ <div class="row">
+ <label>Background<br><input type="color" name="footer_bg" value="{{$t.FooterBg}}"></label>
+ <label>Text colour<br><input type="color" name="footer_color" value="{{$t.FooterColor}}"></label>
+ </div>
+</div>
+
+<p><button type="submit">Save design</button></p>
+</form>
+{{end}}
diff --git a/internal/web/templates/dashboard/images.html b/internal/web/templates/dashboard/images.html
new file mode 100644
index 0000000..5383e73
--- /dev/null
+++ b/internal/web/templates/dashboard/images.html
@@ -0,0 +1,22 @@
+{{define "title"}}Images · {{.Blog.Title}}{{end}}
+{{define "content"}}
+<h1>Images</h1>
+<div class="card">
+ <form method="post" action="/b/{{.Blog.Subdomain}}/images/upload" enctype="multipart/form-data">
+ <input type="hidden" name="_csrf" value="{{.CSRF}}">
+ <label>Upload an image <span class="muted">(PNG, JPEG, GIF or WebP)</span><br><input type="file" name="file" accept="image/*" required></label>
+ <button type="submit">Upload</button>
+ </form>
+</div>
+<p class="muted">To put an image in a post, copy its Markdown line into the post text.</p>
+<div class="gallery">
+{{range .Data.images}}
+ <div class="card thumb">
+ <a href="/media/{{.ID}}" target="_blank"><img src="/media/{{.ID}}" alt="{{.Filename}}"></a>
+ <div class="meta">{{.Filename}} <span class="muted">({{kb .Size}})</span></div>
+ <input class="copy" readonly value="![{{.Filename}}](/media/{{.ID}})" onclick="this.select()">
+ <form method="post" action="/b/{{$.Blog.Subdomain}}/images/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">delete</button></form>
+ </div>
+{{else}}<p class="muted">No images yet.</p>{{end}}
+</div>
+{{end}}
diff --git a/internal/web/templates/dashboard/overview.html b/internal/web/templates/dashboard/overview.html
new file mode 100644
index 0000000..0ec72e6
--- /dev/null
+++ b/internal/web/templates/dashboard/overview.html
@@ -0,0 +1,35 @@
+{{define "title"}}{{.Blog.Title}} · Blogspace{{end}}
+{{define "content"}}
+<h1>{{.Blog.Title}}</h1>
+<p class="muted">Your blog is live at <a href="{{.BlogURL}}" target="_blank">{{.BlogURL}}</a>. Every change you save here shows up there as soon as you refresh.</p>
+
+<div class="cols">
+ <div class="card">
+ <h2>Quick actions</h2>
+ <p><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new">Write a new post</a></p>
+ <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> &middot; <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p>
+ </div>
+ <div class="card">
+ <h2>Pages</h2>
+ <ul class="plain">
+ {{range .Data.pages}}<li><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.Title}}</a> <span class="muted">({{.PostCount}} posts{{if .IsHome}}, home{{end}}{{if not .ShowInNav}}, hidden from menu{{end}})</span></li>{{end}}
+ </ul>
+ </div>
+</div>
+
+<div class="card">
+ <h2>Latest posts</h2>
+ {{if .Data.posts}}
+ <table>
+ <tr><th>Title</th><th>Page</th><th>Date</th><th></th></tr>
+ {{range .Data.posts}}<tr>
+ <td><a href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/edit">{{.Title}}</a>{{if not .Published}} <span class="tag">hidden</span>{{end}}</td>
+ <td>{{.PageTitle}}</td>
+ <td>{{date .CreatedAt}}</td>
+ <td><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">view &#8599;</a></td>
+ </tr>{{end}}
+ </table>
+ <p><a href="/b/{{.Blog.Subdomain}}/posts">All posts</a></p>
+ {{else}}<p class="muted">No posts yet. <a href="/b/{{.Blog.Subdomain}}/posts/new">Write the first one.</a></p>{{end}}
+</div>
+{{end}}
diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html
new file mode 100644
index 0000000..df94c7e
--- /dev/null
+++ b/internal/web/templates/dashboard/page_form.html
@@ -0,0 +1,21 @@
+{{define "title"}}{{if .Data.page.ID}}Edit page{{else}}New page{{end}} · {{.Blog.Title}}{{end}}
+{{define "content"}}
+<div class="card">
+ <h1>{{if .Data.page.ID}}Edit page{{else}}New page{{end}}</h1>
+ <form method="post">
+ <input type="hidden" name="_csrf" value="{{.CSRF}}">
+ <label>Title<br><input name="title" value="{{.Data.page.Title}}" required maxlength="120"></label>
+ <label>Address <span class="muted">(leave empty to make one from the title; e.g. <code>about</code> → {{.BlogURL}}/about)</span><br>
+ <input name="slug" value="{{.Data.page.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label>
+ <label>Intro text <span class="muted">(Markdown, shown above the posts; optional)</span><br>
+ <textarea name="intro" rows="8">{{.Data.page.IntroMD}}</textarea></label>
+ <label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> Show in menu</label>
+ <p>
+ <button type="submit">Save</button>
+ {{if .Data.page.ID}}<a class="btn secondary" href="{{.BlogURL}}{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}" target="_blank">View page &#8599;</a>{{end}}
+ <a href="/b/{{.Blog.Subdomain}}/pages">Back to pages</a>
+ </p>
+ </form>
+</div>
+{{template "mdhelp"}}
+{{end}}
diff --git a/internal/web/templates/dashboard/pages.html b/internal/web/templates/dashboard/pages.html
new file mode 100644
index 0000000..6703e7f
--- /dev/null
+++ b/internal/web/templates/dashboard/pages.html
@@ -0,0 +1,24 @@
+{{define "title"}}Pages · {{.Blog.Title}}{{end}}
+{{define "content"}}
+<h1>Pages <a class="btn small" href="/b/{{.Blog.Subdomain}}/pages/new">+ New page</a></h1>
+<p class="muted">Pages appear in your blog's menu in this order. Each page holds its own list of posts.</p>
+<div class="card">
+<table>
+ <tr><th>Menu order</th><th>Title</th><th>Address</th><th>Posts</th><th></th></tr>
+ {{range .Data.pages}}<tr>
+ <td class="nowrap">
+ <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">&#9650;</button></form>
+ <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">&#9660;</button></form>
+ </td>
+ <td><a href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/edit">{{.Title}}</a>
+ {{if .IsHome}}<span class="tag">home</span>{{end}}{{if not .ShowInNav}}<span class="tag">hidden</span>{{end}}</td>
+ <td><a href="{{$.BlogURL}}{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}" target="_blank">{{if .IsHome}}/{{else}}/{{.Slug}}{{end}} &#8599;</a></td>
+ <td><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.PostCount}}</a></td>
+ <td class="nowrap">
+ {{if not .IsHome}}<form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/home" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">Make home</button></form>
+ <a class="danger" href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/delete">delete</a>{{end}}
+ </td>
+ </tr>{{end}}
+</table>
+</div>
+{{end}}
diff --git a/internal/web/templates/dashboard/password.html b/internal/web/templates/dashboard/password.html
new file mode 100644
index 0000000..f333bd7
--- /dev/null
+++ b/internal/web/templates/dashboard/password.html
@@ -0,0 +1,13 @@
+{{define "title"}}Change password · Blogspace{{end}}
+{{define "content"}}
+<div class="card narrow">
+ <h1>Change password</h1>
+ <form method="post" action="/account/password">
+ <input type="hidden" name="_csrf" value="{{.CSRF}}">
+ <label>Current password<br><input type="password" name="current" required autocomplete="current-password"></label>
+ <label>New password<br><input type="password" name="password" required minlength="8" autocomplete="new-password"></label>
+ <label>Repeat new password<br><input type="password" name="password2" required minlength="8" autocomplete="new-password"></label>
+ <button type="submit">Change password</button>
+ </form>
+</div>
+{{end}}
diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html
new file mode 100644
index 0000000..520b9e6
--- /dev/null
+++ b/internal/web/templates/dashboard/post_form.html
@@ -0,0 +1,26 @@
+{{define "title"}}{{if .Data.post.ID}}Edit post{{else}}New post{{end}} · {{.Blog.Title}}{{end}}
+{{define "content"}}
+<div class="card">
+ <h1>{{if .Data.post.ID}}Edit post{{else}}New post{{end}}</h1>
+ <form method="post">
+ <input type="hidden" name="_csrf" value="{{.CSRF}}">
+ <label>Title<br><input name="title" value="{{.Data.post.Title}}" required maxlength="200" autofocus></label>
+ <div class="row">
+ <label>Page<br>
+ <select name="page_id">{{range .Data.pages}}<option value="{{.ID}}"{{if eq .ID $.Data.post.PageID}} selected{{end}}>{{.Title}}</option>{{end}}</select>
+ </label>
+ <label>Address <span class="muted">(optional, made from the title)</span><br>
+ <input name="slug" value="{{.Data.post.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label>
+ </div>
+ <label>Content <span class="muted">(Markdown)</span><br>
+ <textarea name="body" rows="24" class="editor">{{.Data.post.BodyMD}}</textarea></label>
+ <label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> Visible on the blog</label>
+ <p>
+ <button type="submit">Save</button>
+ {{if .Data.post.ID}}<a class="btn secondary" href="{{.BlogURL}}/{{.Data.post.PageSlug}}/{{.Data.post.Slug}}" target="_blank">View post &#8599;</a>{{end}}
+ <a href="/b/{{.Blog.Subdomain}}/posts">Back to posts</a>
+ </p>
+ </form>
+</div>
+{{template "mdhelp"}}
+{{end}}
diff --git a/internal/web/templates/dashboard/posts.html b/internal/web/templates/dashboard/posts.html
new file mode 100644
index 0000000..24e86bb
--- /dev/null
+++ b/internal/web/templates/dashboard/posts.html
@@ -0,0 +1,25 @@
+{{define "title"}}Posts · {{.Blog.Title}}{{end}}
+{{define "content"}}
+<h1>Posts <a class="btn small" href="/b/{{.Blog.Subdomain}}/posts/new{{if .Data.pageID}}?page={{.Data.pageID}}{{end}}">+ New post</a></h1>
+<form method="get" class="filter">
+ <label>Page:
+ <select name="page" onchange="this.form.submit()">
+ <option value="0">All pages</option>
+ {{range .Data.pages}}<option value="{{.ID}}"{{if eq .ID $.Data.pageID}} selected{{end}}>{{.Title}}</option>{{end}}
+ </select></label>
+ <noscript><button type="submit">Filter</button></noscript>
+</form>
+<div class="card">
+{{if .Data.posts}}
+<table>
+ <tr><th>Title</th><th>Page</th><th>Date</th><th></th></tr>
+ {{range .Data.posts}}<tr>
+ <td><a href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/edit">{{.Title}}</a>{{if not .Published}} <span class="tag">hidden</span>{{end}}</td>
+ <td>{{.PageTitle}}</td>
+ <td class="nowrap">{{date .CreatedAt}}</td>
+ <td class="nowrap"><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">view &#8599;</a> &middot; <a class="danger" href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/delete">delete</a></td>
+ </tr>{{end}}
+</table>
+{{else}}<p class="muted">No posts here yet.</p>{{end}}
+</div>
+{{end}}
diff --git a/internal/web/templates/dashboard/settings.html b/internal/web/templates/dashboard/settings.html
new file mode 100644
index 0000000..24dffb4
--- /dev/null
+++ b/internal/web/templates/dashboard/settings.html
@@ -0,0 +1,13 @@
+{{define "title"}}Settings · {{.Blog.Title}}{{end}}
+{{define "content"}}
+<div class="card narrow">
+ <h1>Blog settings</h1>
+ <form method="post" action="/b/{{.Blog.Subdomain}}/settings">
+ <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>
+ <button type="submit">Save</button>
+ </form>
+</div>
+{{end}}
diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html
new file mode 100644
index 0000000..7e3128e
--- /dev/null
+++ b/internal/web/templates/layouts/blog.html
@@ -0,0 +1,38 @@
+{{define "layout"}}<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{block "title" .}}{{.Blog.Title}}{{end}}</title>
+<link rel="alternate" type="application/rss+xml" title="{{.Blog.Title}}" href="/feed.xml">
+<link rel="stylesheet" href="/static/blog.css">
+<style>{{css .Data.css}}</style>
+</head>
+<body class="nav-{{.Data.theme.NavPosition}}">
+{{if eq .Data.theme.NavPosition "top-bar"}}{{template "blognav" .}}{{end}}
+<div class="site-header">
+ {{if .Data.theme.HeaderImage}}<div class="header-image"><a href="/"><img src="/media/{{.Data.theme.HeaderImage}}" alt=""></a></div>{{end}}
+ {{if .Data.theme.HeaderShowTitle}}<div class="wrap header-text">
+ <h1 class="site-title"><a href="/">{{.Blog.Title}}</a></h1>
+ {{if .Blog.Tagline}}<p class="tagline">{{.Blog.Tagline}}</p>{{end}}
+ </div>{{end}}
+</div>
+{{if eq .Data.theme.NavPosition "below-header"}}{{template "blognav" .}}{{end}}
+<div class="wrap body-wrap">
+ {{if eq .Data.theme.NavPosition "left-sidebar"}}{{template "blognav" .}}{{end}}
+ <div class="content">
+ {{template "content" .}}
+ </div>
+</div>
+<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>
+ </div>
+</div>
+</body>
+</html>{{end}}
+
+{{define "blognav"}}<div class="site-nav"><div class="wrap nav-inner">
+{{range .Data.nav}}<a href="{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}"{{if and $.Data.page (eq $.Data.page.ID .ID)}} class="active"{{end}}>{{.Title}}</a>
+{{end}}</div></div>{{end}}
diff --git a/internal/web/templates/layouts/dashboard.html b/internal/web/templates/layouts/dashboard.html
new file mode 100644
index 0000000..6633c29
--- /dev/null
+++ b/internal/web/templates/layouts/dashboard.html
@@ -0,0 +1,17 @@
+{{define "layout"}}<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{block "title" .}}Blogspace{{end}}</title>
+<link rel="stylesheet" href="/static/dashboard.css">
+</head>
+<body>
+{{template "dashnav" .}}
+<main class="main">
+{{if .Flash}}<div class="flash ok">{{.Flash}}</div>{{end}}
+{{if .Error}}<div class="flash err">{{.Error}}</div>{{end}}
+{{template "content" .}}
+</main>
+</body>
+</html>{{end}}
diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html
new file mode 100644
index 0000000..13a04d0
--- /dev/null
+++ b/internal/web/templates/partials/dashnav.html
@@ -0,0 +1,27 @@
+{{define "dashnav"}}<div class="topbar">
+ <div class="topbar-inner">
+ <a class="brand" href="/">Blogspace</a>
+ {{if .User}}
+ <span class="who">{{.User.Username}}{{if .User.IsSuperadmin}} (superadmin){{end}}</span>
+ <span class="links">
+ {{if .User.IsSuperadmin}}<a href="/admin/">Admin</a>{{end}}
+ <a href="/account/password">Password</a>
+ <form method="post" action="/logout" class="inline"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button type="submit" class="linkbtn">Log out</button></form>
+ </span>
+ {{end}}
+ </div>
+</div>
+{{if .Blog}}
+<div class="blogbar">
+ <div class="topbar-inner">
+ <strong class="blogname">{{.Blog.Title}}</strong>
+ <a href="/b/{{.Blog.Subdomain}}/">Overview</a>
+ <a href="/b/{{.Blog.Subdomain}}/posts">Posts</a>
+ <a href="/b/{{.Blog.Subdomain}}/pages">Pages</a>
+ <a href="/b/{{.Blog.Subdomain}}/design">Design</a>
+ <a href="/b/{{.Blog.Subdomain}}/images">Images</a>
+ <a href="/b/{{.Blog.Subdomain}}/settings">Settings</a>
+ <a class="view" href="{{.BlogURL}}" target="_blank">View blog &#8599;</a>
+ </div>
+</div>
+{{end}}{{end}}
diff --git a/internal/web/templates/partials/imagepick.html b/internal/web/templates/partials/imagepick.html
new file mode 100644
index 0000000..687c6cd
--- /dev/null
+++ b/internal/web/templates/partials/imagepick.html
@@ -0,0 +1,11 @@
+{{define "imagepick"}}<div class="imagepick">
+ <label>{{.label}}<br>
+ <select name="{{.name}}">
+ <option value="">{{if .current}}(keep current){{else}}(none){{end}}</option>
+ {{range .images}}<option value="{{.ID}}"{{if eq .ID.String $.current}} selected{{end}}>{{.Filename}}</option>{{end}}
+ </select>
+ </label>
+ <label>…or upload a new one<br><input type="file" name="{{.name}}_file" accept="image/*"></label>
+ {{if .current}}<label class="check"><input type="checkbox" name="{{.name}}_remove"> Remove image</label>
+ <img class="preview" src="/media/{{.current}}" alt="">{{end}}
+</div>{{end}}
diff --git a/internal/web/templates/partials/mdhelp.html b/internal/web/templates/partials/mdhelp.html
new file mode 100644
index 0000000..878130e
--- /dev/null
+++ b/internal/web/templates/partials/mdhelp.html
@@ -0,0 +1,9 @@
+{{define "mdhelp"}}<div class="card help">
+ <h3>Formatting cheat-sheet</h3>
+ <table class="cheat">
+ <tr><td><code># Heading</code>, <code>## Smaller heading</code></td><td><code>**bold**</code>, <code>*italic*</code></td></tr>
+ <tr><td><code>[link text](https://example.org)</code></td><td><code>![description](/media/…)</code> — copy the address from <em>Images</em></td></tr>
+ <tr><td><code>- list item</code> / <code>1. numbered</code></td><td><code>&gt; quote</code>, <code>`code`</code>, <code>---</code> for a line</td></tr>
+ </table>
+ <p class="muted">Blank line = new paragraph. Press Enter once for a line break.</p>
+</div>{{end}}
diff --git a/internal/web/theme.go b/internal/web/theme.go
new file mode 100644
index 0000000..40efc54
--- /dev/null
+++ b/internal/web/theme.go
@@ -0,0 +1,212 @@
+package web
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/url"
+ "regexp"
+ "strings"
+ "text/template"
+
+ "github.com/google/uuid"
+)
+
+// Theme is the structured, form-editable look of a blog. Stored as jsonb.
+type Theme struct {
+ BgColor string `json:"bg_color"`
+ BgImage string `json:"bg_image"` // image uuid or ""
+ BgMode string `json:"bg_mode"` // cover | tile | fixed
+
+ ContentBg string `json:"content_bg"`
+ TextColor string `json:"text_color"`
+ LinkColor string `json:"link_color"`
+ ContentWidth string `json:"content_width"` // narrow | medium | wide
+ Font string `json:"font"` // sans | serif | mono
+ FontSize string `json:"font_size"` // small | normal | large
+
+ HeaderShowTitle bool `json:"header_show_title"`
+ HeaderImage string `json:"header_image"`
+ HeaderAlign string `json:"header_align"` // left | center
+ HeaderBg string `json:"header_bg"`
+ HeaderText string `json:"header_text"`
+
+ NavPosition string `json:"nav_position"` // below-header | top-bar | left-sidebar
+ NavBg string `json:"nav_bg"`
+ NavText string `json:"nav_text"`
+ NavShowHome bool `json:"nav_show_home"`
+
+ FooterText string `json:"footer_text"`
+ FooterBg string `json:"footer_bg"`
+ FooterColor string `json:"footer_color"`
+}
+
+func DefaultTheme() Theme {
+ return Theme{
+ BgColor: "#e9e6df", BgMode: "cover",
+ ContentBg: "#ffffff", TextColor: "#222222", LinkColor: "#1a5fb4",
+ ContentWidth: "medium", Font: "sans", FontSize: "normal",
+ HeaderShowTitle: true, HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff",
+ NavPosition: "below-header", NavBg: "#ffffff", NavText: "#222222", NavShowHome: true,
+ FooterText: "", FooterBg: "#2f3a4a", FooterColor: "#d0d5dc",
+ }
+}
+
+// ParseTheme decodes stored JSON on top of the defaults so new fields get sane values.
+func ParseTheme(raw json.RawMessage) Theme {
+ t := DefaultTheme()
+ if len(raw) > 0 {
+ _ = json.Unmarshal(raw, &t)
+ }
+ t.normalize()
+ return t
+}
+
+var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
+
+func pick(v string, allowed ...string) string {
+ for _, a := range allowed {
+ if v == a {
+ return v
+ }
+ }
+ return allowed[0]
+}
+
+func color(v, def string) string {
+ v = strings.TrimSpace(v)
+ if hexColor.MatchString(v) {
+ return strings.ToLower(v)
+ }
+ return def
+}
+
+func imageID(v string) string {
+ if _, err := uuid.Parse(v); err != nil {
+ return ""
+ }
+ return v
+}
+
+// normalize clamps every field to an allowed value so the CSS template can trust them.
+func (t *Theme) normalize() {
+ d := DefaultTheme()
+ t.BgColor = color(t.BgColor, d.BgColor)
+ t.BgImage = imageID(t.BgImage)
+ t.BgMode = pick(t.BgMode, "cover", "tile", "fixed")
+ t.ContentBg = color(t.ContentBg, d.ContentBg)
+ t.TextColor = color(t.TextColor, d.TextColor)
+ t.LinkColor = color(t.LinkColor, d.LinkColor)
+ t.ContentWidth = pick(t.ContentWidth, "medium", "narrow", "wide")
+ t.Font = pick(t.Font, "sans", "serif", "mono")
+ t.FontSize = pick(t.FontSize, "normal", "small", "large")
+ t.HeaderImage = imageID(t.HeaderImage)
+ t.HeaderAlign = pick(t.HeaderAlign, "left", "center")
+ t.HeaderBg = color(t.HeaderBg, d.HeaderBg)
+ t.HeaderText = color(t.HeaderText, d.HeaderText)
+ t.NavPosition = pick(t.NavPosition, "below-header", "top-bar", "left-sidebar")
+ t.NavBg = color(t.NavBg, d.NavBg)
+ t.NavText = color(t.NavText, d.NavText)
+ t.FooterBg = color(t.FooterBg, d.FooterBg)
+ t.FooterColor = color(t.FooterColor, d.FooterColor)
+ if len(t.FooterText) > 2000 {
+ t.FooterText = t.FooterText[:2000]
+ }
+}
+
+// ThemeFromForm reads the design form on top of the current theme.
+func ThemeFromForm(cur Theme, f url.Values) Theme {
+ t := cur
+ get := func(k string) string { return strings.TrimSpace(f.Get(k)) }
+ t.BgColor = get("bg_color")
+ t.BgMode = get("bg_mode")
+ t.ContentBg = get("content_bg")
+ t.TextColor = get("text_color")
+ t.LinkColor = get("link_color")
+ t.ContentWidth = get("content_width")
+ t.Font = get("font")
+ t.FontSize = get("font_size")
+ t.HeaderShowTitle = f.Get("header_show_title") == "on"
+ t.HeaderAlign = get("header_align")
+ t.HeaderBg = get("header_bg")
+ t.HeaderText = get("header_text")
+ t.NavPosition = get("nav_position")
+ t.NavBg = get("nav_bg")
+ t.NavText = get("nav_text")
+ t.NavShowHome = f.Get("nav_show_home") == "on"
+ t.FooterText = get("footer_text")
+ t.FooterBg = get("footer_bg")
+ t.FooterColor = get("footer_color")
+ if f.Get("bg_image_remove") == "on" {
+ t.BgImage = ""
+ } else if v := get("bg_image"); v != "" {
+ t.BgImage = v
+ }
+ if f.Get("header_image_remove") == "on" {
+ t.HeaderImage = ""
+ } else if v := get("header_image"); v != "" {
+ t.HeaderImage = v
+ }
+ t.normalize()
+ return t
+}
+
+func (t Theme) JSON() json.RawMessage {
+ b, _ := json.Marshal(t)
+ return b
+}
+
+// Helpers used by the CSS template.
+func (t Theme) FontFamily() string {
+ switch t.Font {
+ case "serif":
+ return `Georgia, "Times New Roman", Times, serif`
+ case "mono":
+ return `"Courier New", Courier, monospace`
+ }
+ return `"Helvetica Neue", Helvetica, Arial, sans-serif`
+}
+
+func (t Theme) FontSizePx() string {
+ switch t.FontSize {
+ case "small":
+ return "15px"
+ case "large":
+ return "19px"
+ }
+ return "17px"
+}
+
+func (t Theme) MaxWidth() string {
+ switch t.ContentWidth {
+ case "narrow":
+ return "640px"
+ case "wide":
+ return "1100px"
+ }
+ return "840px"
+}
+
+var themeCSS = template.Must(template.New("theme").Parse(`
+body { margin:0; background-color:{{.BgColor}}; color:{{.TextColor}}; font-family:{{.FontFamily}}; font-size:{{.FontSizePx}}; line-height:1.55;
+{{- if .BgImage}} background-image:url(/media/{{.BgImage}});
+ {{- if eq .BgMode "cover"}} background-size:cover; background-position:center top; background-repeat:no-repeat;
+ {{- else if eq .BgMode "fixed"}} background-size:cover; background-position:center; background-attachment:fixed; background-repeat:no-repeat;
+ {{- else}} background-repeat:repeat;{{end}}
+{{- end}} }
+a { color:{{.LinkColor}}; }
+.wrap { max-width:{{.MaxWidth}}; margin:0 auto; }
+.site-header { background:{{.HeaderBg}}; color:{{.HeaderText}}; text-align:{{.HeaderAlign}}; }
+.site-header a { color:{{.HeaderText}}; }
+.site-nav { background:{{.NavBg}}; }
+.site-nav a { color:{{.NavText}}; }
+.content { background:{{.ContentBg}}; }
+.site-footer { background:{{.FooterBg}}; color:{{.FooterColor}}; }
+.site-footer a { color:{{.FooterColor}}; }
+`))
+
+// CSS renders the per-blog stylesheet; all values were normalized so it is safe to inline.
+func (t Theme) CSS() string {
+ var b bytes.Buffer
+ _ = themeCSS.Execute(&b, t)
+ return b.String()
+}
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
new file mode 100644
index 0000000..42e37a7
--- /dev/null
+++ b/internal/web/web_test.go
@@ -0,0 +1,73 @@
+package web
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/gramanas/blogspace/internal/config"
+)
+
+func TestHostname(t *testing.T) {
+ cases := map[string]string{"Example.com:8080": "example.com", "a.example.com": "a.example.com", "example.com.": "example.com", "[::1]:80": "::1"}
+ for in, want := range cases {
+ if got := hostname(in); got != want {
+ t.Errorf("hostname(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+// Host routing that does not need the database: unknown hosts and reserved/nested subdomains 404 before any lookup.
+func TestHostRoutingWithoutDB(t *testing.T) {
+ cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20}
+ s := NewServer(cfg, nil)
+ for _, host := range []string{"evil.com", "a.b.example.com", "www.blog.example.com", "static.example.com", "notexample.com"} {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/", nil)
+ req.Host = host
+ s.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("host %q: got %d, want 404", host, rec.Code)
+ }
+ }
+ // root domain (and www) reach the management mux: /login 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.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)
+ }
+ }
+}
+
+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" {
+ t.Errorf("normalize: %+v", th)
+ }
+ f := url.Values{"bg_color": {"#ABCDEF"}, "content_width": {"wide"}, "header_show_title": {"on"}, "bg_image": {"not-a-uuid"}}
+ th = ThemeFromForm(DefaultTheme(), f)
+ if th.BgColor != "#abcdef" || th.ContentWidth != "wide" || !th.HeaderShowTitle || th.BgImage != "" {
+ t.Errorf("from form: %+v", th)
+ }
+ css := th.CSS()
+ if !strings.Contains(css, "background-color:#abcdef") || !strings.Contains(css, "max-width:1100px") || strings.Contains(css, "display:none") {
+ t.Errorf("css: %s", css)
+ }
+}
+
+func TestAllTemplatesParse(t *testing.T) {
+ tpl := newTemplates(false, funcs)
+ for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html",
+ "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/images.html", "dashboard/settings.html",
+ "dashboard/password.html", "dashboard/confirm.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html",
+ "blog/page.html", "blog/post.html", "blog/404.html"} {
+ if _, err := tpl.get(name); err != nil {
+ t.Errorf("%s: %v", name, err)
+ }
+ }
+}