From aeb19df4222269c585de55be5568326222df879d Mon Sep 17 00:00:00 2001 From: grm Date: Sun, 13 Sep 2026 23:52:41 +0300 Subject: Give every blog its own Postgres database A blog is now a database of its own (blog_) 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 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/db/db.go | 182 +++++++++++++++++++-- internal/db/db_test.go | 25 +++ internal/db/migrations/00001_init.sql | 68 -------- internal/db/migrations/00002_sections.sql | 19 --- internal/db/migrations/00003_layout.sql | 56 ------- internal/db/migrations/00004_section_placement.sql | 13 -- internal/db/migrations/blog/00001_init.sql | 91 +++++++++++ internal/db/migrations/control/00001_init.sql | 68 ++++++++ internal/db/migrations/control/00002_sections.sql | 19 +++ internal/db/migrations/control/00003_layout.sql | 56 +++++++ .../migrations/control/00004_section_placement.sql | 13 ++ .../control/00005_per_blog_databases.sql | 12 ++ .../db/migrations/control/00007_drop_content.sql | 9 + internal/db/split.go | 152 +++++++++++++++++ internal/store/blogs.go | 118 ++++++++++--- internal/store/images.go | 25 ++- internal/store/menu.go | 54 +++--- internal/store/modules.go | 57 ++++--- internal/store/pages.go | 69 ++++---- internal/store/posts.go | 58 ++++--- internal/store/sections.go | 51 +++--- internal/store/store.go | 39 ++++- internal/store/users.go | 22 ++- internal/web/handlers_admin.go | 12 +- internal/web/handlers_blog.go | 25 ++- internal/web/handlers_dashboard.go | 7 +- internal/web/handlers_design.go | 20 +-- internal/web/handlers_layout.go | 35 ++-- internal/web/handlers_media.go | 2 +- internal/web/handlers_pages.go | 16 +- internal/web/handlers_posts.go | 20 +-- internal/web/handlers_sections.go | 17 +- internal/web/routes.go | 6 +- internal/web/server.go | 37 ++++- internal/web/templates/admin/index.html | 2 +- internal/web/templates/dashboard/design.html | 8 +- internal/web/templates/dashboard/images.html | 2 +- internal/web/templates/partials/imagepick.html | 2 +- internal/web/web_test.go | 9 +- 39 files changed, 1039 insertions(+), 457 deletions(-) create mode 100644 internal/db/db_test.go delete mode 100644 internal/db/migrations/00001_init.sql delete mode 100644 internal/db/migrations/00002_sections.sql delete mode 100644 internal/db/migrations/00003_layout.sql delete mode 100644 internal/db/migrations/00004_section_placement.sql create mode 100644 internal/db/migrations/blog/00001_init.sql create mode 100644 internal/db/migrations/control/00001_init.sql create mode 100644 internal/db/migrations/control/00002_sections.sql create mode 100644 internal/db/migrations/control/00003_layout.sql create mode 100644 internal/db/migrations/control/00004_section_placement.sql create mode 100644 internal/db/migrations/control/00005_per_blog_databases.sql create mode 100644 internal/db/migrations/control/00007_drop_content.sql create mode 100644 internal/db/split.go (limited to 'internal') 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/00001_init.sql b/internal/db/migrations/00001_init.sql deleted file mode 100644 index e27dad4..0000000 --- a/internal/db/migrations/00001_init.sql +++ /dev/null @@ -1,68 +0,0 @@ --- +goose Up -CREATE TABLE users ( - id bigserial PRIMARY KEY, - username text NOT NULL UNIQUE, - password_hash text NOT NULL, - role text NOT NULL CHECK (role IN ('superadmin', 'blogger')), - disabled boolean NOT NULL DEFAULT false, - token_version integer NOT NULL DEFAULT 0, - created_at timestamptz NOT NULL DEFAULT now() -); - -CREATE TABLE blogs ( - id bigserial PRIMARY KEY, - owner_id bigint NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, - subdomain text NOT NULL UNIQUE CHECK (subdomain ~ '^[a-z0-9](-?[a-z0-9]){0,62}$'), - title text NOT NULL, - tagline text NOT NULL DEFAULT '', - theme jsonb NOT NULL DEFAULT '{}'::jsonb, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() -); - -CREATE TABLE pages ( - id bigserial PRIMARY KEY, - blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, - slug text NOT NULL, - title text NOT NULL, - intro_md text NOT NULL DEFAULT '', - intro_html text NOT NULL DEFAULT '', - nav_order integer NOT NULL DEFAULT 0, - show_in_nav boolean NOT NULL DEFAULT true, - is_home boolean NOT NULL DEFAULT false, - created_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (blog_id, slug) -); -CREATE UNIQUE INDEX pages_one_home_per_blog ON pages (blog_id) WHERE is_home; - -CREATE TABLE posts ( - id bigserial PRIMARY KEY, - page_id bigint NOT NULL REFERENCES pages(id) ON DELETE CASCADE, - slug text NOT NULL, - title text NOT NULL, - body_md text NOT NULL DEFAULT '', - body_html text NOT NULL DEFAULT '', - published boolean NOT NULL DEFAULT true, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (page_id, slug) -); -CREATE INDEX posts_page_created ON posts (page_id, created_at DESC); - -CREATE TABLE images ( - id uuid PRIMARY KEY, - blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, - filename text NOT NULL, - content_type text NOT NULL, - size integer NOT NULL, - data bytea NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() -); -CREATE INDEX images_blog ON images (blog_id, created_at DESC); - --- +goose Down -DROP TABLE images; -DROP TABLE posts; -DROP TABLE pages; -DROP TABLE blogs; -DROP TABLE users; diff --git a/internal/db/migrations/00002_sections.sql b/internal/db/migrations/00002_sections.sql deleted file mode 100644 index 388cb17..0000000 --- a/internal/db/migrations/00002_sections.sql +++ /dev/null @@ -1,19 +0,0 @@ --- +goose Up --- Announcements: blog-wide notices shown on every page and post. -CREATE TABLE sections ( - id bigserial PRIMARY KEY, - blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, - title text NOT NULL DEFAULT '', - body_md text NOT NULL DEFAULT '', - body_html text NOT NULL DEFAULT '', - placement text NOT NULL DEFAULT 'above' CHECK (placement IN ('above', 'below', 'sidebar')), - 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_blog ON sections (blog_id, sort_order, id); - --- +goose Down -DROP TABLE sections; diff --git a/internal/db/migrations/00003_layout.sql b/internal/db/migrations/00003_layout.sql deleted file mode 100644 index e8fff23..0000000 --- a/internal/db/migrations/00003_layout.sql +++ /dev/null @@ -1,56 +0,0 @@ --- +goose Up --- Layout modules: what each area of a blog (header, columns, footer) shows. -CREATE TABLE modules ( - id bigserial PRIMARY KEY, - blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, - 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_blog ON modules (blog_id, area, sort_order, id); - --- The menu: blog pages and custom links in one ordered list. -CREATE TABLE menu_items ( - id bigserial PRIMARY KEY, - blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, - 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_blog ON menu_items (blog_id, sort_order, id); - --- Existing blogs keep the menu they had: pages marked "show in menu", in menu --- order, with the home page only if the theme included it. -INSERT INTO menu_items (blog_id, page_id, sort_order) -SELECT p.blog_id, p.id, row_number() OVER (PARTITION BY p.blog_id ORDER BY p.nav_order, p.id) - 1 -FROM pages p JOIN blogs b ON b.id = p.blog_id -WHERE p.show_in_nav AND (NOT p.is_home OR coalesce((b.theme->>'nav_show_home')::boolean, true)); -ALTER TABLE pages DROP COLUMN show_in_nav; - --- ...and the layout their theme described. -INSERT INTO modules (blog_id, area, kind, sort_order) -SELECT id, 'header', 'title', CASE WHEN theme->>'nav_position' = 'top-bar' THEN 1 ELSE 0 END -FROM blogs WHERE coalesce((theme->>'header_show_title')::boolean, true); -INSERT INTO modules (blog_id, area, kind, sort_order) -SELECT id, 'header', 'menu', CASE WHEN theme->>'nav_position' = 'top-bar' THEN 0 ELSE 1 END -FROM blogs WHERE coalesce(theme->>'nav_position', 'below-header') <> 'left-sidebar'; -INSERT INTO modules (blog_id, area, kind, sort_order) -SELECT id, 'left', 'menu', 0 FROM blogs WHERE theme->>'nav_position' = 'left-sidebar'; -INSERT INTO modules (blog_id, area, kind, body, sort_order) -SELECT id, 'footer', 'text', theme->>'footer_text', 0 FROM blogs WHERE coalesce(theme->>'footer_text', '') <> ''; -INSERT INTO modules (blog_id, area, kind, sort_order) -SELECT id, 'footer', 'rss', 1 FROM blogs WHERE coalesce((theme->>'footer_show_rss')::boolean, true); - --- +goose Down -ALTER TABLE pages ADD COLUMN show_in_nav boolean NOT NULL DEFAULT true; -UPDATE pages SET show_in_nav = EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id = pages.id); -DROP TABLE menu_items; -DROP TABLE modules; diff --git a/internal/db/migrations/00004_section_placement.sql b/internal/db/migrations/00004_section_placement.sql deleted file mode 100644 index c36787e..0000000 --- a/internal/db/migrations/00004_section_placement.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +goose Up --- Announcements are placed in a column (left, main, right) at its top or bottom. -ALTER TABLE sections DROP CONSTRAINT sections_placement_check; -UPDATE sections SET placement = CASE placement WHEN 'above' THEN 'main-top' WHEN 'below' THEN 'main-bottom' ELSE 'left-top' END; -ALTER TABLE sections ALTER COLUMN placement SET DEFAULT 'main-top'; -ALTER TABLE sections ADD CONSTRAINT sections_placement_check - CHECK (placement IN ('left-top', 'left-bottom', 'main-top', 'main-bottom', 'right-top', 'right-bottom')); - --- +goose Down -ALTER TABLE sections DROP CONSTRAINT sections_placement_check; -UPDATE sections SET placement = CASE placement WHEN 'main-top' THEN 'above' WHEN 'main-bottom' THEN 'below' ELSE 'sidebar' END; -ALTER TABLE sections ALTER COLUMN placement SET DEFAULT 'above'; -ALTER TABLE sections ADD CONSTRAINT sections_placement_check CHECK (placement IN ('above', 'below', 'sidebar')); 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/control/00001_init.sql b/internal/db/migrations/control/00001_init.sql new file mode 100644 index 0000000..e27dad4 --- /dev/null +++ b/internal/db/migrations/control/00001_init.sql @@ -0,0 +1,68 @@ +-- +goose Up +CREATE TABLE users ( + id bigserial PRIMARY KEY, + username text NOT NULL UNIQUE, + password_hash text NOT NULL, + role text NOT NULL CHECK (role IN ('superadmin', 'blogger')), + disabled boolean NOT NULL DEFAULT false, + token_version integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE blogs ( + id bigserial PRIMARY KEY, + owner_id bigint NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + subdomain text NOT NULL UNIQUE CHECK (subdomain ~ '^[a-z0-9](-?[a-z0-9]){0,62}$'), + title text NOT NULL, + tagline text NOT NULL DEFAULT '', + theme jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE pages ( + id bigserial PRIMARY KEY, + blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, + slug text NOT NULL, + title text NOT NULL, + intro_md text NOT NULL DEFAULT '', + intro_html text NOT NULL DEFAULT '', + nav_order integer NOT NULL DEFAULT 0, + show_in_nav boolean NOT NULL DEFAULT true, + is_home boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (blog_id, slug) +); +CREATE UNIQUE INDEX pages_one_home_per_blog ON pages (blog_id) WHERE is_home; + +CREATE TABLE posts ( + id bigserial PRIMARY KEY, + page_id bigint NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + slug text NOT NULL, + title text NOT NULL, + body_md text NOT NULL DEFAULT '', + body_html text NOT NULL DEFAULT '', + published boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (page_id, slug) +); +CREATE INDEX posts_page_created ON posts (page_id, created_at DESC); + +CREATE TABLE images ( + id uuid PRIMARY KEY, + blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, + filename text NOT NULL, + content_type text NOT NULL, + size integer NOT NULL, + data bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX images_blog ON images (blog_id, created_at DESC); + +-- +goose Down +DROP TABLE images; +DROP TABLE posts; +DROP TABLE pages; +DROP TABLE blogs; +DROP TABLE users; diff --git a/internal/db/migrations/control/00002_sections.sql b/internal/db/migrations/control/00002_sections.sql new file mode 100644 index 0000000..388cb17 --- /dev/null +++ b/internal/db/migrations/control/00002_sections.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- Announcements: blog-wide notices shown on every page and post. +CREATE TABLE sections ( + id bigserial PRIMARY KEY, + blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, + title text NOT NULL DEFAULT '', + body_md text NOT NULL DEFAULT '', + body_html text NOT NULL DEFAULT '', + placement text NOT NULL DEFAULT 'above' CHECK (placement IN ('above', 'below', 'sidebar')), + 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_blog ON sections (blog_id, sort_order, id); + +-- +goose Down +DROP TABLE sections; diff --git a/internal/db/migrations/control/00003_layout.sql b/internal/db/migrations/control/00003_layout.sql new file mode 100644 index 0000000..e8fff23 --- /dev/null +++ b/internal/db/migrations/control/00003_layout.sql @@ -0,0 +1,56 @@ +-- +goose Up +-- Layout modules: what each area of a blog (header, columns, footer) shows. +CREATE TABLE modules ( + id bigserial PRIMARY KEY, + blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, + 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_blog ON modules (blog_id, area, sort_order, id); + +-- The menu: blog pages and custom links in one ordered list. +CREATE TABLE menu_items ( + id bigserial PRIMARY KEY, + blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, + 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_blog ON menu_items (blog_id, sort_order, id); + +-- Existing blogs keep the menu they had: pages marked "show in menu", in menu +-- order, with the home page only if the theme included it. +INSERT INTO menu_items (blog_id, page_id, sort_order) +SELECT p.blog_id, p.id, row_number() OVER (PARTITION BY p.blog_id ORDER BY p.nav_order, p.id) - 1 +FROM pages p JOIN blogs b ON b.id = p.blog_id +WHERE p.show_in_nav AND (NOT p.is_home OR coalesce((b.theme->>'nav_show_home')::boolean, true)); +ALTER TABLE pages DROP COLUMN show_in_nav; + +-- ...and the layout their theme described. +INSERT INTO modules (blog_id, area, kind, sort_order) +SELECT id, 'header', 'title', CASE WHEN theme->>'nav_position' = 'top-bar' THEN 1 ELSE 0 END +FROM blogs WHERE coalesce((theme->>'header_show_title')::boolean, true); +INSERT INTO modules (blog_id, area, kind, sort_order) +SELECT id, 'header', 'menu', CASE WHEN theme->>'nav_position' = 'top-bar' THEN 0 ELSE 1 END +FROM blogs WHERE coalesce(theme->>'nav_position', 'below-header') <> 'left-sidebar'; +INSERT INTO modules (blog_id, area, kind, sort_order) +SELECT id, 'left', 'menu', 0 FROM blogs WHERE theme->>'nav_position' = 'left-sidebar'; +INSERT INTO modules (blog_id, area, kind, body, sort_order) +SELECT id, 'footer', 'text', theme->>'footer_text', 0 FROM blogs WHERE coalesce(theme->>'footer_text', '') <> ''; +INSERT INTO modules (blog_id, area, kind, sort_order) +SELECT id, 'footer', 'rss', 1 FROM blogs WHERE coalesce((theme->>'footer_show_rss')::boolean, true); + +-- +goose Down +ALTER TABLE pages ADD COLUMN show_in_nav boolean NOT NULL DEFAULT true; +UPDATE pages SET show_in_nav = EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id = pages.id); +DROP TABLE menu_items; +DROP TABLE modules; diff --git a/internal/db/migrations/control/00004_section_placement.sql b/internal/db/migrations/control/00004_section_placement.sql new file mode 100644 index 0000000..c36787e --- /dev/null +++ b/internal/db/migrations/control/00004_section_placement.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- Announcements are placed in a column (left, main, right) at its top or bottom. +ALTER TABLE sections DROP CONSTRAINT sections_placement_check; +UPDATE sections SET placement = CASE placement WHEN 'above' THEN 'main-top' WHEN 'below' THEN 'main-bottom' ELSE 'left-top' END; +ALTER TABLE sections ALTER COLUMN placement SET DEFAULT 'main-top'; +ALTER TABLE sections ADD CONSTRAINT sections_placement_check + CHECK (placement IN ('left-top', 'left-bottom', 'main-top', 'main-bottom', 'right-top', 'right-bottom')); + +-- +goose Down +ALTER TABLE sections DROP CONSTRAINT sections_placement_check; +UPDATE sections SET placement = CASE placement WHEN 'main-top' THEN 'above' WHEN 'main-bottom' THEN 'below' ELSE 'sidebar' END; +ALTER TABLE sections ALTER COLUMN placement SET DEFAULT 'above'; +ALTER TABLE sections ADD CONSTRAINT sections_placement_check CHECK (placement IN ('above', 'below', 'sidebar')); 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}} {{.Username}}{{if .Disabled}} disabled{{end}} {{.Role}} - {{if .Subdomain}}{{deref .BlogTitle}} {{if eq (deref .Subdomain) "www"}}{{$.Data.cfg.BaseDomain}}{{else}}{{deref .Subdomain}}{{end}} ↗{{else}}—{{end}} + {{if .Subdomain}}{{if eq (deref .Subdomain) "www"}}{{$.Data.cfg.BaseDomain}}{{else}}{{deref .Subdomain}}{{end}} view ↗{{else}}—{{end}} {{date .CreatedAt}} {{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 @@ - {{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")}}
@@ -102,7 +102,7 @@

The title, logo and menu are header modules — add, remove and order them on the Layout tab. The banner below is shown across the top of the header whenever one is chosen.

- {{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)")}} - {{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")}}
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 @@