aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-13 23:52:41 +0300
committergrm <grm@eyesin.space>2026-09-13 23:52:41 +0300
commitaeb19df4222269c585de55be5568326222df879d (patch)
treee87ee743b73ef7b5b1999e61d1b3ad1e19fe1569
parente671381121a63422f0cf4d5b0842f80109a12d20 (diff)
downloadblogspace-aeb19df4222269c585de55be5568326222df879d.tar.gz
blogspace-aeb19df4222269c585de55be5568326222df879d.tar.bz2
blogspace-aeb19df4222269c585de55be5568326222df879d.zip
Give every blog its own Postgres database
A blog is now a database of its own (blog_<sub>) on the same server: one pg_dump is a complete backup of a blog, one psql restores it, and nothing a blog's queries do can reach another blog's rows. The control database (DATABASE_URL) keeps only users and the blog registry (id, owner, subdomain, db_name); title, tagline and theme move into a one-row settings table next to the content so the dump really is everything. db.Cluster holds the control pool plus small, lazily opened per-blog pools. store.Store (control) hands out a store.BlogStore per blog; every blog_id parameter and column is gone, the database is the scope. Handlers reach it through blogStore(r), which resolveBlog puts in the context next to the blog. Existing data is moved in place by control migration 00006, a Go migration that runs inside the control transaction: it creates and migrates each blog database, copies the rows preserving ids, and marks the registry; 00007 then drops the old tables. Either every blog is moved or the control database is untouched. /media/{id} now serves the host's blog only, so dashboard previews on the root domain use /b/{sub}/media/{id}. Subdomains are capped at 58 chars so "blog_" + name fits a Postgres identifier. Deleting a user drops their database. Store.Open resets a blog's pool and retries once so a database restored under a running app (dropdb --force, createdb, psql) just works. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
-rw-r--r--AGENTS.md55
-rw-r--r--README.md45
-rw-r--r--cmd/blogspace/main.go31
-rw-r--r--cmd/blogspace/seed.go30
-rw-r--r--internal/db/db.go182
-rw-r--r--internal/db/db_test.go25
-rw-r--r--internal/db/migrations/blog/00001_init.sql91
-rw-r--r--internal/db/migrations/control/00001_init.sql (renamed from internal/db/migrations/00001_init.sql)0
-rw-r--r--internal/db/migrations/control/00002_sections.sql (renamed from internal/db/migrations/00002_sections.sql)0
-rw-r--r--internal/db/migrations/control/00003_layout.sql (renamed from internal/db/migrations/00003_layout.sql)0
-rw-r--r--internal/db/migrations/control/00004_section_placement.sql (renamed from internal/db/migrations/00004_section_placement.sql)0
-rw-r--r--internal/db/migrations/control/00005_per_blog_databases.sql12
-rw-r--r--internal/db/migrations/control/00007_drop_content.sql9
-rw-r--r--internal/db/split.go152
-rw-r--r--internal/store/blogs.go118
-rw-r--r--internal/store/images.go25
-rw-r--r--internal/store/menu.go54
-rw-r--r--internal/store/modules.go57
-rw-r--r--internal/store/pages.go69
-rw-r--r--internal/store/posts.go58
-rw-r--r--internal/store/sections.go51
-rw-r--r--internal/store/store.go39
-rw-r--r--internal/store/users.go22
-rw-r--r--internal/web/handlers_admin.go12
-rw-r--r--internal/web/handlers_blog.go25
-rw-r--r--internal/web/handlers_dashboard.go7
-rw-r--r--internal/web/handlers_design.go20
-rw-r--r--internal/web/handlers_layout.go35
-rw-r--r--internal/web/handlers_media.go2
-rw-r--r--internal/web/handlers_pages.go16
-rw-r--r--internal/web/handlers_posts.go20
-rw-r--r--internal/web/handlers_sections.go17
-rw-r--r--internal/web/routes.go6
-rw-r--r--internal/web/server.go37
-rw-r--r--internal/web/templates/admin/index.html2
-rw-r--r--internal/web/templates/dashboard/design.html8
-rw-r--r--internal/web/templates/dashboard/images.html2
-rw-r--r--internal/web/templates/partials/imagepick.html2
-rw-r--r--internal/web/web_test.go9
39 files changed, 1009 insertions, 336 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 3a1da81..8b997e4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,6 +23,9 @@ table and deployment notes.
(e.g. the `<noscript>` button in `posts.html`).
- Server-rendered `html/template`; no SPA, no frontend toolchain.
- Postgres holds **everything**, including images (`images.data bytea`).
+ **Each blog is its own database** (`blog_<sub>`, see Key mechanics) so a
+ blog is backed up and restored with plain `pg_dump`/`psql`; the control
+ database (`DATABASE_URL`) holds only `users` and the `blogs` registry.
- Posts are Markdown (goldmark → bluemonday). No WYSIWYG. The editor's
"Insert image" is the one scripted convenience (`partials/editor.html`);
it must keep its no-JS fallback (file appended on save).
@@ -40,10 +43,14 @@ table and deployment notes.
## Layout
```
-cmd/blogspace/ main.go (serve|seed|migrate, superadmin + root blog bootstrap), seed.go
+cmd/blogspace/ main.go (serve|seed|migrate, superadmin + root blog bootstrap,
+ migrates every blog database at start), seed.go
internal/config/ env → Config; RootSubdomain = "www"
-internal/db/ pgxpool + goose; migrations/*.sql embedded
-internal/store/ models + hand-written SQL (users, blogs, pages, posts, images, sections, modules, menu)
+internal/db/ Cluster (control pool + lazy per-blog pools, CREATE/DROP DATABASE),
+ goose providers; migrations/control/*.sql, migrations/blog/*.sql,
+ split.go (control Go migration 00006: the one-off move to per-blog DBs)
+internal/store/ Store = control DB (users, blog registry, create/delete blog);
+ BlogStore = one blog's DB (settings, pages, posts, images, sections, modules, menu)
internal/auth/ bcrypt, JWT issue/parse, cookie, HMAC CSRF
internal/markdown/ Render(md) → sanitized HTML
internal/slug/ Make/Valid/WithSuffix (Greek transliteration included)
@@ -57,11 +64,29 @@ internal/web/ server.go (host router, middleware, render helpers)
## Key mechanics
+- **One database per blog**: the registry row (`blogs`: id, owner, subdomain,
+ `db_name`) lives in the control DB; everything else — a one-row `settings`
+ table (title, tagline, theme) plus pages, posts, images, sections, modules,
+ menu_items — lives in `blog_<sub>` (`db.DBName`: dashes → underscores, so
+ subdomains are capped at 58 chars). No table in a blog DB carries a
+ blog id; the database is the scope. `store.Store` (control) hands out a
+ `store.BlogStore` per blog via `Store.Open`, which also fills `Blog.Title/
+ Tagline/ThemeJSON` from `settings`. `db.Cluster` caches one small pool per
+ blog (4 conns, idle closed after 2 min). `Store.Open` resets the pool and
+ retries once so a `dropdb --force` + restore under a running app is seamless.
+ Creating a blog: registry insert in a control tx → `CreateBlogDB` (CREATE
+ DATABASE + blog migrations, outside the tx) → settings/home page/defaults →
+ commit; any failure rolls back and drops the DB. A leftover database with
+ the blog's name is `db.ErrDatabaseExists` (409 in `/admin/users/new`).
+ Deleting a user drops their blog database. Start-up (`migrateBlogs`)
+ migrates every registered blog DB; a missing one is logged and skipped.
- **Host routing** (`web/server.go` `ServeHTTP`): base domain or `www.` →
root mux with `ctxHostSub = "www"`; `<sub>.base` → subdomain mux; anything
else 404. Public blog handlers get the blog via the `hostBlog` wrapper;
management handlers get it via `withBlog` (`/b/{sub}/…`, owner or
- superadmin). Both put it in `ctxBlog`; templates see it as `.Blog`.
+ superadmin). Both call `resolveBlog`, which puts the blog in `ctxBlog`
+ (templates see it as `.Blog`) and its `BlogStore` in `ctxBlogStore`
+ (`blogStore(r)` in handlers).
- **Root blog** is a normal `blogs` row with subdomain `www`, owned by the
first superadmin, created by `ensureRootBlog` on startup. Managed at
`/b/www/`. `Config.BlogURL("www")` returns the root URL.
@@ -141,7 +166,12 @@ internal/web/ server.go (host router, middleware, render helpers)
(`handleFavicon`) serves the png or redirects to the blog's image, so
icon-probing browsers never hit the 404 page; `favicon.ico` is reserved.
- **Images**: `/media/{uuid}` served on every host with immutable cache
- headers. Uploads are content-sniffed (png/jpeg/gif/webp/ico). Deleting an
+ headers, from the host's blog database only (the root domain serves the
+ root blog's images). Dashboard previews for another blog on the root host
+ therefore use `GET /b/{sub}/media/{id}` (`withBlog`): `dashboard/images.html`
+ and the `imagepick` partial (`sub` arg). Markdown keeps the relative
+ `/media/…` form because post bodies render on the blog host.
+ Uploads are content-sniffed (png/jpeg/gif/webp/ico). Deleting an
image clears theme references to it. `POST /b/{sub}/images/upload`
answers JSON (`{id, filename, markdown}` / `{error}`) when the request
has `Accept: application/json`; that is what the editor script calls.
@@ -172,7 +202,7 @@ internal/web/ server.go (host router, middleware, render helpers)
make dev # postgres in docker + app (DEV=1: templates/static reloaded from disk)
make seed # admin/admin (superadmin, owns root blog), alice/alicealice
make test # unit tests; no DB needed
-make db-reset # wipe dev data
+make db-reset # wipe dev data (control and all blog databases: the whole volume)
```
Port 8080 may be taken on the owner's machine: `make dev ADDR=:8090 PUBLIC_PORT=8090`.
Browsers resolve `*.blogspace.localhost` to loopback; with curl use
@@ -184,11 +214,16 @@ superadmin password to `admin`. Production refuses both.
## Conventions
- Run `gofmt -w .`, `go vet ./...`, `go test ./...` before committing.
-- Migrations: add `internal/db/migrations/NNNNN_name.sql` with goose
+- Migrations: `internal/db/migrations/blog/NNNNN_name.sql` for blog content
+ (runs in every blog database — it must not reference `users`/`blogs`) and
+ `internal/db/migrations/control/` for users and the registry; goose
`-- +goose Up/Down` sections; they run automatically at startup. Never
- edit an applied migration.
-- New store methods take `ctx` first and scope queries by `blog_id` so a
- blogger can never touch another blog's rows.
+ edit an applied migration. Control 00005–00007 are the one-off split (add
+ `db_name`, move content in `split.go`, drop the old tables) — a fresh
+ install still replays them.
+- Blog content goes through `BlogStore` methods, which take `ctx` first and
+ are bound to one blog's database, so a blogger can never touch another
+ blog's rows; users and the registry go through `Store`.
- Keep handlers thin: validate → call store → `redirectOK`. Re-render the
form with `"error"` in the data map on validation failure (4xx status).
- Match the existing comment density; comments explain *why*, not *what*.
diff --git a/README.md b/README.md
index d8c11f2..fbf7412 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,9 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones.
- Changes are live immediately: save, then refresh the blog tab.
- A **superadmin** creates bloggers, resets passwords, disables or deletes accounts.
- The **root domain is itself a blog**, owned by the superadmin and managed like any other.
-- Images are stored in Postgres; everything is in one database.
+- **Each blog is its own Postgres database** (`blog_<name>`), images included, so one
+ `pg_dump` is a complete backup of a blog and one `psql` restores it. A small control
+ database holds the users and the list of blogs.
## Local development
@@ -56,13 +58,13 @@ Go changes need a restart.
| `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 |
+| `DATABASE_URL` | local dev DSN | Connection string of the **control** database; blog databases are created next to it by the same role |
| `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.
+Migrations run automatically at startup, for the control database and for every blog database.
## Deployment (Docker)
@@ -95,16 +97,45 @@ server {
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.
+### Backups and restores
+
+Every blog lives in its own database, `blog_<name>` (dashes become underscores:
+`my-blog` → `blog_my_blog`). The control database (`blogspace`) holds the users
+and the blog registry. Images are in the blog database, so one dump is the whole blog.
+
+```sh
+# one blog
+docker compose exec db pg_dump -U blogspace blog_alice > alice.sql
+# users and the blog list
+docker compose exec db pg_dump -U blogspace blogspace > control.sql
+# everything at once
+docker compose exec db pg_dumpall -U blogspace > all.sql
+```
+
+Restoring a blog, with the app running:
+
+```sh
+docker compose exec db dropdb -U blogspace --force blog_alice
+docker compose exec db createdb -U blogspace blog_alice
+docker compose exec -T db psql -U blogspace -q blog_alice < alice.sql
+```
+
+The registry row must exist: on a fresh install first create the user with that
+subdomain in `/admin/` (which makes an empty `blog_alice`), then overwrite it as
+above. A dump taken with an older version of Blogspace is upgraded at the next
+start (or with `blogspace migrate`).
+
+Deleting a user in `/admin/` drops their blog database — take a dump first if
+you may want it back. Blog pools are small (4 connections each, closed when
+idle); with many blogs busy at once, raise `max_connections` on the `db` service.
## 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, sections)
+internal/db/ control + per-blog pools, goose migrations (control/ and blog/)
+internal/store/ Store (users, blog registry) and BlogStore (one blog's content)
internal/auth/ bcrypt, JWT cookie sessions, CSRF tokens
internal/markdown/ goldmark + bluemonday
internal/slug/ title → slug
diff --git a/cmd/blogspace/main.go b/cmd/blogspace/main.go
index cf240a8..20e6ffe 100644
--- a/cmd/blogspace/main.go
+++ b/cmd/blogspace/main.go
@@ -45,15 +45,18 @@ 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 {
+ if err := db.MigrateControl(ctx, cfg.DatabaseURL); err != nil {
return fmt.Errorf("migrate: %w", err)
}
- pool, err := db.Open(ctx, cfg.DatabaseURL)
+ cluster, err := db.Open(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
- defer pool.Close()
- st := store.New(pool)
+ defer cluster.Close()
+ st := store.New(cluster)
+ if err := migrateBlogs(ctx, cluster, st); err != nil {
+ return err
+ }
switch cmd {
case "migrate":
@@ -74,13 +77,29 @@ func run(ctx context.Context, cfg *config.Config, cmd string) error {
}
}
+// migrateBlogs brings every registered blog database up to the current
+// schema. A blog whose database is missing (a restore gone wrong) is logged
+// and skipped so the other blogs still come up; its requests fail until fixed.
+func migrateBlogs(ctx context.Context, cluster *db.Cluster, st *store.Store) error {
+ blogs, err := st.ListBlogs(ctx)
+ if err != nil {
+ return err
+ }
+ for _, b := range blogs {
+ if err := db.MigrateBlog(ctx, cluster.BlogURL(b.DBName)); err != nil {
+ log.Printf("blog %q: database %s: %v", b.Subdomain, b.DBName, err)
+ }
+ }
+ return nil
+}
+
// 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)
+ c, err := db.Open(ctx, url)
if err == nil {
- pool.Close()
+ c.Close()
return nil
}
if time.Now().After(deadline) {
diff --git a/cmd/blogspace/seed.go b/cmd/blogspace/seed.go
index 7862363..a476486 100644
--- a/cmd/blogspace/seed.go
+++ b/cmd/blogspace/seed.go
@@ -41,10 +41,14 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error {
if err != nil {
return err
}
- if err := st.UpdateBlogSettings(ctx, blog.ID, blog.Title, "Notes on gardening, code and everything in between"); err != nil {
+ bs, err := st.Open(ctx, blog)
+ if err != nil {
+ return err
+ }
+ if err := bs.UpdateSettings(ctx, blog.Title, "Notes on gardening, code and everything in between"); err != nil {
return err
}
- home, err := st.HomePage(ctx, blog.ID)
+ home, err := bs.HomePage(ctx)
if err != nil {
return err
}
@@ -54,9 +58,8 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error {
{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)
+ created, err := bs.CreatePage(ctx, &p)
if err != nil {
return err
}
@@ -74,14 +77,14 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error {
if err := png.Encode(&buf, img); err != nil {
return err
}
- bg, err := st.CreateImage(ctx, blog.ID, "gradient.png", "image/png", buf.Bytes())
+ bg, err := bs.CreateImage(ctx, "gradient.png", "image/png", buf.Bytes())
if err != nil {
return err
}
theme := web.DefaultTheme()
theme.BgImage = bg.ID.String()
theme.BgMode = "tile"
- if err := st.UpdateBlogTheme(ctx, blog.ID, theme.JSON()); err != nil {
+ if err := bs.UpdateTheme(ctx, theme.JSON()); err != nil {
return err
}
// a footer line and a right column, so the layout options have something to show
@@ -90,8 +93,7 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error {
{Area: "right", Kind: "recent", Title: "Recent posts", Count: 5},
{Area: "right", Kind: "archive", Title: "Archive"},
} {
- m.BlogID = blog.ID
- if _, err := st.CreateModule(ctx, &m); err != nil {
+ if _, err := bs.CreateModule(ctx, &m); err != nil {
return err
}
}
@@ -100,7 +102,7 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error {
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 {
+ if _, err := bs.CreatePost(ctx, post); err != nil {
return err
}
}
@@ -112,7 +114,7 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error {
}
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 {
+ if _, err := bs.CreatePost(ctx, post); err != nil {
return err
}
}
@@ -121,12 +123,16 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error {
if err != nil {
return err
}
- rootHome, err := st.HomePage(ctx, root.ID)
+ rs, err := st.Open(ctx, root)
+ if err != nil {
+ return err
+ }
+ rootHome, err := rs.HomePage(ctx)
if err != nil {
return err
}
welcome := "This is the blog on the **root domain**, owned by the superadmin. It works like every other blog.\n\nOther blogs live on subdomains, for example [alice](" + cfg.BlogURL("alice") + ")."
- if _, err := st.CreatePost(ctx, &store.Post{PageID: rootHome.ID, Title: "Welcome to Blogspace", Slug: "welcome", BodyMD: welcome, BodyHTML: markdown.Render(welcome), Published: true}); err != nil {
+ if _, err := rs.CreatePost(ctx, &store.Post{PageID: rootHome.ID, Title: "Welcome to Blogspace", Slug: "welcome", BodyMD: welcome, BodyHTML: markdown.Render(welcome), Published: true}); err != nil {
return err
}
log.Printf("seed: superadmin %q (password %q in dev), blogger alice / alicealice at %s", cfg.SuperadminUsername, "admin", cfg.BlogURL("alice"))
diff --git a/internal/db/db.go b/internal/db/db.go
index 44d489b..601680d 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -1,22 +1,45 @@
-// Package db opens the Postgres pool and applies embedded migrations.
+// Package db opens the Postgres pools and applies the embedded migrations.
+//
+// There are two kinds of database: the control database (users and the blog
+// registry, named by DATABASE_URL) and one database per blog on the same
+// server, created by the app. A Cluster hands out pools for both.
package db
import (
"context"
"database/sql"
"embed"
+ "errors"
"fmt"
+ "io/fs"
+ "net/url"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
)
-//go:embed migrations/*.sql
+//go:embed migrations/control/*.sql migrations/blog/*.sql
var migrations embed.FS
-func Open(ctx context.Context, url string) (*pgxpool.Pool, error) {
- pool, err := pgxpool.New(ctx, url)
+// Cluster is the connection to one Postgres server: the control pool plus a
+// lazily opened, cached pool per blog database.
+type Cluster struct {
+ controlURL string
+ control *pgxpool.Pool
+ mu sync.Mutex
+ blogs map[string]*pgxpool.Pool
+}
+
+// Open connects to the control database.
+func Open(ctx context.Context, controlURL string) (*Cluster, error) {
+ pool, err := pgxpool.New(ctx, controlURL)
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
@@ -24,20 +47,155 @@ func Open(ctx context.Context, url string) (*pgxpool.Pool, error) {
pool.Close()
return nil, fmt.Errorf("ping: %w", err)
}
- return pool, nil
+ return &Cluster{controlURL: controlURL, control: pool, blogs: map[string]*pgxpool.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)
+func (c *Cluster) Control() *pgxpool.Pool { return c.control }
+
+// Blog returns the pool for a blog database, opening it on first use. Blog
+// pools are small and drop idle connections quickly, so a hundred quiet blogs
+// cost nothing; only the busy ones hold connections.
+func (c *Cluster) Blog(ctx context.Context, dbName string) (*pgxpool.Pool, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if p, ok := c.blogs[dbName]; ok {
+ return p, nil
+ }
+ cfg, err := pgxpool.ParseConfig(c.BlogURL(dbName))
+ if err != nil {
+ return nil, err
+ }
+ cfg.MaxConns = 4
+ cfg.MinConns = 0
+ cfg.MaxConnIdleTime = 2 * time.Minute
+ p, err := pgxpool.NewWithConfig(ctx, cfg)
+ if err != nil {
+ return nil, err
+ }
+ c.blogs[dbName] = p
+ return p, nil
+}
+
+// forget closes and drops the cached pool for a blog database, if any.
+func (c *Cluster) forget(dbName string) {
+ c.mu.Lock()
+ p, ok := c.blogs[dbName]
+ delete(c.blogs, dbName)
+ c.mu.Unlock()
+ if ok {
+ p.Close()
+ }
+}
+
+// Close closes every pool.
+func (c *Cluster) Close() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ for name, p := range c.blogs {
+ p.Close()
+ delete(c.blogs, name)
+ }
+ c.control.Close()
+}
+
+// BlogURL is the control URL pointed at another database on the same server.
+func (c *Cluster) BlogURL(dbName string) string { return withDatabase(c.controlURL, dbName) }
+
+func withDatabase(dsn, dbName string) string {
+ u, err := url.Parse(dsn)
+ if err != nil {
+ return dsn
+ }
+ u.Path = "/" + dbName
+ return u.String()
+}
+
+var dbNameRe = regexp.MustCompile(`^[a-z0-9_]{1,63}$`)
+
+// DBName is the database a blog lives in: "blog_" + subdomain, dashes as
+// underscores so the name needs no quoting in psql or pg_dump. Subdomains only
+// allow [a-z0-9-], so the mapping is one-to-one.
+func DBName(subdomain string) string { return "blog_" + strings.ReplaceAll(subdomain, "-", "_") }
+
+// ErrDatabaseExists is returned by CreateBlogDB when the name is taken — a
+// leftover from a deleted blog or a failed attempt that must be dropped by hand.
+var ErrDatabaseExists = errors.New("database already exists")
+
+// CreateBlogDB creates an empty blog database and applies the blog migrations.
+// CREATE DATABASE cannot run inside a transaction, so this always uses its own
+// connection; callers doing registry work in a transaction must order it so a
+// failure here rolls the transaction back.
+func (c *Cluster) CreateBlogDB(ctx context.Context, dbName string) error {
+ if !dbNameRe.MatchString(dbName) {
+ return fmt.Errorf("invalid database name %q", dbName)
+ }
+ if err := createDatabase(ctx, c.control, dbName); err != nil {
+ return err
+ }
+ if err := MigrateBlog(ctx, c.BlogURL(dbName)); err != nil {
+ c.control.Exec(ctx, `DROP DATABASE IF EXISTS `+pgx.Identifier{dbName}.Sanitize()+` WITH (FORCE)`)
+ return fmt.Errorf("migrate %s: %w", dbName, err)
+ }
+ return nil
+}
+
+// execer is the pool or connection createDatabase runs on.
+type execer interface {
+ Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
+}
+
+func createDatabase(ctx context.Context, db execer, dbName string) error {
+ _, err := db.Exec(ctx, `CREATE DATABASE `+pgx.Identifier{dbName}.Sanitize())
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == "42P04" { // duplicate_database
+ return fmt.Errorf("%w: %s", ErrDatabaseExists, dbName)
+ }
+ if err != nil {
+ return fmt.Errorf("create database %s: %w", dbName, err)
+ }
+ return nil
+}
+
+// DropBlogDB closes the blog's pool and drops its database, kicking any other
+// session still connected to it.
+func (c *Cluster) DropBlogDB(ctx context.Context, dbName string) error {
+ if !dbNameRe.MatchString(dbName) {
+ return fmt.Errorf("invalid database name %q", dbName)
+ }
+ c.forget(dbName)
+ _, err := c.control.Exec(ctx, `DROP DATABASE IF EXISTS `+pgx.Identifier{dbName}.Sanitize()+` WITH (FORCE)`)
+ return err
+}
+
+// ---- migrations ------------------------------------------------------------
+
+// MigrateControl applies the control database migrations, including the Go
+// migration that moves content out into the blog databases.
+func MigrateControl(ctx context.Context, controlURL string) error {
+ return migrate(ctx, controlURL, "migrations/control", goose.WithGoMigrations(splitMigration(controlURL)))
+}
+
+// MigrateBlog applies the blog schema migrations to one blog database.
+func MigrateBlog(ctx context.Context, blogURL string) error {
+ return migrate(ctx, blogURL, "migrations/blog")
+}
+
+// migrate runs goose over database/sql. A Provider (rather than the package
+// globals) keeps the two migration sets, and the Go migration, apart.
+func migrate(ctx context.Context, dsn, dir string, opts ...goose.ProviderOption) error {
+ sqldb, err := sql.Open("pgx", dsn)
if err != nil {
return err
}
defer sqldb.Close()
- goose.SetBaseFS(migrations)
- goose.SetLogger(goose.NopLogger())
- if err := goose.SetDialect("postgres"); err != nil {
+ fsys, err := fs.Sub(migrations, dir)
+ if err != nil {
+ return err
+ }
+ p, err := goose.NewProvider(goose.DialectPostgres, sqldb, fsys, opts...)
+ if err != nil {
return err
}
- return goose.UpContext(ctx, sqldb, "migrations")
+ _, err = p.Up(ctx)
+ return err
}
diff --git a/internal/db/db_test.go b/internal/db/db_test.go
new file mode 100644
index 0000000..b4fa3da
--- /dev/null
+++ b/internal/db/db_test.go
@@ -0,0 +1,25 @@
+package db
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestDBName(t *testing.T) {
+ for sub, want := range map[string]string{"alice": "blog_alice", "my-blog": "blog_my_blog", "a-b-c": "blog_a_b_c", "www": "blog_www"} {
+ if got := DBName(sub); got != want || !dbNameRe.MatchString(got) {
+ t.Errorf("DBName(%q) = %q, want %q", sub, got, want)
+ }
+ }
+ // the longest subdomain the admin form accepts still fits a Postgres identifier
+ if n := len(DBName(strings.Repeat("a-", 29))); n > 63 { // 58 chars, the admin form's limit
+ t.Errorf("database name too long: %d", n)
+ }
+}
+
+func TestWithDatabase(t *testing.T) {
+ got := withDatabase("postgres://u:p@db:5432/blogspace?sslmode=disable", "blog_alice")
+ if want := "postgres://u:p@db:5432/blog_alice?sslmode=disable"; got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+}
diff --git a/internal/db/migrations/blog/00001_init.sql b/internal/db/migrations/blog/00001_init.sql
new file mode 100644
index 0000000..4649935
--- /dev/null
+++ b/internal/db/migrations/blog/00001_init.sql
@@ -0,0 +1,91 @@
+-- +goose Up
+-- One database per blog: nothing here carries a blog id, the database is the scope.
+CREATE TABLE settings (
+ id boolean PRIMARY KEY DEFAULT true CHECK (id), -- exactly one row
+ 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,
+ slug text NOT NULL UNIQUE,
+ title text NOT NULL,
+ intro_md text NOT NULL DEFAULT '',
+ intro_html text NOT NULL DEFAULT '',
+ nav_order integer NOT NULL DEFAULT 0,
+ is_home boolean NOT NULL DEFAULT false,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE UNIQUE INDEX pages_one_home ON pages ((true)) 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,
+ 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_created ON images (created_at DESC);
+
+-- Announcements: blog-wide notices shown on every page and post.
+CREATE TABLE sections (
+ id bigserial PRIMARY KEY,
+ title text NOT NULL DEFAULT '',
+ body_md text NOT NULL DEFAULT '',
+ body_html text NOT NULL DEFAULT '',
+ placement text NOT NULL DEFAULT 'main-top'
+ CHECK (placement IN ('left-top', 'left-bottom', 'main-top', 'main-bottom', 'right-top', 'right-bottom')),
+ style text NOT NULL DEFAULT 'note' CHECK (style IN ('plain', 'note', 'warning')),
+ enabled boolean NOT NULL DEFAULT true,
+ sort_order integer NOT NULL DEFAULT 0,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX sections_order ON sections (sort_order, id);
+
+-- Layout modules: what each area of a blog (header, columns, footer) shows.
+CREATE TABLE modules (
+ id bigserial PRIMARY KEY,
+ area text NOT NULL CHECK (area IN ('header', 'left', 'right', 'above', 'below', 'footer')),
+ kind text NOT NULL CHECK (kind IN ('title', 'logo', 'menu', 'archive', 'recent', 'html', 'rss', 'text', 'sitemap')),
+ title text NOT NULL DEFAULT '',
+ body text NOT NULL DEFAULT '',
+ count integer NOT NULL DEFAULT 5,
+ sort_order integer NOT NULL DEFAULT 0,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX modules_area ON modules (area, sort_order, id);
+
+-- The menu: blog pages and custom links in one ordered list.
+CREATE TABLE menu_items (
+ id bigserial PRIMARY KEY,
+ page_id bigint REFERENCES pages(id) ON DELETE CASCADE,
+ label text NOT NULL DEFAULT '',
+ url text NOT NULL DEFAULT '',
+ sort_order integer NOT NULL DEFAULT 0,
+ CHECK (page_id IS NOT NULL OR url <> '')
+);
+CREATE UNIQUE INDEX menu_items_page ON menu_items (page_id) WHERE page_id IS NOT NULL;
+CREATE INDEX menu_items_order ON menu_items (sort_order, id);
+
+-- +goose Down
+DROP TABLE menu_items, modules, sections, images, posts, pages, settings;
diff --git a/internal/db/migrations/00001_init.sql b/internal/db/migrations/control/00001_init.sql
index e27dad4..e27dad4 100644
--- a/internal/db/migrations/00001_init.sql
+++ b/internal/db/migrations/control/00001_init.sql
diff --git a/internal/db/migrations/00002_sections.sql b/internal/db/migrations/control/00002_sections.sql
index 388cb17..388cb17 100644
--- a/internal/db/migrations/00002_sections.sql
+++ b/internal/db/migrations/control/00002_sections.sql
diff --git a/internal/db/migrations/00003_layout.sql b/internal/db/migrations/control/00003_layout.sql
index e8fff23..e8fff23 100644
--- a/internal/db/migrations/00003_layout.sql
+++ b/internal/db/migrations/control/00003_layout.sql
diff --git a/internal/db/migrations/00004_section_placement.sql b/internal/db/migrations/control/00004_section_placement.sql
index c36787e..c36787e 100644
--- a/internal/db/migrations/00004_section_placement.sql
+++ b/internal/db/migrations/control/00004_section_placement.sql
diff --git a/internal/db/migrations/control/00005_per_blog_databases.sql b/internal/db/migrations/control/00005_per_blog_databases.sql
new file mode 100644
index 0000000..92d3fa2
--- /dev/null
+++ b/internal/db/migrations/control/00005_per_blog_databases.sql
@@ -0,0 +1,12 @@
+-- +goose Up
+-- Each blog gets its own database; the registry remembers which one.
+-- db_name stays nullable until the Go migration 00006 has moved the content.
+ALTER TABLE blogs ADD COLUMN db_name text UNIQUE;
+-- "blog_" + subdomain must fit in a 63-char Postgres identifier.
+ALTER TABLE blogs DROP CONSTRAINT blogs_subdomain_check;
+ALTER TABLE blogs ADD CONSTRAINT blogs_subdomain_check CHECK (subdomain ~ '^[a-z0-9](-?[a-z0-9]){0,57}$');
+
+-- +goose Down
+ALTER TABLE blogs DROP CONSTRAINT blogs_subdomain_check;
+ALTER TABLE blogs ADD CONSTRAINT blogs_subdomain_check CHECK (subdomain ~ '^[a-z0-9](-?[a-z0-9]){0,62}$');
+ALTER TABLE blogs DROP COLUMN db_name;
diff --git a/internal/db/migrations/control/00007_drop_content.sql b/internal/db/migrations/control/00007_drop_content.sql
new file mode 100644
index 0000000..9daa1ac
--- /dev/null
+++ b/internal/db/migrations/control/00007_drop_content.sql
@@ -0,0 +1,9 @@
+-- +goose Up
+-- The content now lives in the per-blog databases (see 00006 in split.go);
+-- the control database keeps only users and the blog registry.
+DROP TABLE menu_items, modules, sections, images, posts, pages;
+ALTER TABLE blogs DROP COLUMN title, DROP COLUMN tagline, DROP COLUMN theme, DROP COLUMN updated_at;
+ALTER TABLE blogs ALTER COLUMN db_name SET NOT NULL;
+
+-- +goose Down
+-- Not reversible: the content is gone from this database. Restore from a backup instead.
diff --git a/internal/db/split.go b/internal/db/split.go
new file mode 100644
index 0000000..668d255
--- /dev/null
+++ b/internal/db/split.go
@@ -0,0 +1,152 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "log"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/pressly/goose/v3"
+)
+
+// splitMigration is control migration 00006: it moves every blog's content out
+// of the control database into a database of its own. It runs in the control
+// transaction, so either every blog is moved and marked with its db_name or
+// nothing changes; the blog databases it created are then dropped by hand
+// (the error says which) and the next start retries. 00007 drops the old
+// tables afterwards.
+func splitMigration(controlURL string) *goose.Migration {
+ up := func(ctx context.Context, tx *sql.Tx) error {
+ return splitBlogs(ctx, tx, controlURL)
+ }
+ down := func(ctx context.Context, tx *sql.Tx) error {
+ return errors.New("the per-blog split cannot be undone; restore the control database from a backup")
+ }
+ return goose.NewGoMigration(6, &goose.GoFunc{RunTx: up}, &goose.GoFunc{RunTx: down})
+}
+
+// splitTable is one content table to copy: the query selects the blog's rows
+// in the column order of the new table.
+type splitTable struct {
+ name string
+ cols []string
+ query string // $1 = blog id
+ seq bool // bigserial id to bump after the copy
+}
+
+var splitTables = []splitTable{
+ {"pages", []string{"id", "slug", "title", "intro_md", "intro_html", "nav_order", "is_home", "created_at"},
+ `SELECT id, slug, title, intro_md, intro_html, nav_order, is_home, created_at FROM pages WHERE blog_id=$1`, true},
+ {"posts", []string{"id", "page_id", "slug", "title", "body_md", "body_html", "published", "created_at", "updated_at"},
+ `SELECT p.id, p.page_id, p.slug, p.title, p.body_md, p.body_html, p.published, p.created_at, p.updated_at
+ FROM posts p JOIN pages g ON g.id=p.page_id WHERE g.blog_id=$1`, true},
+ {"images", []string{"id", "filename", "content_type", "size", "data", "created_at"},
+ `SELECT id, filename, content_type, size, data, created_at FROM images WHERE blog_id=$1`, false},
+ {"sections", []string{"id", "title", "body_md", "body_html", "placement", "style", "enabled", "sort_order", "created_at", "updated_at"},
+ `SELECT id, title, body_md, body_html, placement, style, enabled, sort_order, created_at, updated_at FROM sections WHERE blog_id=$1`, true},
+ {"modules", []string{"id", "area", "kind", "title", "body", "count", "sort_order", "created_at", "updated_at"},
+ `SELECT id, area, kind, title, body, count, sort_order, created_at, updated_at FROM modules WHERE blog_id=$1`, true},
+ {"menu_items", []string{"id", "page_id", "label", "url", "sort_order"},
+ `SELECT id, page_id, label, url, sort_order FROM menu_items WHERE blog_id=$1`, true},
+}
+
+func splitBlogs(ctx context.Context, tx *sql.Tx, controlURL string) error {
+ type blog struct {
+ id int64
+ sub, title, tagline string
+ theme []byte
+ createdAt, updatedAt any
+ }
+ rows, err := tx.QueryContext(ctx, `SELECT id, subdomain, title, tagline, theme, created_at, updated_at FROM blogs WHERE db_name IS NULL ORDER BY id`)
+ if err != nil {
+ return err
+ }
+ var blogs []blog
+ for rows.Next() {
+ var b blog
+ if err := rows.Scan(&b.id, &b.sub, &b.title, &b.tagline, &b.theme, &b.createdAt, &b.updatedAt); err != nil {
+ rows.Close()
+ return err
+ }
+ blogs = append(blogs, b)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ return err
+ }
+ if len(blogs) == 0 {
+ return nil
+ }
+
+ // DDL needs its own connection: CREATE DATABASE refuses to run in a transaction.
+ admin, err := pgx.Connect(ctx, controlURL)
+ if err != nil {
+ return err
+ }
+ defer admin.Close(ctx)
+ var created []string
+ for _, b := range blogs {
+ name := DBName(b.sub)
+ log.Printf("moving blog %q into database %s", b.sub, name)
+ if err := createDatabase(ctx, admin, name); err != nil {
+ return fmt.Errorf("%w — a leftover of an earlier failed split must be dropped by hand (created so far: %v)", err, created)
+ }
+ created = append(created, name)
+ if err := MigrateBlog(ctx, withDatabase(controlURL, name)); err != nil {
+ return fmt.Errorf("migrate %s: %w (drop the databases %v before retrying)", name, err, created)
+ }
+ if err := copyBlog(ctx, tx, withDatabase(controlURL, name), b.id, b.title, b.tagline, b.theme, b.createdAt, b.updatedAt); err != nil {
+ return fmt.Errorf("copy blog %q: %w (drop the databases %v before retrying)", b.sub, err, created)
+ }
+ if _, err := tx.ExecContext(ctx, `UPDATE blogs SET db_name=$1 WHERE id=$2`, name, b.id); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// copyBlog streams one blog's rows from the control transaction into its new database.
+func copyBlog(ctx context.Context, tx *sql.Tx, blogURL string, blogID int64, title, tagline string, theme []byte, createdAt, updatedAt any) error {
+ conn, err := pgx.Connect(ctx, blogURL)
+ if err != nil {
+ return err
+ }
+ defer conn.Close(ctx)
+ if _, err := conn.Exec(ctx, `INSERT INTO settings (title, tagline, theme, created_at, updated_at) VALUES ($1,$2,$3,$4,$5)`,
+ title, tagline, theme, createdAt, updatedAt); err != nil {
+ return fmt.Errorf("settings: %w", err)
+ }
+ for _, t := range splitTables {
+ rows, err := tx.QueryContext(ctx, t.query, blogID)
+ if err != nil {
+ return fmt.Errorf("%s: %w", t.name, err)
+ }
+ n := len(t.cols)
+ src := pgx.CopyFromFunc(func() ([]any, error) {
+ if !rows.Next() {
+ return nil, rows.Err()
+ }
+ vals := make([]any, n)
+ ptrs := make([]any, n)
+ for i := range vals {
+ ptrs[i] = &vals[i]
+ }
+ // database/sql hands back int64/string/bool/[]byte/time.Time,
+ // which pgx encodes for the matching column types.
+ return vals, rows.Scan(ptrs...)
+ })
+ _, err = conn.CopyFrom(ctx, pgx.Identifier{t.name}, t.cols, src)
+ rows.Close()
+ if err != nil {
+ return fmt.Errorf("%s: %w", t.name, err)
+ }
+ if t.seq {
+ if _, err := conn.Exec(ctx, `SELECT setval(pg_get_serial_sequence($1,'id'), coalesce(max(id),0)+1, false) FROM `+pgx.Identifier{t.name}.Sanitize(), t.name); err != nil {
+ return fmt.Errorf("%s sequence: %w", t.name, err)
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/store/blogs.go b/internal/store/blogs.go
index b7edee0..a2e88a8 100644
--- a/internal/store/blogs.go
+++ b/internal/store/blogs.go
@@ -3,34 +3,52 @@ package store
import (
"context"
"encoding/json"
+ "errors"
+ "fmt"
"time"
+ "github.com/gramanas/blogspace/internal/db"
"github.com/jackc/pgx/v5"
)
+// Blog is a registry row (control database) plus the settings row of the
+// blog's own database, which Store.Open fills in.
type Blog struct {
ID int64
OwnerID int64
Subdomain string
+ DBName string
+ CreatedAt time.Time
+ // from the blog database
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`
+const blogCols = `id, owner_id, subdomain, db_name, created_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)
+ err := row.Scan(&b.ID, &b.OwnerID, &b.Subdomain, &b.DBName, &b.CreatedAt)
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 (bs *BlogStore) loadSettings(ctx context.Context, b *Blog) error {
+ err := bs.db.QueryRow(ctx, `SELECT title, tagline, theme, updated_at FROM settings`).Scan(&b.Title, &b.Tagline, &b.ThemeJSON, &b.UpdatedAt)
+ if errors.Is(err, pgx.ErrNoRows) { // registered, but the database is empty: not a 404
+ return fmt.Errorf("database %s of blog %q has no settings row (empty or wrong dump?)", b.DBName, b.Subdomain)
+ }
+ if err != nil {
+ return fmt.Errorf("settings of %s: %w", b.DBName, err)
+ }
+ return nil
+}
+
+// CreateBlogger creates a user, their blog and a default home page.
func (s *Store) CreateBlogger(ctx context.Context, username, passwordHash, subdomain, title string) (*User, *Blog, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
@@ -43,13 +61,10 @@ func (s *Store) CreateBlogger(ctx context.Context, username, passwordHash, subdo
if err != nil {
return nil, nil, err
}
- b, err := createBlog(ctx, tx, u.ID, subdomain, title)
+ b, err := s.createBlog(ctx, tx, u.ID, subdomain, title)
if err != nil {
return nil, nil, err
}
- if err := tx.Commit(ctx); err != nil {
- return nil, nil, err
- }
return u, b, nil
}
@@ -60,30 +75,64 @@ func (s *Store) CreateBlog(ctx context.Context, ownerID int64, subdomain, title
return nil, err
}
defer tx.Rollback(ctx)
- b, err := createBlog(ctx, tx, ownerID, subdomain, title)
+ return s.createBlog(ctx, tx, ownerID, subdomain, title)
+}
+
+// createBlog registers the blog in tx, then creates and fills its database and
+// commits. The registry insert goes first so a taken subdomain fails before
+// any DDL; if anything after that fails the transaction rolls back and the new
+// database is dropped again.
+func (s *Store) createBlog(ctx context.Context, tx pgx.Tx, ownerID int64, subdomain, title string) (*Blog, error) {
+ name := db.DBName(subdomain)
+ b, err := scanBlog(tx.QueryRow(ctx, `INSERT INTO blogs (owner_id, subdomain, db_name) VALUES ($1,$2,$3) RETURNING `+blogCols,
+ ownerID, subdomain, name))
+ if err != nil {
+ return nil, err
+ }
+ if err := s.cluster.CreateBlogDB(ctx, name); err != nil {
+ return nil, err
+ }
+ bs, err := s.fillNewBlog(ctx, b, title)
+ if err == nil {
+ err = tx.Commit(ctx)
+ }
if err != nil {
+ s.cluster.DropBlogDB(ctx, name)
return nil, err
}
- return b, tx.Commit(ctx)
+ if err := bs.loadSettings(ctx, b); err != nil {
+ return nil, err
+ }
+ return b, nil
}
-func createBlog(ctx context.Context, tx pgx.Tx, ownerID int64, subdomain, title string) (*Blog, error) {
- b, err := scanBlog(tx.QueryRow(ctx, `INSERT INTO blogs (owner_id, subdomain, title) VALUES ($1,$2,$3) RETURNING `+blogCols,
- ownerID, subdomain, title))
+// fillNewBlog writes what every new blog starts with: its settings, a home
+// page in the menu and the default layout.
+func (s *Store) fillNewBlog(ctx context.Context, b *Blog, title string) (*BlogStore, error) {
+ pool, err := s.cluster.Blog(ctx, b.DBName)
if err != nil {
return nil, err
}
+ bs := &BlogStore{db: pool}
+ btx, err := pool.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer btx.Rollback(ctx)
+ if _, err := btx.Exec(ctx, `INSERT INTO settings (title, created_at, updated_at) VALUES ($1,$2,$2)`, title, b.CreatedAt); err != nil {
+ return nil, err
+ }
var homeID int64
- if err := tx.QueryRow(ctx, `INSERT INTO pages (blog_id, slug, title, nav_order, is_home) VALUES ($1,'home','Home',0,true) RETURNING id`, b.ID).Scan(&homeID); err != nil {
+ if err := btx.QueryRow(ctx, `INSERT INTO pages (slug, title, nav_order, is_home) VALUES ('home','Home',0,true) RETURNING id`).Scan(&homeID); err != nil {
return nil, wrap(err)
}
- if err := addMenuPage(ctx, tx, b.ID, homeID); err != nil {
+ if err := addMenuPage(ctx, btx, homeID); err != nil {
return nil, err
}
- if err := insertDefaultModules(ctx, tx, b.ID); err != nil {
+ if err := insertDefaultModules(ctx, btx); err != nil {
return nil, err
}
- return b, nil
+ return bs, btx.Commit(ctx)
}
func (s *Store) BlogByID(ctx context.Context, id int64) (*Blog, error) {
@@ -98,17 +147,38 @@ 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
+// ListBlogs returns every registered blog, oldest first.
+func (s *Store) ListBlogs(ctx context.Context) ([]Blog, error) {
+ rows, err := s.db.Query(ctx, `SELECT `+blogCols+` FROM blogs ORDER BY id`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Blog
+ for rows.Next() {
+ b, err := scanBlog(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *b)
+ }
+ return out, rows.Err()
+}
+
+// DeleteBlog unregisters the blog and drops its database.
+func (s *Store) DeleteBlog(ctx context.Context, b *Blog) error {
+ if _, err := s.db.Exec(ctx, `DELETE FROM blogs WHERE id=$1`, b.ID); err != nil {
+ return err
+ }
+ return s.cluster.DropBlogDB(ctx, b.DBName)
}
-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)
+func (bs *BlogStore) UpdateSettings(ctx context.Context, title, tagline string) error {
+ _, err := bs.db.Exec(ctx, `UPDATE settings SET title=$1, tagline=$2, updated_at=now()`, title, tagline)
return err
}
-func (s *Store) DeleteBlog(ctx context.Context, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM blogs WHERE id=$1`, id)
+func (bs *BlogStore) UpdateTheme(ctx context.Context, theme json.RawMessage) error {
+ _, err := bs.db.Exec(ctx, `UPDATE settings SET theme=$1, updated_at=now()`, theme)
return err
}
diff --git a/internal/store/images.go b/internal/store/images.go
index 3699e75..b222468 100644
--- a/internal/store/images.go
+++ b/internal/store/images.go
@@ -9,7 +9,6 @@ import (
type Image struct {
ID uuid.UUID
- BlogID int64
Filename string
ContentType string
Size int
@@ -17,15 +16,15 @@ type Image struct {
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)
+func (bs *BlogStore) CreateImage(ctx context.Context, filename, contentType string, data []byte) (*Image, error) {
+ img := &Image{ID: uuid.New(), Filename: filename, ContentType: contentType, Size: len(data)}
+ err := bs.db.QueryRow(ctx, `INSERT INTO images (id, filename, content_type, size, data) VALUES ($1,$2,$3,$4,$5) RETURNING created_at`,
+ img.ID, 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)
+func (bs *BlogStore) ListImages(ctx context.Context) ([]Image, error) {
+ rows, err := bs.db.Query(ctx, `SELECT id, filename, content_type, size, created_at FROM images ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
@@ -33,7 +32,7 @@ func (s *Store) ListImages(ctx context.Context, blogID int64) ([]Image, error) {
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 {
+ if err := rows.Scan(&i.ID, &i.Filename, &i.ContentType, &i.Size, &i.CreatedAt); err != nil {
return nil, err
}
out = append(out, i)
@@ -42,17 +41,17 @@ func (s *Store) ListImages(ctx context.Context, blogID int64) ([]Image, error) {
}
// ImageData loads an image including its bytes.
-func (s *Store) ImageData(ctx context.Context, id uuid.UUID) (*Image, error) {
+func (bs *BlogStore) 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)
+ err := bs.db.QueryRow(ctx, `SELECT id, filename, content_type, size, data, created_at FROM images WHERE id=$1`, id).
+ Scan(&i.ID, &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)
+func (bs *BlogStore) DeleteImage(ctx context.Context, id uuid.UUID) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM images WHERE id=$1`, id)
return err
}
diff --git a/internal/store/menu.go b/internal/store/menu.go
index 2d3eabd..cedb7ad 100644
--- a/internal/store/menu.go
+++ b/internal/store/menu.go
@@ -2,14 +2,11 @@ package store
import (
"context"
-
- "github.com/jackc/pgx/v5/pgconn"
)
// MenuItem is one entry of a blog's menu: either a page of the blog or a custom link.
type MenuItem struct {
ID int64
- BlogID int64
PageID *int64 // nil for custom links
Label string // custom links only; pages use their title
URL string // custom links only
@@ -39,11 +36,11 @@ func (m MenuItem) Href() string {
return "/" + m.PageSlug
}
-const menuCols = `m.id, m.blog_id, m.page_id, m.label, m.url, m.sort_order, coalesce(p.title,''), coalesce(p.slug,''), coalesce(p.is_home,false)`
+const menuCols = `m.id, m.page_id, m.label, m.url, m.sort_order, coalesce(p.title,''), coalesce(p.slug,''), coalesce(p.is_home,false)`
-func (s *Store) ListMenu(ctx context.Context, blogID int64) ([]MenuItem, error) {
- rows, err := s.db.Query(ctx, `SELECT `+menuCols+` FROM menu_items m LEFT JOIN pages p ON p.id=m.page_id
- WHERE m.blog_id=$1 ORDER BY m.sort_order, m.id`, blogID)
+func (bs *BlogStore) ListMenu(ctx context.Context) ([]MenuItem, error) {
+ rows, err := bs.db.Query(ctx, `SELECT `+menuCols+` FROM menu_items m LEFT JOIN pages p ON p.id=m.page_id
+ ORDER BY m.sort_order, m.id`)
if err != nil {
return nil, err
}
@@ -51,7 +48,7 @@ func (s *Store) ListMenu(ctx context.Context, blogID int64) ([]MenuItem, error)
var out []MenuItem
for rows.Next() {
var m MenuItem
- if err := rows.Scan(&m.ID, &m.BlogID, &m.PageID, &m.Label, &m.URL, &m.SortOrder, &m.PageTitle, &m.PageSlug, &m.IsHome); err != nil {
+ if err := rows.Scan(&m.ID, &m.PageID, &m.Label, &m.URL, &m.SortOrder, &m.PageTitle, &m.PageSlug, &m.IsHome); err != nil {
return nil, err
}
out = append(out, m)
@@ -60,41 +57,41 @@ func (s *Store) ListMenu(ctx context.Context, blogID int64) ([]MenuItem, error)
}
// AddMenuPage appends a page to the menu; a page already in the menu is left where it is.
-func (s *Store) AddMenuPage(ctx context.Context, blogID, pageID int64) error {
- return addMenuPage(ctx, s.db, blogID, pageID)
+func (bs *BlogStore) AddMenuPage(ctx context.Context, pageID int64) error {
+ return addMenuPage(ctx, bs.db, pageID)
}
-func addMenuPage(ctx context.Context, db querier, blogID, pageID int64) error {
- _, err := db.Exec(ctx, `INSERT INTO menu_items (blog_id, page_id, sort_order)
- SELECT $1, id, (SELECT coalesce(max(sort_order),-1)+1 FROM menu_items WHERE blog_id=$1)
- FROM pages WHERE blog_id=$1 AND id=$2 ON CONFLICT (page_id) WHERE page_id IS NOT NULL DO NOTHING`, blogID, pageID)
+func addMenuPage(ctx context.Context, db querier, pageID int64) error {
+ _, err := db.Exec(ctx, `INSERT INTO menu_items (page_id, sort_order)
+ SELECT id, (SELECT coalesce(max(sort_order),-1)+1 FROM menu_items)
+ FROM pages WHERE id=$1 ON CONFLICT (page_id) WHERE page_id IS NOT NULL DO NOTHING`, pageID)
return err
}
// SetPageInMenu adds or removes a page's menu entry.
-func (s *Store) SetPageInMenu(ctx context.Context, blogID, pageID int64, on bool) error {
+func (bs *BlogStore) SetPageInMenu(ctx context.Context, pageID int64, on bool) error {
if on {
- return s.AddMenuPage(ctx, blogID, pageID)
+ return bs.AddMenuPage(ctx, pageID)
}
- _, err := s.db.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND page_id=$2`, blogID, pageID)
+ _, err := bs.db.Exec(ctx, `DELETE FROM menu_items WHERE page_id=$1`, pageID)
return err
}
// AddMenuLink appends a custom link to the menu.
-func (s *Store) AddMenuLink(ctx context.Context, blogID int64, label, url string) error {
- _, err := s.db.Exec(ctx, `INSERT INTO menu_items (blog_id, label, url, sort_order)
- VALUES ($1,$2,$3,(SELECT coalesce(max(sort_order),-1)+1 FROM menu_items WHERE blog_id=$1))`, blogID, label, url)
+func (bs *BlogStore) AddMenuLink(ctx context.Context, label, url string) error {
+ _, err := bs.db.Exec(ctx, `INSERT INTO menu_items (label, url, sort_order)
+ VALUES ($1,$2,(SELECT coalesce(max(sort_order),-1)+1 FROM menu_items))`, label, url)
return err
}
-func (s *Store) DeleteMenuItem(ctx context.Context, blogID, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND id=$2`, blogID, id)
+func (bs *BlogStore) DeleteMenuItem(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM menu_items WHERE id=$1`, id)
return err
}
// MoveMenuItem swaps sort_order with the neighbouring entry (dir = -1 up, +1 down).
-func (s *Store) MoveMenuItem(ctx context.Context, blogID, id int64, dir int) error {
- items, err := s.ListMenu(ctx, blogID)
+func (bs *BlogStore) MoveMenuItem(ctx context.Context, id int64, dir int) error {
+ items, err := bs.ListMenu(ctx)
if err != nil {
return err
}
@@ -109,20 +106,15 @@ func (s *Store) MoveMenuItem(ctx context.Context, blogID, id int64, dir int) err
return nil
}
items[idx], items[j] = items[j], items[idx]
- tx, err := s.db.Begin(ctx)
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for i, m := range items { // renumber everything; keeps orders dense
- if _, err := tx.Exec(ctx, `UPDATE menu_items SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, m.ID, i); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE menu_items SET sort_order=$2 WHERE id=$1`, m.ID, i); err != nil {
return err
}
}
return tx.Commit(ctx)
}
-
-// querier is what the helpers shared with page transactions need: the pool or a pgx.Tx.
-type querier interface {
- Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
-}
diff --git a/internal/store/modules.go b/internal/store/modules.go
index c7d9760..e0c3cd5 100644
--- a/internal/store/modules.go
+++ b/internal/store/modules.go
@@ -9,7 +9,6 @@ import (
// block of custom HTML… placed in one of the areas of the page.
type Module struct {
ID int64
- BlogID int64
Area string // header | left | right | above | below | footer
Kind string // title | logo | menu | archive | recent | html | rss | text | sitemap
Title string // optional heading (side columns and footer)
@@ -20,20 +19,20 @@ type Module struct {
UpdatedAt time.Time
}
-const moduleCols = `id, blog_id, area, kind, title, body, count, sort_order, created_at, updated_at`
+const moduleCols = `id, area, kind, title, body, count, sort_order, created_at, updated_at`
func scanModule(row interface{ Scan(...any) error }) (*Module, error) {
var m Module
- err := row.Scan(&m.ID, &m.BlogID, &m.Area, &m.Kind, &m.Title, &m.Body, &m.Count, &m.SortOrder, &m.CreatedAt, &m.UpdatedAt)
+ err := row.Scan(&m.ID, &m.Area, &m.Kind, &m.Title, &m.Body, &m.Count, &m.SortOrder, &m.CreatedAt, &m.UpdatedAt)
if err != nil {
return nil, wrap(err)
}
return &m, nil
}
-// ListModules returns every module of a blog, grouped by area in display order.
-func (s *Store) ListModules(ctx context.Context, blogID int64) ([]Module, error) {
- rows, err := s.db.Query(ctx, `SELECT `+moduleCols+` FROM modules WHERE blog_id=$1 ORDER BY area, sort_order, id`, blogID)
+// ListModules returns every module of the blog, grouped by area in display order.
+func (bs *BlogStore) ListModules(ctx context.Context) ([]Module, error) {
+ rows, err := bs.db.Query(ctx, `SELECT `+moduleCols+` FROM modules ORDER BY area, sort_order, id`)
if err != nil {
return nil, err
}
@@ -49,36 +48,36 @@ func (s *Store) ListModules(ctx context.Context, blogID int64) ([]Module, error)
return out, rows.Err()
}
-func (s *Store) ModuleByID(ctx context.Context, blogID, id int64) (*Module, error) {
- return scanModule(s.db.QueryRow(ctx, `SELECT `+moduleCols+` FROM modules WHERE blog_id=$1 AND id=$2`, blogID, id))
+func (bs *BlogStore) ModuleByID(ctx context.Context, id int64) (*Module, error) {
+ return scanModule(bs.db.QueryRow(ctx, `SELECT `+moduleCols+` FROM modules WHERE id=$1`, id))
}
// CreateModule appends a module to the end of its area.
-func (s *Store) CreateModule(ctx context.Context, m *Module) (*Module, error) {
- return scanModule(s.db.QueryRow(ctx, `INSERT INTO modules (blog_id, area, kind, title, body, count, sort_order)
- VALUES ($1,$2,$3,$4,$5,$6,(SELECT coalesce(max(sort_order),-1)+1 FROM modules WHERE blog_id=$1 AND area=$2)) RETURNING `+moduleCols,
- m.BlogID, m.Area, m.Kind, m.Title, m.Body, m.Count))
+func (bs *BlogStore) CreateModule(ctx context.Context, m *Module) (*Module, error) {
+ return scanModule(bs.db.QueryRow(ctx, `INSERT INTO modules (area, kind, title, body, count, sort_order)
+ VALUES ($1,$2,$3,$4,$5,(SELECT coalesce(max(sort_order),-1)+1 FROM modules WHERE area=$1)) RETURNING `+moduleCols,
+ m.Area, m.Kind, m.Title, m.Body, m.Count))
}
// UpdateModule saves the editable settings; area and kind never change.
-func (s *Store) UpdateModule(ctx context.Context, m *Module) error {
- _, err := s.db.Exec(ctx, `UPDATE modules SET title=$3, body=$4, count=$5, updated_at=now() WHERE blog_id=$1 AND id=$2`,
- m.BlogID, m.ID, m.Title, m.Body, m.Count)
+func (bs *BlogStore) UpdateModule(ctx context.Context, m *Module) error {
+ _, err := bs.db.Exec(ctx, `UPDATE modules SET title=$2, body=$3, count=$4, updated_at=now() WHERE id=$1`,
+ m.ID, m.Title, m.Body, m.Count)
return wrap(err)
}
-func (s *Store) DeleteModule(ctx context.Context, blogID, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM modules WHERE blog_id=$1 AND id=$2`, blogID, id)
+func (bs *BlogStore) DeleteModule(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM modules WHERE id=$1`, id)
return err
}
// MoveModule swaps sort_order with the neighbouring module of the same area (dir = -1 up, +1 down).
-func (s *Store) MoveModule(ctx context.Context, blogID, id int64, dir int) error {
- cur, err := s.ModuleByID(ctx, blogID, id)
+func (bs *BlogStore) MoveModule(ctx context.Context, id int64, dir int) error {
+ cur, err := bs.ModuleByID(ctx, id)
if err != nil {
return err
}
- all, err := s.ListModules(ctx, blogID)
+ all, err := bs.ListModules(ctx)
if err != nil {
return err
}
@@ -98,13 +97,13 @@ func (s *Store) MoveModule(ctx context.Context, blogID, id int64, dir int) error
return nil
}
mods[idx], mods[j] = mods[j], mods[idx]
- tx, err := s.db.Begin(ctx)
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for i, m := range mods { // renumber the area; keeps orders dense
- if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, m.ID, i); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order=$2 WHERE id=$1`, m.ID, i); err != nil {
return err
}
}
@@ -112,16 +111,16 @@ func (s *Store) MoveModule(ctx context.Context, blogID, id int64, dir int) error
}
// ResetModules puts the layout back to what a new blog starts with.
-func (s *Store) ResetModules(ctx context.Context, blogID int64) error {
- tx, err := s.db.Begin(ctx)
+func (bs *BlogStore) ResetModules(ctx context.Context) error {
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
- if _, err := tx.Exec(ctx, `DELETE FROM modules WHERE blog_id=$1`, blogID); err != nil {
+ if _, err := tx.Exec(ctx, `DELETE FROM modules`); err != nil {
return err
}
- if err := insertDefaultModules(ctx, tx, blogID); err != nil {
+ if err := insertDefaultModules(ctx, tx); err != nil {
return err
}
return tx.Commit(ctx)
@@ -129,8 +128,8 @@ func (s *Store) ResetModules(ctx context.Context, blogID int64) error {
// insertDefaultModules is the starting layout: title and menu in the header,
// an RSS link in the footer. Must match what the layout migration gave old blogs.
-func insertDefaultModules(ctx context.Context, tx querier, blogID int64) error {
- _, err := tx.Exec(ctx, `INSERT INTO modules (blog_id, area, kind, sort_order) VALUES
- ($1,'header','title',0), ($1,'header','menu',1), ($1,'footer','rss',0)`, blogID)
+func insertDefaultModules(ctx context.Context, tx querier) error {
+ _, err := tx.Exec(ctx, `INSERT INTO modules (area, kind, sort_order) VALUES
+ ('header','title',0), ('header','menu',1), ('footer','rss',0)`)
return err
}
diff --git a/internal/store/pages.go b/internal/store/pages.go
index cd0e652..55cad33 100644
--- a/internal/store/pages.go
+++ b/internal/store/pages.go
@@ -7,7 +7,6 @@ import (
type Page struct {
ID int64
- BlogID int64
Slug string
Title string
IntroMD string
@@ -19,20 +18,20 @@ type Page struct {
PostCount int // filled by ListPages only
}
-const pageCols = `id, blog_id, slug, title, intro_md, intro_html, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), is_home, created_at`
+const pageCols = `id, slug, title, intro_md, intro_html, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), 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)
+ err := row.Scan(&p.ID, &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)
+func (bs *BlogStore) ListPages(ctx context.Context) ([]Page, error) {
+ rows, err := bs.db.Query(ctx, `SELECT `+pageCols+`, (SELECT count(*) FROM posts WHERE page_id=pages.id)
+ FROM pages ORDER BY nav_order, id`)
if err != nil {
return nil, err
}
@@ -40,7 +39,7 @@ func (s *Store) ListPages(ctx context.Context, blogID int64) ([]Page, error) {
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 {
+ if err := rows.Scan(&p.ID, &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)
@@ -48,34 +47,34 @@ func (s *Store) ListPages(ctx context.Context, blogID int64) ([]Page, error) {
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 (bs *BlogStore) PageByID(ctx context.Context, id int64) (*Page, error) {
+ return scanPage(bs.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE id=$1`, 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 (bs *BlogStore) PageBySlug(ctx context.Context, slug string) (*Page, error) {
+ return scanPage(bs.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE slug=$1`, 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 (bs *BlogStore) HomePage(ctx context.Context) (*Page, error) {
+ return scanPage(bs.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages ORDER BY is_home DESC, nav_order, id LIMIT 1`))
}
// CreatePage adds a page at the end of the page list and, when ShowInNav is
// set, to the end of the menu.
-func (s *Store) CreatePage(ctx context.Context, p *Page) (*Page, error) {
- tx, err := s.db.Begin(ctx)
+func (bs *BlogStore) CreatePage(ctx context.Context, p *Page) (*Page, error) {
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
- created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (blog_id, slug, title, intro_md, intro_html, nav_order)
- VALUES ($1,$2,$3,$4,$5,(SELECT coalesce(max(nav_order),-1)+1 FROM pages WHERE blog_id=$1)) RETURNING `+pageCols,
- p.BlogID, p.Slug, p.Title, p.IntroMD, p.IntroHTML))
+ created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (slug, title, intro_md, intro_html, nav_order)
+ VALUES ($1,$2,$3,$4,(SELECT coalesce(max(nav_order),-1)+1 FROM pages)) RETURNING `+pageCols,
+ p.Slug, p.Title, p.IntroMD, p.IntroHTML))
if err != nil {
return nil, err
}
if p.ShowInNav {
- if err := addMenuPage(ctx, tx, p.BlogID, created.ID); err != nil {
+ if err := addMenuPage(ctx, tx, created.ID); err != nil {
return nil, err
}
created.ShowInNav = true
@@ -84,20 +83,20 @@ func (s *Store) CreatePage(ctx context.Context, p *Page) (*Page, error) {
}
// UpdatePage saves the page and keeps its menu entry in step with ShowInNav.
-func (s *Store) UpdatePage(ctx context.Context, p *Page) error {
- tx, err := s.db.Begin(ctx)
+func (bs *BlogStore) UpdatePage(ctx context.Context, p *Page) error {
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
- if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$3, title=$4, intro_md=$5, intro_html=$6 WHERE blog_id=$1 AND id=$2`,
- p.BlogID, p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$2, title=$3, intro_md=$4, intro_html=$5 WHERE id=$1`,
+ p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML); err != nil {
return wrap(err)
}
if p.ShowInNav {
- err = addMenuPage(ctx, tx, p.BlogID, p.ID)
+ err = addMenuPage(ctx, tx, p.ID)
} else {
- _, err = tx.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND page_id=$2`, p.BlogID, p.ID)
+ _, err = tx.Exec(ctx, `DELETE FROM menu_items WHERE page_id=$1`, p.ID)
}
if err != nil {
return err
@@ -105,30 +104,30 @@ func (s *Store) UpdatePage(ctx context.Context, p *Page) error {
return tx.Commit(ctx)
}
-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)
+func (bs *BlogStore) DeletePage(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM pages WHERE id=$1 AND NOT is_home`, 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)
+func (bs *BlogStore) SetHomePage(ctx context.Context, id int64) error {
+ tx, err := bs.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 {
+ if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=false WHERE is_home`); 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 {
+ if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=true WHERE id=$1`, 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)
+func (bs *BlogStore) MovePage(ctx context.Context, id int64, dir int) error {
+ pages, err := bs.ListPages(ctx)
if err != nil {
return err
}
@@ -143,13 +142,13 @@ func (s *Store) MovePage(ctx context.Context, blogID, id int64, dir int) error {
return nil
}
pages[idx], pages[j] = pages[j], pages[idx]
- tx, err := s.db.Begin(ctx)
+ tx, err := bs.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 {
+ if _, err := tx.Exec(ctx, `UPDATE pages SET nav_order=$2 WHERE id=$1`, p.ID, i); err != nil {
return err
}
}
diff --git a/internal/store/posts.go b/internal/store/posts.go
index 9f3c4f9..94bc856 100644
--- a/internal/store/posts.go
+++ b/internal/store/posts.go
@@ -31,8 +31,8 @@ func scanPost(row interface{ Scan(...any) error }) (*Post, error) {
return &p, nil
}
-func (s *Store) collectPosts(ctx context.Context, q string, args ...any) ([]Post, error) {
- rows, err := s.db.Query(ctx, q, args...)
+func (bs *BlogStore) collectPosts(ctx context.Context, q string, args ...any) ([]Post, error) {
+ rows, err := bs.db.Query(ctx, q, args...)
if err != nil {
return nil, err
}
@@ -48,21 +48,21 @@ func (s *Store) collectPosts(ctx context.Context, q string, args ...any) ([]Post
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)
+// ListPosts returns all posts of the blog for the dashboard, optionally filtered by page.
+func (bs *BlogStore) ListPosts(ctx context.Context, pageID int64) ([]Post, error) {
+ return bs.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE ($1=0 OR p.page_id=$1) ORDER BY p.created_at DESC, p.id DESC`, 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
+func (bs *BlogStore) PublishedPosts(ctx context.Context, pageID int64, limit, offset int) ([]Post, int, error) {
+ posts, err := bs.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)
+ err = bs.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE page_id=$1 AND published`, pageID).Scan(&total)
return posts, total, err
}
@@ -74,10 +74,10 @@ type PostRef struct {
CreatedAt time.Time
}
-// PublishedPostIndex lists every published post of a blog, newest first, without bodies.
-func (s *Store) PublishedPostIndex(ctx context.Context, blogID int64) ([]PostRef, error) {
- rows, err := s.db.Query(ctx, `SELECT p.title, p.slug, g.slug, p.created_at 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`, blogID)
+// PublishedPostIndex lists every published post of the blog, newest first, without bodies.
+func (bs *BlogStore) PublishedPostIndex(ctx context.Context) ([]PostRef, error) {
+ rows, err := bs.db.Query(ctx, `SELECT p.title, p.slug, g.slug, p.created_at FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE p.published ORDER BY p.created_at DESC, p.id DESC`)
if err != nil {
return nil, err
}
@@ -93,40 +93,38 @@ func (s *Store) PublishedPostIndex(ctx context.Context, blogID int64) ([]PostRef
return out, rows.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)
+// RecentPublishedPosts returns the newest published posts across the whole blog (for feeds).
+func (bs *BlogStore) RecentPublishedPosts(ctx context.Context, limit int) ([]Post, error) {
+ return bs.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $1`, 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 (bs *BlogStore) PostByID(ctx context.Context, id int64) (*Post, error) {
+ return scanPost(bs.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id WHERE p.id=$1`, 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
+func (bs *BlogStore) PublishedPostBySlug(ctx context.Context, pageID int64, slug string) (*Post, error) {
+ return scanPost(bs.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) {
+func (bs *BlogStore) 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`,
+ err := bs.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))
+ return bs.PostByID(ctx, 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`,
+func (bs *BlogStore) UpdatePost(ctx context.Context, p *Post) error {
+ _, err := bs.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)
+func (bs *BlogStore) DeletePost(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM posts WHERE id=$1`, id)
return err
}
diff --git a/internal/store/sections.go b/internal/store/sections.go
index caac251..2f742f7 100644
--- a/internal/store/sections.go
+++ b/internal/store/sections.go
@@ -9,7 +9,6 @@ import (
// Section is an announcement shown on every page and post of a blog.
type Section struct {
ID int64
- BlogID int64
Title string
BodyMD string
BodyHTML string
@@ -25,19 +24,19 @@ type Section struct {
func (s Section) Column() string { c, _, _ := strings.Cut(s.Placement, "-"); return c }
func (s Section) Position() string { _, p, _ := strings.Cut(s.Placement, "-"); return p }
-const sectionCols = `id, blog_id, title, body_md, body_html, placement, style, enabled, sort_order, created_at, updated_at`
+const sectionCols = `id, title, body_md, body_html, placement, style, enabled, sort_order, created_at, updated_at`
func scanSection(row interface{ Scan(...any) error }) (*Section, error) {
var s Section
- err := row.Scan(&s.ID, &s.BlogID, &s.Title, &s.BodyMD, &s.BodyHTML, &s.Placement, &s.Style, &s.Enabled, &s.SortOrder, &s.CreatedAt, &s.UpdatedAt)
+ err := row.Scan(&s.ID, &s.Title, &s.BodyMD, &s.BodyHTML, &s.Placement, &s.Style, &s.Enabled, &s.SortOrder, &s.CreatedAt, &s.UpdatedAt)
if err != nil {
return nil, wrap(err)
}
return &s, nil
}
-func (s *Store) collectSections(ctx context.Context, q string, args ...any) ([]Section, error) {
- rows, err := s.db.Query(ctx, q, args...)
+func (bs *BlogStore) collectSections(ctx context.Context, q string, args ...any) ([]Section, error) {
+ rows, err := bs.db.Query(ctx, q, args...)
if err != nil {
return nil, err
}
@@ -53,44 +52,44 @@ func (s *Store) collectSections(ctx context.Context, q string, args ...any) ([]S
return out, rows.Err()
}
-func (s *Store) ListSections(ctx context.Context, blogID int64) ([]Section, error) {
- return s.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 ORDER BY sort_order, id`, blogID)
+func (bs *BlogStore) ListSections(ctx context.Context) ([]Section, error) {
+ return bs.collectSections(ctx, `SELECT `+sectionCols+` FROM sections ORDER BY sort_order, id`)
}
// EnabledSections is what the public blog renders.
-func (s *Store) EnabledSections(ctx context.Context, blogID int64) ([]Section, error) {
- return s.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 AND enabled ORDER BY sort_order, id`, blogID)
+func (bs *BlogStore) EnabledSections(ctx context.Context) ([]Section, error) {
+ return bs.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE enabled ORDER BY sort_order, id`)
}
-func (s *Store) SectionByID(ctx context.Context, blogID, id int64) (*Section, error) {
- return scanSection(s.db.QueryRow(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 AND id=$2`, blogID, id))
+func (bs *BlogStore) SectionByID(ctx context.Context, id int64) (*Section, error) {
+ return scanSection(bs.db.QueryRow(ctx, `SELECT `+sectionCols+` FROM sections WHERE id=$1`, id))
}
-func (s *Store) CreateSection(ctx context.Context, sec *Section) (*Section, error) {
- return scanSection(s.db.QueryRow(ctx, `INSERT INTO sections (blog_id, title, body_md, body_html, placement, style, enabled, sort_order)
- VALUES ($1,$2,$3,$4,$5,$6,$7,(SELECT coalesce(max(sort_order),-1)+1 FROM sections WHERE blog_id=$1)) RETURNING `+sectionCols,
- sec.BlogID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled))
+func (bs *BlogStore) CreateSection(ctx context.Context, sec *Section) (*Section, error) {
+ return scanSection(bs.db.QueryRow(ctx, `INSERT INTO sections (title, body_md, body_html, placement, style, enabled, sort_order)
+ VALUES ($1,$2,$3,$4,$5,$6,(SELECT coalesce(max(sort_order),-1)+1 FROM sections)) RETURNING `+sectionCols,
+ sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled))
}
-func (s *Store) UpdateSection(ctx context.Context, sec *Section) error {
- _, err := s.db.Exec(ctx, `UPDATE sections SET title=$3, body_md=$4, body_html=$5, placement=$6, style=$7, enabled=$8, updated_at=now() WHERE blog_id=$1 AND id=$2`,
- sec.BlogID, sec.ID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled)
+func (bs *BlogStore) UpdateSection(ctx context.Context, sec *Section) error {
+ _, err := bs.db.Exec(ctx, `UPDATE sections SET title=$2, body_md=$3, body_html=$4, placement=$5, style=$6, enabled=$7, updated_at=now() WHERE id=$1`,
+ sec.ID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled)
return wrap(err)
}
-func (s *Store) SetSectionEnabled(ctx context.Context, blogID, id int64, on bool) error {
- _, err := s.db.Exec(ctx, `UPDATE sections SET enabled=$3, updated_at=now() WHERE blog_id=$1 AND id=$2`, blogID, id, on)
+func (bs *BlogStore) SetSectionEnabled(ctx context.Context, id int64, on bool) error {
+ _, err := bs.db.Exec(ctx, `UPDATE sections SET enabled=$2, updated_at=now() WHERE id=$1`, id, on)
return err
}
-func (s *Store) DeleteSection(ctx context.Context, blogID, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM sections WHERE blog_id=$1 AND id=$2`, blogID, id)
+func (bs *BlogStore) DeleteSection(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM sections WHERE id=$1`, id)
return err
}
// MoveSection swaps sort_order with the neighbouring section (dir = -1 up, +1 down).
-func (s *Store) MoveSection(ctx context.Context, blogID, id int64, dir int) error {
- secs, err := s.ListSections(ctx, blogID)
+func (bs *BlogStore) MoveSection(ctx context.Context, id int64, dir int) error {
+ secs, err := bs.ListSections(ctx)
if err != nil {
return err
}
@@ -105,13 +104,13 @@ func (s *Store) MoveSection(ctx context.Context, blogID, id int64, dir int) erro
return nil
}
secs[idx], secs[j] = secs[j], secs[idx]
- tx, err := s.db.Begin(ctx)
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for i, sec := range secs { // renumber everything; keeps orders dense
- if _, err := tx.Exec(ctx, `UPDATE sections SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, sec.ID, i); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE sections SET sort_order=$2 WHERE id=$1`, sec.ID, i); err != nil {
return err
}
}
diff --git a/internal/store/store.go b/internal/store/store.go
index 472bb2a..c8e97ea 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -1,9 +1,15 @@
// Package store holds the data models and all SQL queries.
+//
+// Store talks to the control database (users, blog registry) and hands out a
+// BlogStore per blog, which is bound to that blog's own database — nothing a
+// BlogStore does can reach another blog's rows.
package store
import (
+ "context"
"errors"
+ "github.com/gramanas/blogspace/internal/db"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
@@ -13,10 +19,36 @@ var ErrNotFound = errors.New("not found")
var ErrConflict = errors.New("already exists")
type Store struct {
+ db *pgxpool.Pool // control database
+ cluster *db.Cluster
+}
+
+func New(cluster *db.Cluster) *Store { return &Store{db: cluster.Control(), cluster: cluster} }
+
+// BlogStore runs the content queries of one blog against its database.
+type BlogStore struct {
db *pgxpool.Pool
}
-func New(db *pgxpool.Pool) *Store { return &Store{db: db} }
+// Open returns the blog's store and fills in the settings (title, tagline,
+// theme) kept in the blog database.
+func (s *Store) Open(ctx context.Context, b *Blog) (*BlogStore, error) {
+ pool, err := s.cluster.Blog(ctx, b.DBName)
+ if err != nil {
+ return nil, err
+ }
+ bs := &BlogStore{db: pool}
+ if err := bs.loadSettings(ctx, b); err != nil {
+ // A database restored underneath a running app (dropdb --force,
+ // createdb, psql < dump) leaves the pool holding dead connections;
+ // drop them and try once more before giving up.
+ pool.Reset()
+ if err := bs.loadSettings(ctx, b); err != nil {
+ return nil, err
+ }
+ }
+ return bs, nil
+}
// wrap maps driver errors onto the store's sentinel errors.
func wrap(err error) error {
@@ -32,3 +64,8 @@ func wrap(err error) error {
}
return err
}
+
+// querier is what the helpers shared with transactions need: the pool or a pgx.Tx.
+type querier interface {
+ Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
+}
diff --git a/internal/store/users.go b/internal/store/users.go
index 3ea9bdb..0c47c24 100644
--- a/internal/store/users.go
+++ b/internal/store/users.go
@@ -2,6 +2,7 @@ package store
import (
"context"
+ "errors"
"time"
)
@@ -69,9 +70,21 @@ func (s *Store) SetUserDisabled(ctx context.Context, id int64, disabled bool) er
return err
}
+// DeleteUser removes the user, their registry row and their blog database.
+// The registry goes first: a stale blog database is harmless and can be
+// dropped by hand, a registry row without a database is not.
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM users WHERE id=$1`, id)
- return err
+ b, err := s.BlogByOwner(ctx, id)
+ if err != nil && !errors.Is(err, ErrNotFound) {
+ return err
+ }
+ if _, err := s.db.Exec(ctx, `DELETE FROM users WHERE id=$1`, id); err != nil {
+ return err
+ }
+ if b != nil {
+ return s.cluster.DropBlogDB(ctx, b.DBName)
+ }
+ return nil
}
// UserWithBlog is a row for the admin overview.
@@ -79,12 +92,11 @@ 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
+ b.id, b.subdomain
FROM users u LEFT JOIN blogs b ON b.owner_id = u.id
ORDER BY u.role, u.username`)
if err != nil {
@@ -95,7 +107,7 @@ func (s *Store) ListUsers(ctx context.Context) ([]UserWithBlog, error) {
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 {
+ &r.BlogID, &r.Subdomain); err != nil {
return nil, err
}
out = append(out, r)
diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go
index 4287411..e0b3991 100644
--- a/internal/web/handlers_admin.go
+++ b/internal/web/handlers_admin.go
@@ -9,11 +9,14 @@ import (
"github.com/gramanas/blogspace/internal/auth"
"github.com/gramanas/blogspace/internal/config"
+ "github.com/gramanas/blogspace/internal/db"
"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}$`)
+
+// 58 chars at most: "blog_" + subdomain must fit a Postgres database name.
+var subdomainRe = regexp.MustCompile(`^[a-z0-9](-?[a-z0-9]){0,57}$`)
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
users, err := s.st.ListUsers(r.Context())
@@ -70,6 +73,11 @@ func (s *Server) handleAdminNewUser(w http.ResponseWriter, r *http.Request) {
s.renderStatus(w, r, http.StatusConflict, "admin/new_user.html", f)
return
}
+ if errors.Is(err, db.ErrDatabaseExists) {
+ f["error"] = "A database " + db.DBName(sub) + " already exists in Postgres (a leftover of a deleted blog?). Drop it or pick another subdomain."
+ s.renderStatus(w, r, http.StatusConflict, "admin/new_user.html", f)
+ return
+ }
s.serverError(w, err)
return
}
@@ -151,7 +159,7 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
s.plainError(w, http.StatusBadRequest, "This user owns the root blog and cannot be deleted.")
return
}
- if err := s.st.DeleteUser(r.Context(), u.ID); err != nil { // cascades to blog, pages, posts, images
+ if err := s.st.DeleteUser(r.Context(), u.ID); err != nil { // drops their blog database too
s.serverError(w, err)
return
}
diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go
index 42ec45b..f2e7713 100644
--- a/internal/web/handlers_blog.go
+++ b/internal/web/handlers_blog.go
@@ -17,19 +17,19 @@ const postsPerPage = 10
func (s *Server) blogView(r *http.Request) (map[string]any, error) {
blog := currentBlog(r)
ctx := r.Context()
- pages, err := s.st.ListPages(ctx, blog.ID)
+ pages, err := blogStore(r).ListPages(ctx)
if err != nil {
return nil, err
}
- menu, err := s.st.ListMenu(ctx, blog.ID)
+ menu, err := blogStore(r).ListMenu(ctx)
if err != nil {
return nil, err
}
- mods, err := s.st.ListModules(ctx, blog.ID)
+ mods, err := blogStore(r).ListModules(ctx)
if err != nil {
return nil, err
}
- sections, err := s.st.EnabledSections(ctx, blog.ID)
+ sections, err := blogStore(r).EnabledSections(ctx)
if err != nil {
return nil, err
}
@@ -38,14 +38,14 @@ func (s *Server) blogView(r *http.Request) (map[string]any, error) {
v := map[string]any{"theme": theme, "css": theme.CSS(), "layout": layout, "menu": menu, "pages": pages}
// Module data is only fetched when a visible module shows it.
if layout.NeedsArchive() {
- index, err := s.st.PublishedPostIndex(ctx, blog.ID)
+ index, err := blogStore(r).PublishedPostIndex(ctx)
if err != nil {
return nil, err
}
v["archive"] = groupArchive(index)
}
if n := layout.MaxRecent(); n > 0 {
- recent, err := s.st.RecentPublishedPosts(ctx, blog.ID, n)
+ recent, err := blogStore(r).RecentPublishedPosts(ctx, n)
if err != nil {
return nil, err
}
@@ -56,7 +56,7 @@ func (s *Server) blogView(r *http.Request) (map[string]any, error) {
}
func (s *Server) handleBlogHome(w http.ResponseWriter, r *http.Request) {
- page, err := s.st.HomePage(r.Context(), currentBlog(r).ID)
+ page, err := blogStore(r).HomePage(r.Context())
if err != nil {
s.blogNotFound(w, r)
return
@@ -65,7 +65,7 @@ func (s *Server) handleBlogHome(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleBlogPage(w http.ResponseWriter, r *http.Request) {
- page, err := s.st.PageBySlug(r.Context(), currentBlog(r).ID, r.PathValue("page"))
+ page, err := blogStore(r).PageBySlug(r.Context(), r.PathValue("page"))
if err != nil {
s.blogNotFound(w, r)
return
@@ -87,7 +87,7 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store.
if n < 1 {
n = 1
}
- posts, total, err := s.st.PublishedPosts(r.Context(), page.ID, postsPerPage, (n-1)*postsPerPage)
+ posts, total, err := blogStore(r).PublishedPosts(r.Context(), page.ID, postsPerPage, (n-1)*postsPerPage)
if err != nil {
s.serverError(w, err)
return
@@ -102,13 +102,12 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store.
}
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"))
+ page, err := blogStore(r).PageBySlug(r.Context(), r.PathValue("page"))
if err != nil {
s.blogNotFound(w, r)
return
}
- post, err := s.st.PublishedPostBySlug(r.Context(), page.ID, r.PathValue("post"))
+ post, err := blogStore(r).PublishedPostBySlug(r.Context(), page.ID, r.PathValue("post"))
if err != nil {
s.blogNotFound(w, r)
return
@@ -154,7 +153,7 @@ type rssItem struct {
func (s *Server) handleBlogFeed(w http.ResponseWriter, r *http.Request) {
blog := currentBlog(r)
- posts, err := s.st.RecentPublishedPosts(r.Context(), blog.ID, 30)
+ posts, err := blogStore(r).RecentPublishedPosts(r.Context(), 30)
if err != nil {
s.serverError(w, err)
return
diff --git a/internal/web/handlers_dashboard.go b/internal/web/handlers_dashboard.go
index f210891..a1e0d89 100644
--- a/internal/web/handlers_dashboard.go
+++ b/internal/web/handlers_dashboard.go
@@ -6,13 +6,12 @@ import (
)
func (s *Server) handleBlogOverview(w http.ResponseWriter, r *http.Request) {
- blog := currentBlog(r)
- pages, err := s.st.ListPages(r.Context(), blog.ID)
+ pages, err := blogStore(r).ListPages(r.Context())
if err != nil {
s.serverError(w, err)
return
}
- posts, err := s.st.ListPosts(r.Context(), blog.ID, 0)
+ posts, err := blogStore(r).ListPosts(r.Context(), 0)
if err != nil {
s.serverError(w, err)
return
@@ -35,7 +34,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
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 {
+ if err := blogStore(r).UpdateSettings(r.Context(), title, tagline); err != nil {
s.serverError(w, err)
return
}
diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go
index 1e18001..9f98e9e 100644
--- a/internal/web/handlers_design.go
+++ b/internal/web/handlers_design.go
@@ -20,7 +20,7 @@ var allowedImageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "
func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) {
blog := currentBlog(r)
- images, err := s.st.ListImages(r.Context(), blog.ID)
+ images, err := blogStore(r).ListImages(r.Context())
if err != nil {
s.serverError(w, err)
return
@@ -39,7 +39,7 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
for field, dst := range map[string]*string{"bg_image_file": &theme.BgImage, "header_image_file": &theme.HeaderImage, "logo_file": &theme.Logo, "favicon_file": &theme.Favicon} {
img, err := s.readUpload(r, field)
if err != nil {
- images, _ := s.st.ListImages(r.Context(), blog.ID)
+ images, _ := blogStore(r).ListImages(r.Context())
s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", map[string]any{"theme": theme, "images": images, "presets": Presets(), "error": err.Error()})
return
}
@@ -47,7 +47,7 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
*dst = img.ID.String()
}
}
- if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil {
+ if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil {
s.serverError(w, err)
return
}
@@ -64,7 +64,7 @@ func (s *Server) handleDesignPreset(w http.ResponseWriter, r *http.Request) {
return
}
theme := ParseTheme(blog.ThemeJSON).WithPreset(p)
- if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil {
+ if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil {
s.serverError(w, err)
return
}
@@ -75,11 +75,11 @@ func (s *Server) handleDesignPreset(w http.ResponseWriter, r *http.Request) {
// stay in the library and the menu is left alone.
func (s *Server) handleDesignReset(w http.ResponseWriter, r *http.Request) {
blog := currentBlog(r)
- if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, DefaultTheme().JSON()); err != nil {
+ if err := blogStore(r).UpdateTheme(r.Context(), DefaultTheme().JSON()); err != nil {
s.serverError(w, err)
return
}
- if err := s.st.ResetModules(r.Context(), blog.ID); err != nil {
+ if err := blogStore(r).ResetModules(r.Context()); err != nil {
s.serverError(w, err)
return
}
@@ -122,7 +122,7 @@ func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader) (*store.
if name == "" || name == "." || len(name) > 120 {
name = "image"
}
- return s.st.CreateImage(r.Context(), currentBlog(r).ID, name, ct, buf.Bytes())
+ return blogStore(r).CreateImage(r.Context(), name, ct, buf.Bytes())
}
// imageMarkdown is the line the editor inserts for an uploaded image.
@@ -153,7 +153,7 @@ func kbString(n int64) string {
// ---- image library ---------------------------------------------------------
func (s *Server) handleImages(w http.ResponseWriter, r *http.Request) {
- images, err := s.st.ListImages(r.Context(), currentBlog(r).ID)
+ images, err := blogStore(r).ListImages(r.Context())
if err != nil {
s.serverError(w, err)
return
@@ -207,7 +207,7 @@ func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
- if err := s.st.DeleteImage(r.Context(), blog.ID, id); err != nil {
+ if err := blogStore(r).DeleteImage(r.Context(), id); err != nil {
s.serverError(w, err)
return
}
@@ -227,7 +227,7 @@ func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) {
theme.Logo, changed = "", true
}
if changed {
- if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil {
+ if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil {
s.serverError(w, err)
return
}
diff --git a/internal/web/handlers_layout.go b/internal/web/handlers_layout.go
index b22c1b5..1046a19 100644
--- a/internal/web/handlers_layout.go
+++ b/internal/web/handlers_layout.go
@@ -25,15 +25,15 @@ func (s *Server) layoutURL(r *http.Request) string {
// layoutData is everything layout.html needs; it is rebuilt after a failed form too.
func (s *Server) layoutData(r *http.Request) (map[string]any, error) {
blog := currentBlog(r)
- mods, err := s.st.ListModules(r.Context(), blog.ID)
+ mods, err := blogStore(r).ListModules(r.Context())
if err != nil {
return nil, err
}
- menu, err := s.st.ListMenu(r.Context(), blog.ID)
+ menu, err := blogStore(r).ListMenu(r.Context())
if err != nil {
return nil, err
}
- pages, err := s.st.ListPages(r.Context(), blog.ID)
+ pages, err := blogStore(r).ListPages(r.Context())
if err != nil {
return nil, err
}
@@ -63,7 +63,7 @@ func (s *Server) handleLayout(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleLayoutWidths(w http.ResponseWriter, r *http.Request) {
blog := currentBlog(r)
theme := LayoutFromForm(ParseTheme(blog.ThemeJSON), r.Form)
- if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil {
+ if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil {
s.serverError(w, err)
return
}
@@ -81,7 +81,7 @@ func (s *Server) handleAreaToggle(w http.ResponseWriter, r *http.Request) {
theme := ParseTheme(blog.ThemeJSON)
on := !theme.AreaOn(area)
theme.SetAreaOn(area, on)
- if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil {
+ if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil {
s.serverError(w, err)
return
}
@@ -93,14 +93,13 @@ func (s *Server) handleAreaToggle(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) {
- blog := currentBlog(r)
area, kind := r.PathValue("area"), r.FormValue("kind")
if !allowedKind(area, kind) {
s.plainError(w, http.StatusBadRequest, "That module cannot go in that area.")
return
}
if singleModuleAreas[area] {
- mods, err := s.st.ListModules(r.Context(), blog.ID)
+ mods, err := blogStore(r).ListModules(r.Context())
if err != nil {
s.serverError(w, err)
return
@@ -112,7 +111,7 @@ func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) {
}
}
}
- m := &store.Module{BlogID: blog.ID, Area: area, Kind: kind, Count: 5}
+ m := &store.Module{Area: area, Kind: kind, Count: 5}
switch kind { // sensible headings so the module reads well before it is edited
case "archive":
m.Title = "Archive"
@@ -121,7 +120,7 @@ func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) {
case "sitemap":
m.Title = "Pages"
}
- m, err := s.st.CreateModule(r.Context(), m)
+ m, err := blogStore(r).CreateModule(r.Context(), m)
if err != nil {
s.serverError(w, err)
return
@@ -143,7 +142,7 @@ func anchorFor(area string) string {
func (s *Server) loadModule(w http.ResponseWriter, r *http.Request) *store.Module {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
- m, err := s.st.ModuleByID(r.Context(), currentBlog(r).ID, id)
+ m, err := blogStore(r).ModuleByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
@@ -196,7 +195,7 @@ func (s *Server) handleModuleSave(w http.ResponseWriter, r *http.Request) {
fail("Content is too long (20 KB max).")
return
}
- if err := s.st.UpdateModule(r.Context(), m); err != nil {
+ if err := blogStore(r).UpdateModule(r.Context(), m); err != nil {
s.serverError(w, err)
return
}
@@ -212,7 +211,7 @@ func (s *Server) handleModuleMove(w http.ResponseWriter, r *http.Request) {
if r.FormValue("dir") == "up" {
dir = -1
}
- if err := s.st.MoveModule(r.Context(), m.BlogID, m.ID, dir); err != nil {
+ if err := blogStore(r).MoveModule(r.Context(), m.ID, dir); err != nil {
s.serverError(w, err)
return
}
@@ -240,7 +239,7 @@ func (s *Server) handleModuleDelete(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) deleteModule(w http.ResponseWriter, r *http.Request, m *store.Module) {
- if err := s.st.DeleteModule(r.Context(), m.BlogID, m.ID); err != nil {
+ if err := blogStore(r).DeleteModule(r.Context(), m.ID); err != nil {
s.serverError(w, err)
return
}
@@ -250,13 +249,12 @@ func (s *Server) deleteModule(w http.ResponseWriter, r *http.Request, m *store.M
// ---- menu --------------------------------------------------------------------
func (s *Server) handleMenuAddPage(w http.ResponseWriter, r *http.Request) {
- blog := currentBlog(r)
pageID, _ := strconv.ParseInt(r.FormValue("page"), 10, 64)
if pageID == 0 {
redirectOK(w, r, s.layoutURL(r)+"#menu", "Pick a page first.")
return
}
- if err := s.st.AddMenuPage(r.Context(), blog.ID, pageID); err != nil {
+ if err := blogStore(r).AddMenuPage(r.Context(), pageID); err != nil {
s.serverError(w, err)
return
}
@@ -264,7 +262,6 @@ func (s *Server) handleMenuAddPage(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleMenuAddLink(w http.ResponseWriter, r *http.Request) {
- blog := currentBlog(r)
label, link := strings.TrimSpace(r.FormValue("label")), strings.TrimSpace(r.FormValue("url"))
if link != "" && !strings.HasPrefix(link, "/") && !strings.Contains(link, ":") {
link = "https://" + link // "example.org" is what people type
@@ -277,7 +274,7 @@ func (s *Server) handleMenuAddLink(w http.ResponseWriter, r *http.Request) {
redirectOK(w, r, s.layoutURL(r)+"#menu", "That address does not look right. Use a full web address like https://example.org or a path like /about.")
return
}
- if err := s.st.AddMenuLink(r.Context(), blog.ID, label, link); err != nil {
+ if err := blogStore(r).AddMenuLink(r.Context(), label, link); err != nil {
s.serverError(w, err)
return
}
@@ -290,7 +287,7 @@ func (s *Server) handleMenuMove(w http.ResponseWriter, r *http.Request) {
if r.FormValue("dir") == "up" {
dir = -1
}
- if err := s.st.MoveMenuItem(r.Context(), currentBlog(r).ID, id, dir); err != nil {
+ if err := blogStore(r).MoveMenuItem(r.Context(), id, dir); err != nil {
s.serverError(w, err)
return
}
@@ -299,7 +296,7 @@ func (s *Server) handleMenuMove(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleMenuDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
- if err := s.st.DeleteMenuItem(r.Context(), currentBlog(r).ID, id); err != nil {
+ if err := blogStore(r).DeleteMenuItem(r.Context(), id); err != nil {
s.serverError(w, err)
return
}
diff --git a/internal/web/handlers_media.go b/internal/web/handlers_media.go
index 9d5a515..4e237fa 100644
--- a/internal/web/handlers_media.go
+++ b/internal/web/handlers_media.go
@@ -22,7 +22,7 @@ func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotModified)
return
}
- img, err := s.st.ImageData(r.Context(), id)
+ img, err := blogStore(r).ImageData(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go
index a226953..09257a3 100644
--- a/internal/web/handlers_pages.go
+++ b/internal/web/handlers_pages.go
@@ -16,7 +16,7 @@ var reservedPageSlugs = map[string]bool{"media": true, "static": true, "feed.xml
"webadmin": true, "logout": true, "dashboard": true, "account": true, "admin": true, "b": true, "healthz": true}
func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) {
- pages, err := s.st.ListPages(r.Context(), currentBlog(r).ID)
+ pages, err := blogStore(r).ListPages(r.Context())
if err != nil {
s.serverError(w, err)
return
@@ -27,7 +27,7 @@ func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) {
// 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)
+ p, err := blogStore(r).PageByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
@@ -51,7 +51,7 @@ func (s *Server) handlePageForm(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) {
blog := currentBlog(r)
- p := &store.Page{BlogID: blog.ID}
+ p := &store.Page{}
if r.PathValue("id") != "" {
if p = s.loadPage(w, r); p == nil {
return
@@ -86,11 +86,11 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) {
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 {
+ if created, err = blogStore(r).CreatePage(r.Context(), p); err == nil {
p = created
}
} else {
- err = s.st.UpdatePage(r.Context(), p)
+ err = blogStore(r).UpdatePage(r.Context(), p)
}
if !errors.Is(err, store.ErrConflict) || !autoSlug || n >= 50 {
break
@@ -129,7 +129,7 @@ func (s *Server) handlePageDelete(w http.ResponseWriter, r *http.Request) {
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 {
+ if err := blogStore(r).DeletePage(r.Context(), p.ID); err != nil {
s.serverError(w, err)
return
}
@@ -145,7 +145,7 @@ func (s *Server) handlePageMove(w http.ResponseWriter, r *http.Request) {
if r.FormValue("dir") == "up" {
dir = -1
}
- if err := s.st.MovePage(r.Context(), p.BlogID, p.ID, dir); err != nil {
+ if err := blogStore(r).MovePage(r.Context(), p.ID, dir); err != nil {
s.serverError(w, err)
return
}
@@ -157,7 +157,7 @@ func (s *Server) handlePageHome(w http.ResponseWriter, r *http.Request) {
if p == nil {
return
}
- if err := s.st.SetHomePage(r.Context(), p.BlogID, p.ID); err != nil {
+ if err := blogStore(r).SetHomePage(r.Context(), p.ID); err != nil {
s.serverError(w, err)
return
}
diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go
index 136b7fe..02a3fd7 100644
--- a/internal/web/handlers_posts.go
+++ b/internal/web/handlers_posts.go
@@ -12,14 +12,13 @@ import (
)
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)
+ pages, err := blogStore(r).ListPages(r.Context())
if err != nil {
s.serverError(w, err)
return
}
- posts, err := s.st.ListPosts(r.Context(), blog.ID, pageID)
+ posts, err := blogStore(r).ListPosts(r.Context(), pageID)
if err != nil {
s.serverError(w, err)
return
@@ -29,7 +28,7 @@ func (s *Server) handlePosts(w http.ResponseWriter, r *http.Request) {
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)
+ p, err := blogStore(r).PostByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
@@ -42,8 +41,7 @@ func (s *Server) loadPost(w http.ResponseWriter, r *http.Request) *store.Post {
}
func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) {
- blog := currentBlog(r)
- pages, err := s.st.ListPages(r.Context(), blog.ID)
+ pages, err := blogStore(r).ListPages(r.Context())
if err != nil {
s.serverError(w, err)
return
@@ -55,7 +53,7 @@ func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) {
}
} 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 {
+ } else if hp, err := blogStore(r).HomePage(r.Context()); err == nil {
p.PageID = hp.ID
}
s.render(w, r, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages})
@@ -63,7 +61,7 @@ func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) {
blog := currentBlog(r)
- pages, err := s.st.ListPages(r.Context(), blog.ID)
+ pages, err := blogStore(r).ListPages(r.Context())
if err != nil {
s.serverError(w, err)
return
@@ -118,11 +116,11 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) {
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 {
+ if created, err = blogStore(r).CreatePost(r.Context(), p); err == nil {
p = created
}
} else {
- err = s.st.UpdatePost(r.Context(), p)
+ err = blogStore(r).UpdatePost(r.Context(), p)
}
if !errors.Is(err, store.ErrConflict) || !autoSlug || n >= 50 {
break
@@ -156,7 +154,7 @@ func (s *Server) handlePostDelete(w http.ResponseWriter, r *http.Request) {
if p == nil {
return
}
- if err := s.st.DeletePost(r.Context(), currentBlog(r).ID, p.ID); err != nil {
+ if err := blogStore(r).DeletePost(r.Context(), p.ID); err != nil {
s.serverError(w, err)
return
}
diff --git a/internal/web/handlers_sections.go b/internal/web/handlers_sections.go
index f5e2e59..1239545 100644
--- a/internal/web/handlers_sections.go
+++ b/internal/web/handlers_sections.go
@@ -19,7 +19,7 @@ func (s *Server) sectionsURL(r *http.Request) string {
}
func (s *Server) handleSections(w http.ResponseWriter, r *http.Request) {
- secs, err := s.st.ListSections(r.Context(), currentBlog(r).ID)
+ secs, err := blogStore(r).ListSections(r.Context())
if err != nil {
s.serverError(w, err)
return
@@ -29,7 +29,7 @@ func (s *Server) handleSections(w http.ResponseWriter, r *http.Request) {
func (s *Server) loadSection(w http.ResponseWriter, r *http.Request) *store.Section {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
- sec, err := s.st.SectionByID(r.Context(), currentBlog(r).ID, id)
+ sec, err := blogStore(r).SectionByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
@@ -58,8 +58,7 @@ func normalizeSection(sec *store.Section) {
}
func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) {
- blog := currentBlog(r)
- sec := &store.Section{BlogID: blog.ID}
+ sec := &store.Section{}
if r.PathValue("id") != "" {
if sec = s.loadSection(w, r); sec == nil {
return
@@ -92,9 +91,9 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) {
sec.BodyMD = appendImageMD(sec.BodyMD, img)
sec.BodyHTML = markdown.Render(sec.BodyMD)
if sec.ID == 0 {
- sec, err = s.st.CreateSection(r.Context(), sec)
+ sec, err = blogStore(r).CreateSection(r.Context(), sec)
} else {
- err = s.st.UpdateSection(r.Context(), sec)
+ err = blogStore(r).UpdateSection(r.Context(), sec)
}
if err != nil {
s.serverError(w, err)
@@ -120,7 +119,7 @@ func (s *Server) handleSectionDelete(w http.ResponseWriter, r *http.Request) {
if sec == nil {
return
}
- if err := s.st.DeleteSection(r.Context(), sec.BlogID, sec.ID); err != nil {
+ if err := blogStore(r).DeleteSection(r.Context(), sec.ID); err != nil {
s.serverError(w, err)
return
}
@@ -136,7 +135,7 @@ func (s *Server) handleSectionMove(w http.ResponseWriter, r *http.Request) {
if r.FormValue("dir") == "up" {
dir = -1
}
- if err := s.st.MoveSection(r.Context(), sec.BlogID, sec.ID, dir); err != nil {
+ if err := blogStore(r).MoveSection(r.Context(), sec.ID, dir); err != nil {
s.serverError(w, err)
return
}
@@ -149,7 +148,7 @@ func (s *Server) handleSectionToggle(w http.ResponseWriter, r *http.Request) {
if sec == nil {
return
}
- if err := s.st.SetSectionEnabled(r.Context(), sec.BlogID, sec.ID, !sec.Enabled); err != nil {
+ if err := blogStore(r).SetSectionEnabled(r.Context(), sec.ID, !sec.Enabled); err != nil {
s.serverError(w, err)
return
}
diff --git a/internal/web/routes.go b/internal/web/routes.go
index 4b3d14f..36ae892 100644
--- a/internal/web/routes.go
+++ b/internal/web/routes.go
@@ -66,6 +66,8 @@ func (s *Server) rootRoutes() http.Handler {
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))
+ // Images live in the blog's database, so dashboard previews on this host go through /b/.
+ m.HandleFunc("GET /b/{sub}/media/{id}", s.withBlog(s.handleMedia))
// superadmin
m.HandleFunc("GET /admin/{$}", s.requireAdmin(s.handleAdmin))
@@ -77,7 +79,7 @@ func (s *Server) rootRoutes() http.Handler {
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.HandleFunc("GET /media/{id}", s.hostBlog(s.handleMedia))
m.Handle("GET /static/{file}", s.staticHandler())
m.HandleFunc("GET /favicon.ico", s.hostBlog(s.handleFavicon))
// The root domain also serves the superadmin's blog. Literal routes above win
@@ -98,7 +100,7 @@ func (s *Server) subdomainRoutes() http.Handler {
m := http.NewServeMux()
// Bloggers type /webadmin on their own blog; send them to the real login page.
m.HandleFunc("GET /webadmin", s.handleWebadminRedirect)
- m.HandleFunc("GET /media/{id}", s.handleMedia)
+ m.HandleFunc("GET /media/{id}", s.hostBlog(s.handleMedia))
m.Handle("GET /static/{file}", s.staticHandler())
m.HandleFunc("GET /favicon.ico", s.hostBlog(s.handleFavicon))
s.blogRoutes(m, s.hostBlog)
diff --git a/internal/web/server.go b/internal/web/server.go
index 483663e..a7af06b 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -66,9 +66,10 @@ func hostname(h string) string {
type ctxKey int
const (
- ctxUser ctxKey = iota
- ctxBlog // the blog a handler operates on (public page, or /b/{sub}/ management)
- ctxHostSub // subdomain derived from the Host header
+ ctxUser ctxKey = iota
+ ctxBlog // the blog a handler operates on (public page, or /b/{sub}/ management)
+ ctxBlogStore // that blog's store, bound to its own database
+ ctxHostSub // subdomain derived from the Host header
)
func currentUser(r *http.Request) *store.User {
@@ -81,13 +82,33 @@ func currentBlog(r *http.Request) *store.Blog {
return b
}
+// blogStore is the store of the blog in the context; only valid behind hostBlog or withBlog.
+func blogStore(r *http.Request) *store.BlogStore {
+ bs, _ := r.Context().Value(ctxBlogStore).(*store.BlogStore)
+ return bs
+}
+
// ---- middleware ----------------------------------------------------------
+// resolveBlog looks a subdomain up in the registry and opens its database.
+func (s *Server) resolveBlog(r *http.Request, sub string) (*http.Request, error) {
+ blog, err := s.st.BlogBySubdomain(r.Context(), sub)
+ if err != nil {
+ return r, err
+ }
+ bs, err := s.st.Open(r.Context(), blog)
+ if err != nil {
+ return r, err
+ }
+ ctx := context.WithValue(r.Context(), ctxBlog, blog)
+ return r.WithContext(context.WithValue(ctx, ctxBlogStore, bs)), nil
+}
+
// hostBlog resolves the blog named by the Host header into the context for public pages.
func (s *Server) hostBlog(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sub, _ := r.Context().Value(ctxHostSub).(string)
- blog, err := s.st.BlogBySubdomain(r.Context(), sub)
+ r, err := s.resolveBlog(r, sub)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
s.plainError(w, http.StatusNotFound, "No blog here (yet).")
@@ -96,7 +117,7 @@ func (s *Server) hostBlog(next http.HandlerFunc) http.HandlerFunc {
s.serverError(w, err)
return
}
- next(w, r.WithContext(context.WithValue(r.Context(), ctxBlog, blog)))
+ next(w, r)
}
}
@@ -176,7 +197,7 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
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"))
+ r, err := s.resolveBlog(r, r.PathValue("sub"))
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
@@ -185,11 +206,11 @@ func (s *Server) withBlog(next http.HandlerFunc) http.HandlerFunc {
s.serverError(w, err)
return
}
- if blog.OwnerID != u.ID && !u.IsSuperadmin() {
+ if blog := currentBlog(r); 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)))
+ next(w, r)
})
}
diff --git a/internal/web/templates/admin/index.html b/internal/web/templates/admin/index.html
index ecb0112..8293368 100644
--- a/internal/web/templates/admin/index.html
+++ b/internal/web/templates/admin/index.html
@@ -10,7 +10,7 @@
{{range .Data.users}}<tr{{if .Disabled}} class="disabled"{{end}}>
<td>{{.Username}}{{if .Disabled}} <span class="tag">disabled</span>{{end}}</td>
<td>{{.Role}}</td>
- <td>{{if .Subdomain}}<a href="/b/{{deref .Subdomain}}/">{{deref .BlogTitle}}</a> <a href="{{$.Data.cfg.BlogURL (deref .Subdomain)}}" target="_blank" class="muted">{{if eq (deref .Subdomain) "www"}}{{$.Data.cfg.BaseDomain}}{{else}}{{deref .Subdomain}}{{end}} &#8599;</a>{{else}}<span class="muted">—</span>{{end}}</td>
+ <td>{{if .Subdomain}}<a href="/b/{{deref .Subdomain}}/">{{if eq (deref .Subdomain) "www"}}{{$.Data.cfg.BaseDomain}}{{else}}{{deref .Subdomain}}{{end}}</a> <a href="{{$.Data.cfg.BlogURL (deref .Subdomain)}}" target="_blank" class="muted">view &#8599;</a>{{else}}<span class="muted">—</span>{{end}}</td>
<td class="nowrap">{{date .CreatedAt}}</td>
<td class="nowrap">
{{if ne .ID $.User.ID}}
diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html
index 4c86c79..9c2a12f 100644
--- a/internal/web/templates/dashboard/design.html
+++ b/internal/web/templates/dashboard/design.html
@@ -34,7 +34,7 @@
<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")}}
+ {{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "sub" .Blog.Subdomain "images" .Data.images "label" "Background image")}}
</div>
<div class="card" id="text">
@@ -102,7 +102,7 @@
</select></label>
</div>
<p class="muted small">The title, logo and menu are header modules — add, remove and order them on the <a href="/b/{{.Blog.Subdomain}}/layout#header">Layout</a> tab. The banner below is shown across the top of the header whenever one is chosen.</p>
- {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "images" .Data.images "label" "Header image (banner)")}}
+ {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "sub" .Blog.Subdomain "images" .Data.images "label" "Header image (banner)")}}
</div>
<div class="card" id="menu">
@@ -161,8 +161,8 @@
<option value="large"{{if eq $t.LogoSize "large"}} selected{{end}}>Large (140 px)</option>
</select></label>
</div>
- {{template "imagepick" (dict "name" "logo" "current" $t.Logo "images" .Data.images "label" "Logo" "hint" "Shown wherever you place a Logo module (Layout tab); it links to your front page.")}}
- {{template "imagepick" (dict "name" "favicon" "current" $t.Favicon "images" .Data.images "label" "Favicon" "hint" "The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon." "accept" "image/png,image/x-icon,image/gif,image/webp,image/jpeg,.ico")}}
+ {{template "imagepick" (dict "name" "logo" "current" $t.Logo "sub" .Blog.Subdomain "images" .Data.images "label" "Logo" "hint" "Shown wherever you place a Logo module (Layout tab); it links to your front page.")}}
+ {{template "imagepick" (dict "name" "favicon" "current" $t.Favicon "sub" .Blog.Subdomain "images" .Data.images "label" "Favicon" "hint" "The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon." "accept" "image/png,image/x-icon,image/gif,image/webp,image/jpeg,.ico")}}
</div>
<div class="savebar">
diff --git a/internal/web/templates/dashboard/images.html b/internal/web/templates/dashboard/images.html
index 1c64391..2e39531 100644
--- a/internal/web/templates/dashboard/images.html
+++ b/internal/web/templates/dashboard/images.html
@@ -14,7 +14,7 @@
<div class="gallery">
{{range .Data.images}}
<div class="card thumb">
- <a href="/media/{{.ID}}" target="_blank"><img src="/media/{{.ID}}" alt="{{.Filename}}"></a>
+ <a href="/b/{{$.Blog.Subdomain}}/media/{{.ID}}" target="_blank"><img src="/b/{{$.Blog.Subdomain}}/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>
diff --git a/internal/web/templates/partials/imagepick.html b/internal/web/templates/partials/imagepick.html
index 0c6eced..4431d02 100644
--- a/internal/web/templates/partials/imagepick.html
+++ b/internal/web/templates/partials/imagepick.html
@@ -3,7 +3,7 @@
{{if .hint}}<p class="muted small">{{.hint}}</p>{{end}}
<div class="picks">
<label class="pick none"><input type="radio" name="{{.name}}" value="none"{{if not .current}} checked{{end}}><span class="swatch">&times;</span><span class="name">None</span></label>
- {{range .images}}<label class="pick"><input type="radio" name="{{$.name}}" value="{{.ID}}"{{if eq .ID.String $.current}} checked{{end}}><img src="/media/{{.ID}}" alt=""><span class="name">{{.Filename}}</span></label>{{end}}
+ {{range .images}}<label class="pick"><input type="radio" name="{{$.name}}" value="{{.ID}}"{{if eq .ID.String $.current}} checked{{end}}><img src="/b/{{$.sub}}/media/{{.ID}}" alt=""><span class="name">{{.Filename}}</span></label>{{end}}
</div>
<label>…or upload a new one<br><input type="file" name="{{.name}}_file" accept="{{if .accept}}{{.accept}}{{else}}image/*{{end}}"></label>
</div>{{end}}
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index a988a23..5078c44 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -48,10 +48,17 @@ func TestHostRoutingWithoutDB(t *testing.T) {
}
// On the root domain the literal management routes must win over the blog's /{page} wildcards.
+// "blog_" + subdomain must fit a 63-char Postgres database name.
+func TestSubdomainLength(t *testing.T) {
+ if !subdomainRe.MatchString(strings.Repeat("a", 58)) || subdomainRe.MatchString(strings.Repeat("a", 59)) {
+ t.Error("subdomains must be at most 58 chars")
+ }
+}
+
func TestRootRoutePrecedence(t *testing.T) {
cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20}
s := NewServer(cfg, nil)
- for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/b/alice/layout": 303, "/account/password": 303} {
+ for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/b/alice/layout": 303, "/b/alice/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8": 303, "/account/password": 303} {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", path, nil)
req.Host = "example.com"