From 3eb04b1a2bdf9e53231fe862cfd76327371a9741 Mon Sep 17 00:00:00 2001 From: gramanas Date: Sat, 12 Sep 2026 11:24:17 +0300 Subject: Initial multi-tenant blog host Go + Postgres application serving a management dashboard on the base domain and one public blog per subdomain. Markdown posts organised in pages, form-based theme customisation, image uploads stored in Postgres, JWT cookie sessions with CSRF, superadmin user management, RSS feeds. Docker/compose deployment and a Makefile-driven dev environment with seed data. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/auth/auth_test.go | 46 +++++ internal/auth/cookie.go | 23 +++ internal/auth/csrf.go | 20 ++ internal/auth/jwt.go | 41 ++++ internal/auth/password.go | 13 ++ internal/config/config.go | 90 +++++++++ internal/db/db.go | 43 ++++ internal/db/migrations/00001_init.sql | 68 +++++++ internal/markdown/render.go | 38 ++++ internal/markdown/render_test.go | 29 +++ internal/slug/slug.go | 54 +++++ internal/slug/slug_test.go | 34 ++++ internal/store/blogs.go | 84 ++++++++ internal/store/images.go | 58 ++++++ internal/store/pages.go | 124 ++++++++++++ internal/store/posts.go | 105 ++++++++++ internal/store/store.go | 34 ++++ internal/store/users.go | 99 ++++++++++ internal/web/handlers_admin.go | 154 +++++++++++++++ internal/web/handlers_auth.go | 131 +++++++++++++ internal/web/handlers_blog.go | 150 ++++++++++++++ internal/web/handlers_dashboard.go | 43 ++++ internal/web/handlers_design.go | 155 +++++++++++++++ internal/web/handlers_media.go | 38 ++++ internal/web/handlers_pages.go | 161 +++++++++++++++ internal/web/handlers_posts.go | 158 +++++++++++++++ internal/web/routes.go | 81 ++++++++ internal/web/server.go | 251 ++++++++++++++++++++++++ internal/web/static/blog.css | 50 +++++ internal/web/static/dashboard.css | 87 ++++++++ internal/web/templates.go | 117 +++++++++++ internal/web/templates/admin/delete_user.html | 12 ++ internal/web/templates/admin/index.html | 28 +++ internal/web/templates/admin/new_user.html | 15 ++ internal/web/templates/auth/login.html | 12 ++ internal/web/templates/blog/404.html | 5 + internal/web/templates/blog/page.html | 19 ++ internal/web/templates/blog/post.html | 8 + internal/web/templates/dashboard/confirm.html | 17 ++ internal/web/templates/dashboard/design.html | 87 ++++++++ internal/web/templates/dashboard/images.html | 22 +++ internal/web/templates/dashboard/overview.html | 35 ++++ internal/web/templates/dashboard/page_form.html | 21 ++ internal/web/templates/dashboard/pages.html | 24 +++ internal/web/templates/dashboard/password.html | 13 ++ internal/web/templates/dashboard/post_form.html | 26 +++ internal/web/templates/dashboard/posts.html | 25 +++ internal/web/templates/dashboard/settings.html | 13 ++ internal/web/templates/layouts/blog.html | 38 ++++ internal/web/templates/layouts/dashboard.html | 17 ++ internal/web/templates/partials/dashnav.html | 27 +++ internal/web/templates/partials/imagepick.html | 11 ++ internal/web/templates/partials/mdhelp.html | 9 + internal/web/theme.go | 212 ++++++++++++++++++++ internal/web/web_test.go | 73 +++++++ 55 files changed, 3348 insertions(+) create mode 100644 internal/auth/auth_test.go create mode 100644 internal/auth/cookie.go create mode 100644 internal/auth/csrf.go create mode 100644 internal/auth/jwt.go create mode 100644 internal/auth/password.go create mode 100644 internal/config/config.go create mode 100644 internal/db/db.go create mode 100644 internal/db/migrations/00001_init.sql create mode 100644 internal/markdown/render.go create mode 100644 internal/markdown/render_test.go create mode 100644 internal/slug/slug.go create mode 100644 internal/slug/slug_test.go create mode 100644 internal/store/blogs.go create mode 100644 internal/store/images.go create mode 100644 internal/store/pages.go create mode 100644 internal/store/posts.go create mode 100644 internal/store/store.go create mode 100644 internal/store/users.go create mode 100644 internal/web/handlers_admin.go create mode 100644 internal/web/handlers_auth.go create mode 100644 internal/web/handlers_blog.go create mode 100644 internal/web/handlers_dashboard.go create mode 100644 internal/web/handlers_design.go create mode 100644 internal/web/handlers_media.go create mode 100644 internal/web/handlers_pages.go create mode 100644 internal/web/handlers_posts.go create mode 100644 internal/web/routes.go create mode 100644 internal/web/server.go create mode 100644 internal/web/static/blog.css create mode 100644 internal/web/static/dashboard.css create mode 100644 internal/web/templates.go create mode 100644 internal/web/templates/admin/delete_user.html create mode 100644 internal/web/templates/admin/index.html create mode 100644 internal/web/templates/admin/new_user.html create mode 100644 internal/web/templates/auth/login.html create mode 100644 internal/web/templates/blog/404.html create mode 100644 internal/web/templates/blog/page.html create mode 100644 internal/web/templates/blog/post.html create mode 100644 internal/web/templates/dashboard/confirm.html create mode 100644 internal/web/templates/dashboard/design.html create mode 100644 internal/web/templates/dashboard/images.html create mode 100644 internal/web/templates/dashboard/overview.html create mode 100644 internal/web/templates/dashboard/page_form.html create mode 100644 internal/web/templates/dashboard/pages.html create mode 100644 internal/web/templates/dashboard/password.html create mode 100644 internal/web/templates/dashboard/post_form.html create mode 100644 internal/web/templates/dashboard/posts.html create mode 100644 internal/web/templates/dashboard/settings.html create mode 100644 internal/web/templates/layouts/blog.html create mode 100644 internal/web/templates/layouts/dashboard.html create mode 100644 internal/web/templates/partials/dashnav.html create mode 100644 internal/web/templates/partials/imagepick.html create mode 100644 internal/web/templates/partials/mdhelp.html create mode 100644 internal/web/theme.go create mode 100644 internal/web/web_test.go (limited to 'internal') diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..d09ec3c --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,46 @@ +package auth + +import ( + "testing" + "time" +) + +func TestTokenRoundTrip(t *testing.T) { + secret := []byte("test-secret-test-secret") + tok, err := IssueToken(secret, 42, 3, time.Now()) + if err != nil { + t.Fatal(err) + } + c, err := ParseToken(secret, tok) + if err != nil { + t.Fatal(err) + } + if c.UserID != 42 || c.TokenVersion != 3 { + t.Errorf("claims = %+v", c) + } + if _, err := ParseToken([]byte("other-secret-other-secret"), tok); err == nil { + t.Error("token accepted with wrong secret") + } + old, _ := IssueToken(secret, 42, 3, time.Now().Add(-8*24*time.Hour)) + if _, err := ParseToken(secret, old); err == nil { + t.Error("expired token accepted") + } +} + +func TestPasswordAndCSRF(t *testing.T) { + h, err := HashPassword("hunter22") + if err != nil { + t.Fatal(err) + } + if !CheckPassword(h, "hunter22") || CheckPassword(h, "hunter23") { + t.Error("password check wrong") + } + secret := []byte("s") + tok := CSRFToken(secret, 1, 0) + if !CheckCSRF(secret, 1, 0, tok) { + t.Error("valid csrf rejected") + } + if CheckCSRF(secret, 1, 1, tok) || CheckCSRF(secret, 2, 0, tok) || CheckCSRF(secret, 1, 0, "") { + t.Error("invalid csrf accepted") + } +} diff --git a/internal/auth/cookie.go b/internal/auth/cookie.go new file mode 100644 index 0000000..9fd6b6e --- /dev/null +++ b/internal/auth/cookie.go @@ -0,0 +1,23 @@ +package auth + +import ( + "net/http" + "time" +) + +const CookieName = "session" + +func SetSessionCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(SessionTTL / time.Second), + }) +} + +func ClearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: CookieName, Value: "", Path: "/", HttpOnly: true, MaxAge: -1}) +} diff --git a/internal/auth/csrf.go b/internal/auth/csrf.go new file mode 100644 index 0000000..da34309 --- /dev/null +++ b/internal/auth/csrf.go @@ -0,0 +1,20 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" +) + +// CSRFToken derives a per-user token from the secret; it changes whenever the +// user's token_version changes (password reset, disable) and needs no storage. +func CSRFToken(secret []byte, userID int64, tokenVersion int) string { + m := hmac.New(sha256.New, secret) + fmt.Fprintf(m, "csrf:%d:%d", userID, tokenVersion) + return hex.EncodeToString(m.Sum(nil)) +} + +func CheckCSRF(secret []byte, userID int64, tokenVersion int, got string) bool { + return hmac.Equal([]byte(CSRFToken(secret, userID, tokenVersion)), []byte(got)) +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..a7fb52a --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,41 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const SessionTTL = 7 * 24 * time.Hour + +type Claims struct { + jwt.RegisteredClaims + UserID int64 `json:"uid"` + TokenVersion int `json:"ver"` +} + +func IssueToken(secret []byte, userID int64, tokenVersion int, now time.Time) (string, error) { + c := Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(SessionTTL)), + }, + UserID: userID, + TokenVersion: tokenVersion, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString(secret) +} + +func ParseToken(secret []byte, tok string) (*Claims, error) { + var c Claims + _, err := jwt.ParseWithClaims(tok, &c, func(t *jwt.Token) (any, error) { return secret, nil }, + jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired()) + if err != nil { + return nil, err + } + if c.UserID == 0 { + return nil, errors.New("missing uid") + } + return &c, nil +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..97aba55 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,13 @@ +// Package auth implements password hashing, JWT sessions and CSRF tokens. +package auth + +import "golang.org/x/crypto/bcrypt" + +func HashPassword(pw string) (string, error) { + h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + return string(h), err +} + +func CheckPassword(hash, pw string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4972178 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,90 @@ +// Package config reads application settings from the environment. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +type Config struct { + Addr string // listen address, e.g. ":8080" + BaseDomain string // e.g. "example.com" (no port) + PublicPort string // port to append to generated blog URLs ("" in prod behind a proxy) + DatabaseURL string + JWTSecret []byte + // Bootstrap superadmin created on first start if no superadmin exists. + SuperadminUsername string + SuperadminPassword string + MaxUploadBytes int64 + Dev bool // reload templates/static from disk +} + +func Load() (*Config, error) { + c := &Config{ + Addr: env("ADDR", ":8080"), + BaseDomain: strings.ToLower(strings.TrimSpace(env("BASE_DOMAIN", "blogspace.localhost"))), + PublicPort: env("PUBLIC_PORT", ""), + DatabaseURL: env("DATABASE_URL", "postgres://blogspace:blogspace@localhost:5432/blogspace?sslmode=disable"), + JWTSecret: []byte(env("JWT_SECRET", "")), + SuperadminUsername: env("SUPERADMIN_USERNAME", "admin"), + SuperadminPassword: env("SUPERADMIN_PASSWORD", ""), + Dev: envBool("DEV", false), + } + mb, err := strconv.Atoi(env("MAX_UPLOAD_MB", "5")) + if err != nil || mb <= 0 { + return nil, fmt.Errorf("MAX_UPLOAD_MB must be a positive integer") + } + c.MaxUploadBytes = int64(mb) << 20 + + if len(c.JWTSecret) == 0 { + if !c.Dev { + return nil, fmt.Errorf("JWT_SECRET must be set") + } + c.JWTSecret = []byte("dev-insecure-secret-change-me") + } + if len(c.JWTSecret) < 16 && !c.Dev { + return nil, fmt.Errorf("JWT_SECRET must be at least 16 bytes") + } + if c.BaseDomain == "" { + return nil, fmt.Errorf("BASE_DOMAIN must be set") + } + return c, nil +} + +// BlogURL returns the public URL for a blog subdomain. +func (c *Config) BlogURL(sub string) string { + return "http://" + sub + "." + c.HostWithPort() +} + +// RootURL returns the public URL of the management site. +func (c *Config) RootURL() string { + return "http://" + c.HostWithPort() +} + +func (c *Config) HostWithPort() string { + if c.PublicPort != "" { + return c.BaseDomain + ":" + c.PublicPort + } + return c.BaseDomain +} + +func env(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return def +} + +func envBool(key string, def bool) bool { + v, ok := os.LookupEnv(key) + if !ok { + return def + } + switch strings.ToLower(v) { + case "1", "true", "yes", "on": + return true + } + return false +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..44d489b --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,43 @@ +// Package db opens the Postgres pool and applies embedded migrations. +package db + +import ( + "context" + "database/sql" + "embed" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +//go:embed migrations/*.sql +var migrations embed.FS + +func Open(ctx context.Context, url string) (*pgxpool.Pool, error) { + pool, err := pgxpool.New(ctx, url) + if err != nil { + return nil, fmt.Errorf("connect: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping: %w", err) + } + return pool, nil +} + +// Migrate applies all pending migrations using goose over database/sql. +func Migrate(ctx context.Context, url string) error { + sqldb, err := sql.Open("pgx", url) + if err != nil { + return err + } + defer sqldb.Close() + goose.SetBaseFS(migrations) + goose.SetLogger(goose.NopLogger()) + if err := goose.SetDialect("postgres"); err != nil { + return err + } + return goose.UpContext(ctx, sqldb, "migrations") +} diff --git a/internal/db/migrations/00001_init.sql b/internal/db/migrations/00001_init.sql new file mode 100644 index 0000000..e27dad4 --- /dev/null +++ b/internal/db/migrations/00001_init.sql @@ -0,0 +1,68 @@ +-- +goose Up +CREATE TABLE users ( + id bigserial PRIMARY KEY, + username text NOT NULL UNIQUE, + password_hash text NOT NULL, + role text NOT NULL CHECK (role IN ('superadmin', 'blogger')), + disabled boolean NOT NULL DEFAULT false, + token_version integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE blogs ( + id bigserial PRIMARY KEY, + owner_id bigint NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + subdomain text NOT NULL UNIQUE CHECK (subdomain ~ '^[a-z0-9](-?[a-z0-9]){0,62}$'), + title text NOT NULL, + tagline text NOT NULL DEFAULT '', + theme jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE pages ( + id bigserial PRIMARY KEY, + blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, + slug text NOT NULL, + title text NOT NULL, + intro_md text NOT NULL DEFAULT '', + intro_html text NOT NULL DEFAULT '', + nav_order integer NOT NULL DEFAULT 0, + show_in_nav boolean NOT NULL DEFAULT true, + is_home boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (blog_id, slug) +); +CREATE UNIQUE INDEX pages_one_home_per_blog ON pages (blog_id) WHERE is_home; + +CREATE TABLE posts ( + id bigserial PRIMARY KEY, + page_id bigint NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + slug text NOT NULL, + title text NOT NULL, + body_md text NOT NULL DEFAULT '', + body_html text NOT NULL DEFAULT '', + published boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (page_id, slug) +); +CREATE INDEX posts_page_created ON posts (page_id, created_at DESC); + +CREATE TABLE images ( + id uuid PRIMARY KEY, + blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE, + filename text NOT NULL, + content_type text NOT NULL, + size integer NOT NULL, + data bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX images_blog ON images (blog_id, created_at DESC); + +-- +goose Down +DROP TABLE images; +DROP TABLE posts; +DROP TABLE pages; +DROP TABLE blogs; +DROP TABLE users; diff --git a/internal/markdown/render.go b/internal/markdown/render.go new file mode 100644 index 0000000..f9a2bfd --- /dev/null +++ b/internal/markdown/render.go @@ -0,0 +1,38 @@ +// Package markdown renders untrusted Markdown to sanitized HTML. +package markdown + +import ( + "bytes" + + "github.com/microcosm-cc/bluemonday" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer/html" +) + +var md = goldmark.New( + goldmark.WithExtensions(extension.GFM, extension.Typographer), + goldmark.WithParserOptions(parser.WithAutoHeadingID()), + goldmark.WithRendererOptions(html.WithHardWraps(), html.WithUnsafe()), // unsafe output is sanitized below +) + +var policy = func() *bluemonday.Policy { + p := bluemonday.UGCPolicy() + p.AllowAttrs("id").OnElements("h1", "h2", "h3", "h4", "h5", "h6") + p.AllowAttrs("class").Matching(bluemonday.SpaceSeparatedTokens).OnElements("code", "pre", "span", "div", "table", "input", "li", "ul") + p.AllowAttrs("type", "checked", "disabled").OnElements("input") + p.AllowAttrs("align").OnElements("th", "td") + p.AllowAttrs("width", "height").OnElements("img") + p.RequireNoFollowOnLinks(false) + return p +}() + +// Render converts Markdown to HTML that is safe to embed unescaped. +func Render(src string) string { + var buf bytes.Buffer + if err := md.Convert([]byte(src), &buf); err != nil { + return "

(could not render content)

" + } + return policy.Sanitize(buf.String()) +} diff --git a/internal/markdown/render_test.go b/internal/markdown/render_test.go new file mode 100644 index 0000000..7240afc --- /dev/null +++ b/internal/markdown/render_test.go @@ -0,0 +1,29 @@ +package markdown + +import ( + "strings" + "testing" +) + +func TestRenderSanitizes(t *testing.T) { + out := Render("# Hi\n\n**bold** x ") + for _, bad := range []string{"bold", `", "
", ` 80 { + s = strings.Trim(s[:80], "-") + } + if s == "" { + return "untitled" + } + return s +} + +// WithSuffix returns "base-n" (or base when n < 2), used to resolve collisions. +func WithSuffix(base string, n int) string { + if n < 2 { + return base + } + return fmt.Sprintf("%s-%d", base, n) +} + +// Valid reports whether s is an acceptable slug as typed by a user. +func Valid(s string) bool { return validRe.MatchString(s) && len(s) <= 80 } diff --git a/internal/slug/slug_test.go b/internal/slug/slug_test.go new file mode 100644 index 0000000..9953f34 --- /dev/null +++ b/internal/slug/slug_test.go @@ -0,0 +1,34 @@ +package slug + +import "testing" + +func TestMake(t *testing.T) { + cases := map[string]string{ + "Hello, World!": "hello-world", + " Ünïcödé Tïtle ": "unicode-title", + "!!!": "untitled", + "": "untitled", + "already-a-slug": "already-a-slug", + "Multiple spaces--": "multiple-spaces", + "Καλημέρα κόσμε": "kalimera-kosme", + "日本語": "untitled", // unsupported scripts fall back; user can type a slug + } + for in, want := range cases { + if got := Make(in); got != want { + t.Errorf("Make(%q) = %q, want %q", in, got, want) + } + } +} + +func TestValid(t *testing.T) { + for _, ok := range []string{"a", "abc-def", "a1-b2"} { + if !Valid(ok) { + t.Errorf("Valid(%q) = false", ok) + } + } + for _, bad := range []string{"", "-a", "a-", "a--b", "A", "a b", "a/b"} { + if Valid(bad) { + t.Errorf("Valid(%q) = true", bad) + } + } +} diff --git a/internal/store/blogs.go b/internal/store/blogs.go new file mode 100644 index 0000000..57a7c10 --- /dev/null +++ b/internal/store/blogs.go @@ -0,0 +1,84 @@ +package store + +import ( + "context" + "encoding/json" + "time" +) + +type Blog struct { + ID int64 + OwnerID int64 + Subdomain string + Title string + Tagline string + ThemeJSON json.RawMessage + CreatedAt time.Time + UpdatedAt time.Time +} + +const blogCols = `id, owner_id, subdomain, title, tagline, theme, created_at, updated_at` + +func scanBlog(row interface{ Scan(...any) error }) (*Blog, error) { + var b Blog + err := row.Scan(&b.ID, &b.OwnerID, &b.Subdomain, &b.Title, &b.Tagline, &b.ThemeJSON, &b.CreatedAt, &b.UpdatedAt) + if err != nil { + return nil, wrap(err) + } + return &b, nil +} + +// CreateBlogger creates a user, their blog and a default home page in one transaction. +func (s *Store) CreateBlogger(ctx context.Context, username, passwordHash, subdomain, title string) (*User, *Blog, error) { + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, nil, err + } + defer tx.Rollback(ctx) + + u, err := scanUser(tx.QueryRow(ctx, `INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING `+userCols, + username, passwordHash, RoleBlogger)) + if err != nil { + return nil, nil, err + } + b, err := scanBlog(tx.QueryRow(ctx, `INSERT INTO blogs (owner_id, subdomain, title) VALUES ($1,$2,$3) RETURNING `+blogCols, + u.ID, subdomain, title)) + if err != nil { + return nil, nil, err + } + _, err = tx.Exec(ctx, `INSERT INTO pages (blog_id, slug, title, nav_order, is_home) VALUES ($1,'home','Home',0,true)`, b.ID) + if err != nil { + return nil, nil, wrap(err) + } + if err := tx.Commit(ctx); err != nil { + return nil, nil, err + } + return u, b, nil +} + +func (s *Store) BlogByID(ctx context.Context, id int64) (*Blog, error) { + return scanBlog(s.db.QueryRow(ctx, `SELECT `+blogCols+` FROM blogs WHERE id=$1`, id)) +} + +func (s *Store) BlogBySubdomain(ctx context.Context, sub string) (*Blog, error) { + return scanBlog(s.db.QueryRow(ctx, `SELECT `+blogCols+` FROM blogs WHERE subdomain=$1`, sub)) +} + +func (s *Store) BlogByOwner(ctx context.Context, ownerID int64) (*Blog, error) { + return scanBlog(s.db.QueryRow(ctx, `SELECT `+blogCols+` FROM blogs WHERE owner_id=$1`, ownerID)) +} + +func (s *Store) UpdateBlogSettings(ctx context.Context, id int64, title, tagline string) error { + _, err := s.db.Exec(ctx, `UPDATE blogs SET title=$2, tagline=$3, updated_at=now() WHERE id=$1`, id, title, tagline) + return err +} + +func (s *Store) UpdateBlogTheme(ctx context.Context, id int64, theme json.RawMessage) error { + _, err := s.db.Exec(ctx, `UPDATE blogs SET theme=$2, updated_at=now() WHERE id=$1`, id, theme) + return err +} + +func (s *Store) DeleteBlog(ctx context.Context, id int64) error { + _, err := s.db.Exec(ctx, `DELETE FROM blogs WHERE id=$1`, id) + return err +} diff --git a/internal/store/images.go b/internal/store/images.go new file mode 100644 index 0000000..3699e75 --- /dev/null +++ b/internal/store/images.go @@ -0,0 +1,58 @@ +package store + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +type Image struct { + ID uuid.UUID + BlogID int64 + Filename string + ContentType string + Size int + Data []byte // only populated by ImageData + CreatedAt time.Time +} + +func (s *Store) CreateImage(ctx context.Context, blogID int64, filename, contentType string, data []byte) (*Image, error) { + img := &Image{ID: uuid.New(), BlogID: blogID, Filename: filename, ContentType: contentType, Size: len(data)} + err := s.db.QueryRow(ctx, `INSERT INTO images (id, blog_id, filename, content_type, size, data) VALUES ($1,$2,$3,$4,$5,$6) RETURNING created_at`, + img.ID, blogID, filename, contentType, len(data), data).Scan(&img.CreatedAt) + return img, err +} + +func (s *Store) ListImages(ctx context.Context, blogID int64) ([]Image, error) { + rows, err := s.db.Query(ctx, `SELECT id, blog_id, filename, content_type, size, created_at FROM images WHERE blog_id=$1 ORDER BY created_at DESC`, blogID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Image + for rows.Next() { + var i Image + if err := rows.Scan(&i.ID, &i.BlogID, &i.Filename, &i.ContentType, &i.Size, &i.CreatedAt); err != nil { + return nil, err + } + out = append(out, i) + } + return out, rows.Err() +} + +// ImageData loads an image including its bytes. +func (s *Store) ImageData(ctx context.Context, id uuid.UUID) (*Image, error) { + var i Image + err := s.db.QueryRow(ctx, `SELECT id, blog_id, filename, content_type, size, data, created_at FROM images WHERE id=$1`, id). + Scan(&i.ID, &i.BlogID, &i.Filename, &i.ContentType, &i.Size, &i.Data, &i.CreatedAt) + if err != nil { + return nil, wrap(err) + } + return &i, nil +} + +func (s *Store) DeleteImage(ctx context.Context, blogID int64, id uuid.UUID) error { + _, err := s.db.Exec(ctx, `DELETE FROM images WHERE blog_id=$1 AND id=$2`, blogID, id) + return err +} diff --git a/internal/store/pages.go b/internal/store/pages.go new file mode 100644 index 0000000..87d440f --- /dev/null +++ b/internal/store/pages.go @@ -0,0 +1,124 @@ +package store + +import ( + "context" + "time" +) + +type Page struct { + ID int64 + BlogID int64 + Slug string + Title string + IntroMD string + IntroHTML string + NavOrder int + ShowInNav bool + IsHome bool + CreatedAt time.Time + PostCount int // filled by ListPages only +} + +const pageCols = `id, blog_id, slug, title, intro_md, intro_html, nav_order, show_in_nav, is_home, created_at` + +func scanPage(row interface{ Scan(...any) error }) (*Page, error) { + var p Page + err := row.Scan(&p.ID, &p.BlogID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt) + if err != nil { + return nil, wrap(err) + } + return &p, nil +} + +func (s *Store) ListPages(ctx context.Context, blogID int64) ([]Page, error) { + rows, err := s.db.Query(ctx, `SELECT `+pageCols+`, (SELECT count(*) FROM posts WHERE page_id=pages.id) + FROM pages WHERE blog_id=$1 ORDER BY nav_order, id`, blogID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Page + for rows.Next() { + var p Page + if err := rows.Scan(&p.ID, &p.BlogID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (s *Store) PageByID(ctx context.Context, blogID, id int64) (*Page, error) { + return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 AND id=$2`, blogID, id)) +} + +func (s *Store) PageBySlug(ctx context.Context, blogID int64, slug string) (*Page, error) { + return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 AND slug=$2`, blogID, slug)) +} + +func (s *Store) HomePage(ctx context.Context, blogID int64) (*Page, error) { + return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 ORDER BY is_home DESC, nav_order, id LIMIT 1`, blogID)) +} + +func (s *Store) CreatePage(ctx context.Context, p *Page) (*Page, error) { + return scanPage(s.db.QueryRow(ctx, `INSERT INTO pages (blog_id, slug, title, intro_md, intro_html, nav_order, show_in_nav) + VALUES ($1,$2,$3,$4,$5,(SELECT coalesce(max(nav_order),-1)+1 FROM pages WHERE blog_id=$1),$6) RETURNING `+pageCols, + p.BlogID, p.Slug, p.Title, p.IntroMD, p.IntroHTML, p.ShowInNav)) +} + +func (s *Store) UpdatePage(ctx context.Context, p *Page) error { + _, err := s.db.Exec(ctx, `UPDATE pages SET slug=$3, title=$4, intro_md=$5, intro_html=$6, show_in_nav=$7 WHERE blog_id=$1 AND id=$2`, + p.BlogID, p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML, p.ShowInNav) + return wrap(err) +} + +func (s *Store) DeletePage(ctx context.Context, blogID, id int64) error { + _, err := s.db.Exec(ctx, `DELETE FROM pages WHERE blog_id=$1 AND id=$2 AND NOT is_home`, blogID, id) + return err +} + +// SetHomePage moves the home flag to the given page. +func (s *Store) SetHomePage(ctx context.Context, blogID, id int64) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=false WHERE blog_id=$1 AND is_home`, blogID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=true WHERE blog_id=$1 AND id=$2`, blogID, id); err != nil { + return err + } + return tx.Commit(ctx) +} + +// MovePage swaps nav_order with the neighbouring page (dir = -1 up, +1 down). +func (s *Store) MovePage(ctx context.Context, blogID, id int64, dir int) error { + pages, err := s.ListPages(ctx, blogID) + if err != nil { + return err + } + idx := -1 + for i := range pages { + if pages[i].ID == id { + idx = i + } + } + j := idx + dir + if idx < 0 || j < 0 || j >= len(pages) { + return nil + } + pages[idx], pages[j] = pages[j], pages[idx] + tx, err := s.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + for i, p := range pages { // renumber everything; keeps orders dense + if _, err := tx.Exec(ctx, `UPDATE pages SET nav_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, p.ID, i); err != nil { + return err + } + } + return tx.Commit(ctx) +} diff --git a/internal/store/posts.go b/internal/store/posts.go new file mode 100644 index 0000000..06bea7f --- /dev/null +++ b/internal/store/posts.go @@ -0,0 +1,105 @@ +package store + +import ( + "context" + "time" +) + +type Post struct { + ID int64 + PageID int64 + Slug string + Title string + BodyMD string + BodyHTML string + Published bool + CreatedAt time.Time + UpdatedAt time.Time + // joined + PageSlug string + PageTitle string +} + +const postCols = `p.id, p.page_id, p.slug, p.title, p.body_md, p.body_html, p.published, p.created_at, p.updated_at, g.slug, g.title` + +func scanPost(row interface{ Scan(...any) error }) (*Post, error) { + var p Post + err := row.Scan(&p.ID, &p.PageID, &p.Slug, &p.Title, &p.BodyMD, &p.BodyHTML, &p.Published, &p.CreatedAt, &p.UpdatedAt, &p.PageSlug, &p.PageTitle) + if err != nil { + return nil, wrap(err) + } + return &p, nil +} + +func (s *Store) collectPosts(ctx context.Context, q string, args ...any) ([]Post, error) { + rows, err := s.db.Query(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Post + for rows.Next() { + p, err := scanPost(rows) + if err != nil { + return nil, err + } + out = append(out, *p) + } + return out, rows.Err() +} + +// ListPosts returns all posts of a blog for the dashboard, optionally filtered by page. +func (s *Store) ListPosts(ctx context.Context, blogID int64, pageID int64) ([]Post, error) { + return s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id + WHERE g.blog_id=$1 AND ($2=0 OR p.page_id=$2) ORDER BY p.created_at DESC, p.id DESC`, blogID, pageID) +} + +// PublishedPosts returns a page of published posts for the public site. +func (s *Store) PublishedPosts(ctx context.Context, pageID int64, limit, offset int) ([]Post, int, error) { + posts, err := s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id + WHERE p.page_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $2 OFFSET $3`, pageID, limit, offset) + if err != nil { + return nil, 0, err + } + var total int + err = s.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE page_id=$1 AND published`, pageID).Scan(&total) + return posts, total, err +} + +// RecentPublishedPosts returns the newest published posts across a whole blog (for feeds). +func (s *Store) RecentPublishedPosts(ctx context.Context, blogID int64, limit int) ([]Post, error) { + return s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id + WHERE g.blog_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $2`, blogID, limit) +} + +func (s *Store) PostByID(ctx context.Context, blogID, id int64) (*Post, error) { + return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id + WHERE g.blog_id=$1 AND p.id=$2`, blogID, id)) +} + +func (s *Store) PublishedPostBySlug(ctx context.Context, pageID int64, slug string) (*Post, error) { + return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id + WHERE p.page_id=$1 AND p.slug=$2 AND p.published`, pageID, slug)) +} + +func (s *Store) CreatePost(ctx context.Context, p *Post) (*Post, error) { + var id int64 + err := s.db.QueryRow(ctx, `INSERT INTO posts (page_id, slug, title, body_md, body_html, published) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, + p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, p.Published).Scan(&id) + if err != nil { + return nil, wrap(err) + } + return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id WHERE p.id=$1`, id)) +} + +// UpdatePost updates a post; the page must belong to the same blog (checked by handler). +func (s *Store) UpdatePost(ctx context.Context, p *Post) error { + _, err := s.db.Exec(ctx, `UPDATE posts SET page_id=$2, slug=$3, title=$4, body_md=$5, body_html=$6, published=$7, updated_at=now() WHERE id=$1`, + p.ID, p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, p.Published) + return wrap(err) +} + +func (s *Store) DeletePost(ctx context.Context, blogID, id int64) error { + _, err := s.db.Exec(ctx, `DELETE FROM posts p USING pages g WHERE g.id=p.page_id AND g.blog_id=$1 AND p.id=$2`, blogID, id) + return err +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..472bb2a --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,34 @@ +// Package store holds the data models and all SQL queries. +package store + +import ( + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrNotFound = errors.New("not found") +var ErrConflict = errors.New("already exists") + +type Store struct { + db *pgxpool.Pool +} + +func New(db *pgxpool.Pool) *Store { return &Store{db: db} } + +// wrap maps driver errors onto the store's sentinel errors. +func wrap(err error) error { + if err == nil { + return nil + } + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation + return ErrConflict + } + return err +} diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..2910888 --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,99 @@ +package store + +import ( + "context" + "time" +) + +const ( + RoleSuperadmin = "superadmin" + RoleBlogger = "blogger" +) + +type User struct { + ID int64 + Username string + PasswordHash string + Role string + Disabled bool + TokenVersion int + CreatedAt time.Time +} + +func (u *User) IsSuperadmin() bool { return u.Role == RoleSuperadmin } + +const userCols = `id, username, password_hash, role, disabled, token_version, created_at` + +func scanUser(row interface{ Scan(...any) error }) (*User, error) { + var u User + err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Disabled, &u.TokenVersion, &u.CreatedAt) + if err != nil { + return nil, wrap(err) + } + return &u, nil +} + +func (s *Store) CreateUser(ctx context.Context, username, passwordHash, role string) (*User, error) { + row := s.db.QueryRow(ctx, `INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING `+userCols, + username, passwordHash, role) + return scanUser(row) +} + +func (s *Store) UserByID(ctx context.Context, id int64) (*User, error) { + return scanUser(s.db.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE id=$1`, id)) +} + +func (s *Store) UserByUsername(ctx context.Context, username string) (*User, error) { + return scanUser(s.db.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE username=$1`, username)) +} + +func (s *Store) CountSuperadmins(ctx context.Context) (int, error) { + var n int + err := s.db.QueryRow(ctx, `SELECT count(*) FROM users WHERE role=$1`, RoleSuperadmin).Scan(&n) + return n, err +} + +// SetPassword replaces the hash and bumps token_version so existing sessions die. +func (s *Store) SetPassword(ctx context.Context, id int64, passwordHash string) error { + _, err := s.db.Exec(ctx, `UPDATE users SET password_hash=$2, token_version=token_version+1 WHERE id=$1`, id, passwordHash) + return err +} + +func (s *Store) SetUserDisabled(ctx context.Context, id int64, disabled bool) error { + _, err := s.db.Exec(ctx, `UPDATE users SET disabled=$2, token_version=token_version+1 WHERE id=$1`, id, disabled) + return err +} + +func (s *Store) DeleteUser(ctx context.Context, id int64) error { + _, err := s.db.Exec(ctx, `DELETE FROM users WHERE id=$1`, id) + return err +} + +// UserWithBlog is a row for the admin overview. +type UserWithBlog struct { + User + BlogID *int64 + Subdomain *string + BlogTitle *string +} + +func (s *Store) ListUsers(ctx context.Context) ([]UserWithBlog, error) { + rows, err := s.db.Query(ctx, `SELECT u.id, u.username, u.password_hash, u.role, u.disabled, u.token_version, u.created_at, + b.id, b.subdomain, b.title + FROM users u LEFT JOIN blogs b ON b.owner_id = u.id + ORDER BY u.role, u.username`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []UserWithBlog + for rows.Next() { + var r UserWithBlog + if err := rows.Scan(&r.ID, &r.Username, &r.PasswordHash, &r.Role, &r.Disabled, &r.TokenVersion, &r.CreatedAt, + &r.BlogID, &r.Subdomain, &r.BlogTitle); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go new file mode 100644 index 0000000..30fdc2e --- /dev/null +++ b/internal/web/handlers_admin.go @@ -0,0 +1,154 @@ +package web + +import ( + "errors" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/gramanas/blogspace/internal/auth" + "github.com/gramanas/blogspace/internal/store" +) + +var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]{2,40}$`) +var subdomainRe = regexp.MustCompile(`^[a-z0-9](-?[a-z0-9]){0,62}$`) + +func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { + users, err := s.st.ListUsers(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "admin/index.html", map[string]any{"users": users, "cfg": s.cfg}) +} + +func (s *Server) handleAdminNewUserForm(w http.ResponseWriter, r *http.Request) { + s.render(w, r, "admin/new_user.html", map[string]any{"cfg": s.cfg}) +} + +func (s *Server) handleAdminNewUser(w http.ResponseWriter, r *http.Request) { + f := map[string]any{ + "username": strings.TrimSpace(r.FormValue("username")), + "subdomain": strings.ToLower(strings.TrimSpace(r.FormValue("subdomain"))), + "title": strings.TrimSpace(r.FormValue("title")), + "cfg": s.cfg, + } + pw := r.FormValue("password") + username, sub, title := f["username"].(string), f["subdomain"].(string), f["title"].(string) + if sub == "" { + sub = strings.ToLower(username) + f["subdomain"] = sub + } + if title == "" { + title = username + "'s blog" + } + var msg string + switch { + case !usernameRe.MatchString(username): + msg = "Username: 2-40 letters, digits, dots, dashes or underscores." + case len(pw) < 8: + msg = "Password must be at least 8 characters." + case !subdomainRe.MatchString(sub) || reservedSubdomains[sub]: + msg = "Subdomain: lowercase letters, digits and single dashes; not a reserved name." + } + if msg != "" { + f["error"] = msg + s.renderStatus(w, r, http.StatusBadRequest, "admin/new_user.html", f) + return + } + hash, err := auth.HashPassword(pw) + if err != nil { + s.serverError(w, err) + return + } + _, blog, err := s.st.CreateBlogger(r.Context(), username, hash, sub, title) + if err != nil { + if errors.Is(err, store.ErrConflict) { + f["error"] = "Username or subdomain already taken." + s.renderStatus(w, r, http.StatusConflict, "admin/new_user.html", f) + return + } + s.serverError(w, err) + return + } + redirectOK(w, r, "/admin/", "Created "+username+" with blog "+blog.Subdomain+".") +} + +func (s *Server) adminTargetUser(w http.ResponseWriter, r *http.Request) *store.User { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + u, err := s.st.UserByID(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + } else { + s.serverError(w, err) + } + return nil + } + if u.ID == currentUser(r).ID { + s.plainError(w, http.StatusBadRequest, "You cannot do that to your own account here; use Account > Password.") + return nil + } + return u +} + +func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) { + u := s.adminTargetUser(w, r) + if u == nil { + return + } + pw := r.FormValue("password") + if len(pw) < 8 { + s.plainError(w, http.StatusBadRequest, "Password must be at least 8 characters.") + return + } + hash, err := auth.HashPassword(pw) + if err != nil { + s.serverError(w, err) + return + } + if err := s.st.SetPassword(r.Context(), u.ID, hash); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/admin/", "Password reset for "+u.Username+".") +} + +func (s *Server) handleAdminSetDisabled(disabled bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u := s.adminTargetUser(w, r) + if u == nil { + return + } + if err := s.st.SetUserDisabled(r.Context(), u.ID, disabled); err != nil { + s.serverError(w, err) + return + } + verb := "enabled" + if disabled { + verb = "disabled" + } + redirectOK(w, r, "/admin/", u.Username+" "+verb+".") + } +} + +func (s *Server) handleAdminDeleteUserConfirm(w http.ResponseWriter, r *http.Request) { + u := s.adminTargetUser(w, r) + if u == nil { + return + } + s.render(w, r, "admin/delete_user.html", map[string]any{"target": u}) +} + +func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) { + u := s.adminTargetUser(w, r) + if u == nil { + return + } + if err := s.st.DeleteUser(r.Context(), u.ID); err != nil { // cascades to blog, pages, posts, images + s.serverError(w, err) + return + } + redirectOK(w, r, "/admin/", "Deleted "+u.Username+" and their blog.") +} diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go new file mode 100644 index 0000000..4554d94 --- /dev/null +++ b/internal/web/handlers_auth.go @@ -0,0 +1,131 @@ +package web + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/gramanas/blogspace/internal/auth" + "github.com/gramanas/blogspace/internal/store" +) + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if currentUser(r) != nil { + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { + if currentUser(r) != nil { + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + return + } + s.render(w, r, "auth/login.html", map[string]any{"next": safeNext(r.URL.Query().Get("next"))}) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + next := safeNext(r.FormValue("next")) + fail := func() { + s.renderStatus(w, r, http.StatusUnauthorized, "auth/login.html", + map[string]any{"error": "Wrong username or password.", "username": username, "next": next}) + } + u, err := s.st.UserByUsername(r.Context(), username) + if err != nil { + if !errors.Is(err, store.ErrNotFound) { + s.serverError(w, err) + return + } + auth.CheckPassword("$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinval", password) // constant-ish time + fail() + return + } + if u.Disabled || !auth.CheckPassword(u.PasswordHash, password) { + fail() + return + } + tok, err := auth.IssueToken(s.cfg.JWTSecret, u.ID, u.TokenVersion, time.Now()) + if err != nil { + s.serverError(w, err) + return + } + auth.SetSessionCookie(w, tok) + if next == "" { + next = "/dashboard" + } + http.Redirect(w, r, next, http.StatusSeeOther) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + auth.ClearSessionCookie(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if u.IsSuperadmin() { + http.Redirect(w, r, "/admin/", http.StatusSeeOther) + return + } + blog, err := s.st.BlogByOwner(r.Context(), u.ID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + s.plainError(w, http.StatusNotFound, "You have no blog yet. Ask the administrator to create one.") + return + } + s.serverError(w, err) + return + } + http.Redirect(w, r, "/b/"+blog.Subdomain+"/", http.StatusSeeOther) +} + +func (s *Server) handlePasswordForm(w http.ResponseWriter, r *http.Request) { + s.render(w, r, "dashboard/password.html", nil) +} + +func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + cur, pw, pw2 := r.FormValue("current"), r.FormValue("password"), r.FormValue("password2") + var msg string + switch { + case !auth.CheckPassword(u.PasswordHash, cur): + msg = "Current password is wrong." + case len(pw) < 8: + msg = "New password must be at least 8 characters." + case pw != pw2: + msg = "New passwords do not match." + } + if msg != "" { + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/password.html", map[string]any{"error": msg}) + return + } + hash, err := auth.HashPassword(pw) + if err != nil { + s.serverError(w, err) + return + } + if err := s.st.SetPassword(r.Context(), u.ID, hash); err != nil { + s.serverError(w, err) + return + } + // token_version changed, so re-issue the session instead of logging the user out + tok, err := auth.IssueToken(s.cfg.JWTSecret, u.ID, u.TokenVersion+1, time.Now()) + if err != nil { + s.serverError(w, err) + return + } + auth.SetSessionCookie(w, tok) + redirectOK(w, r, "/dashboard", "Password changed.") +} + +// safeNext only allows local paths as post-login redirect targets. +func safeNext(n string) string { + if strings.HasPrefix(n, "/") && !strings.HasPrefix(n, "//") { + return n + } + return "" +} diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go new file mode 100644 index 0000000..b768c4d --- /dev/null +++ b/internal/web/handlers_blog.go @@ -0,0 +1,150 @@ +package web + +import ( + "encoding/xml" + "errors" + "net/http" + "strconv" + "time" + + "github.com/gramanas/blogspace/internal/store" +) + +const postsPerPage = 10 + +// blogView gathers what every public page needs: theme css, nav, the blog. +func (s *Server) blogView(r *http.Request) (map[string]any, error) { + blog := currentBlog(r) + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + return nil, err + } + theme := ParseTheme(blog.ThemeJSON) + var nav []store.Page + for _, p := range pages { + if p.ShowInNav && (theme.NavShowHome || !p.IsHome) { + nav = append(nav, p) + } + } + return map[string]any{"theme": theme, "css": theme.CSS(), "nav": nav}, nil +} + +func (s *Server) handleBlogHome(w http.ResponseWriter, r *http.Request) { + page, err := s.st.HomePage(r.Context(), currentBlog(r).ID) + if err != nil { + s.blogNotFound(w, r) + return + } + s.renderPage(w, r, page) +} + +func (s *Server) handleBlogPage(w http.ResponseWriter, r *http.Request) { + page, err := s.st.PageBySlug(r.Context(), currentBlog(r).ID, r.PathValue("page")) + if err != nil { + s.blogNotFound(w, r) + return + } + if page.IsHome { // canonical URL for the home page is / + http.Redirect(w, r, "/", http.StatusMovedPermanently) + return + } + s.renderPage(w, r, page) +} + +func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store.Page) { + v, err := s.blogView(r) + if err != nil { + s.serverError(w, err) + return + } + n, _ := strconv.Atoi(r.URL.Query().Get("p")) + if n < 1 { + n = 1 + } + posts, total, err := s.st.PublishedPosts(r.Context(), page.ID, postsPerPage, (n-1)*postsPerPage) + if err != nil { + s.serverError(w, err) + return + } + last := (total + postsPerPage - 1) / postsPerPage + v["page"], v["posts"], v["pageNum"], v["lastPage"] = page, posts, n, last + v["base"] = "/" + page.Slug + if page.IsHome { + v["base"] = "" + } + s.render(w, r, "blog/page.html", v) +} + +func (s *Server) handleBlogPost(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + page, err := s.st.PageBySlug(r.Context(), blog.ID, r.PathValue("page")) + if err != nil { + s.blogNotFound(w, r) + return + } + post, err := s.st.PublishedPostBySlug(r.Context(), page.ID, r.PathValue("post")) + if err != nil { + s.blogNotFound(w, r) + return + } + v, err := s.blogView(r) + if err != nil { + s.serverError(w, err) + return + } + v["page"], v["post"] = page, post + s.render(w, r, "blog/post.html", v) +} + +func (s *Server) blogNotFound(w http.ResponseWriter, r *http.Request) { + v, err := s.blogView(r) + if err != nil { + s.serverError(w, err) + return + } + s.renderStatus(w, r, http.StatusNotFound, "blog/404.html", v) +} + +// ---- RSS --------------------------------------------------------------------- + +type rss struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Channel struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Items []rssItem `xml:"item"` + } `xml:"channel"` +} + +type rssItem struct { + Title string `xml:"title"` + Link string `xml:"link"` + GUID string `xml:"guid"` + PubDate string `xml:"pubDate"` + Desc string `xml:"description"` +} + +func (s *Server) handleBlogFeed(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + posts, err := s.st.RecentPublishedPosts(r.Context(), blog.ID, 30) + if err != nil { + s.serverError(w, err) + return + } + base := s.cfg.BlogURL(blog.Subdomain) + var f rss + f.Version = "2.0" + f.Channel.Title, f.Channel.Link, f.Channel.Description = blog.Title, base, blog.Tagline + for _, p := range posts { + link := base + "/" + p.PageSlug + "/" + p.Slug + f.Channel.Items = append(f.Channel.Items, rssItem{Title: p.Title, Link: link, GUID: link, + PubDate: p.CreatedAt.Format(time.RFC1123Z), Desc: p.BodyHTML}) + } + w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") + w.Write([]byte(xml.Header)) + if err := xml.NewEncoder(w).Encode(f); err != nil && !errors.Is(err, http.ErrHandlerTimeout) { + return + } +} diff --git a/internal/web/handlers_dashboard.go b/internal/web/handlers_dashboard.go new file mode 100644 index 0000000..f210891 --- /dev/null +++ b/internal/web/handlers_dashboard.go @@ -0,0 +1,43 @@ +package web + +import ( + "net/http" + "strings" +) + +func (s *Server) handleBlogOverview(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + posts, err := s.st.ListPosts(r.Context(), blog.ID, 0) + if err != nil { + s.serverError(w, err) + return + } + if len(posts) > 5 { + posts = posts[:5] + } + s.render(w, r, "dashboard/overview.html", map[string]any{"pages": pages, "posts": posts}) +} + +func (s *Server) handleSettingsForm(w http.ResponseWriter, r *http.Request) { + s.render(w, r, "dashboard/settings.html", nil) +} + +func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + title := strings.TrimSpace(r.FormValue("title")) + tagline := strings.TrimSpace(r.FormValue("tagline")) + if title == "" || len(title) > 120 || len(tagline) > 300 { + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/settings.html", map[string]any{"error": "Title is required (max 120 chars); tagline max 300."}) + return + } + if err := s.st.UpdateBlogSettings(r.Context(), blog.ID, title, tagline); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/settings", "Saved. Refresh your blog to see it.") +} diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go new file mode 100644 index 0000000..601ba34 --- /dev/null +++ b/internal/web/handlers_design.go @@ -0,0 +1,155 @@ +package web + +import ( + "bytes" + "errors" + "io" + "mime/multipart" + "net/http" + "path/filepath" + "strconv" + + "github.com/google/uuid" + "github.com/gramanas/blogspace/internal/store" +) + +var allowedImageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true} + +func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + images, err := s.st.ListImages(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "dashboard/design.html", map[string]any{"theme": ParseTheme(blog.ThemeJSON), "images": images}) +} + +func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { + s.plainError(w, http.StatusBadRequest, "Upload too large or malformed form.") + return + } + theme := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form) + // Optional direct uploads from the design form. + for field, dst := range map[string]*string{"bg_image_file": &theme.BgImage, "header_image_file": &theme.HeaderImage} { + img, err := s.readUpload(r, field) + if err != nil { + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", map[string]any{"theme": theme, "error": err.Error()}) + return + } + if img != nil { + *dst = img.ID.String() + } + } + if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Design saved. Refresh your blog to see it.") +} + +// readUpload stores the file from a multipart field, returning nil if the field is empty. +func (s *Server) readUpload(r *http.Request, field string) (*store.Image, error) { + if r.MultipartForm == nil { + return nil, nil + } + fhs := r.MultipartForm.File[field] + if len(fhs) == 0 { + return nil, nil + } + return s.storeUpload(r, fhs[0]) +} + +func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader) (*store.Image, error) { + if fh.Size > s.cfg.MaxUploadBytes { + return nil, errors.New("image is too large (max " + kbString(s.cfg.MaxUploadBytes) + ")") + } + f, err := fh.Open() + if err != nil { + return nil, err + } + defer f.Close() + var buf bytes.Buffer + if _, err := io.CopyN(&buf, f, s.cfg.MaxUploadBytes+1); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + if int64(buf.Len()) > s.cfg.MaxUploadBytes { + return nil, errors.New("image is too large (max " + kbString(s.cfg.MaxUploadBytes) + ")") + } + ct := http.DetectContentType(buf.Bytes()) + if !allowedImageTypes[ct] { + return nil, errors.New("only PNG, JPEG, GIF and WebP images are accepted") + } + name := filepath.Base(fh.Filename) + if name == "" || name == "." || len(name) > 120 { + name = "image" + } + return s.st.CreateImage(r.Context(), currentBlog(r).ID, name, ct, buf.Bytes()) +} + +func kbString(n int64) string { + if n >= 1<<20 { + return strconv.FormatInt(n>>20, 10) + " MB" + } + return strconv.FormatInt(n>>10, 10) + " KB" +} + +// ---- image library --------------------------------------------------------- + +func (s *Server) handleImages(w http.ResponseWriter, r *http.Request) { + images, err := s.st.ListImages(r.Context(), currentBlog(r).ID) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "dashboard/images.html", map[string]any{"images": images}) +} + +func (s *Server) handleImageUpload(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + if err := r.ParseMultipartForm(1 << 20); err != nil { + s.plainError(w, http.StatusBadRequest, "Upload too large or malformed form.") + return + } + img, err := s.readUpload(r, "file") + if err != nil { + s.plainError(w, http.StatusBadRequest, err.Error()) + return + } + if img == nil { + s.plainError(w, http.StatusBadRequest, "Choose a file first.") + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/images", "Uploaded "+img.Filename+".") +} + +func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + http.NotFound(w, r) + return + } + if err := s.st.DeleteImage(r.Context(), blog.ID, id); err != nil { + s.serverError(w, err) + return + } + // Drop dangling references from the theme. + theme := ParseTheme(blog.ThemeJSON) + changed := false + if theme.BgImage == id.String() { + theme.BgImage, changed = "", true + } + if theme.HeaderImage == id.String() { + theme.HeaderImage, changed = "", true + } + if changed { + if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil { + s.serverError(w, err) + return + } + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/images", "Image deleted.") +} diff --git a/internal/web/handlers_media.go b/internal/web/handlers_media.go new file mode 100644 index 0000000..e651135 --- /dev/null +++ b/internal/web/handlers_media.go @@ -0,0 +1,38 @@ +package web + +import ( + "bytes" + "errors" + "net/http" + + "github.com/google/uuid" + "github.com/gramanas/blogspace/internal/store" +) + +// handleMedia serves an uploaded image. Ids are immutable, so clients may cache forever. +func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + http.NotFound(w, r) + return + } + etag := `"` + id.String() + `"` + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + img, err := s.st.ImageData(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + s.serverError(w, err) + return + } + w.Header().Set("Content-Type", img.ContentType) + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("ETag", etag) + w.Header().Set("X-Content-Type-Options", "nosniff") + http.ServeContent(w, r, img.Filename, img.CreatedAt, bytes.NewReader(img.Data)) +} diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go new file mode 100644 index 0000000..1b79280 --- /dev/null +++ b/internal/web/handlers_pages.go @@ -0,0 +1,161 @@ +package web + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gramanas/blogspace/internal/markdown" + "github.com/gramanas/blogspace/internal/slug" + "github.com/gramanas/blogspace/internal/store" +) + +// Page slugs that would collide with blog routes. +var reservedPageSlugs = map[string]bool{"media": true, "static": true, "feed.xml": true} + +func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) { + pages, err := s.st.ListPages(r.Context(), currentBlog(r).ID) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "dashboard/pages.html", map[string]any{"pages": pages}) +} + +// loadPage fetches the page named in the URL, or nil (having written the response) on failure. +func (s *Server) loadPage(w http.ResponseWriter, r *http.Request) *store.Page { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + p, err := s.st.PageByID(r.Context(), currentBlog(r).ID, id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + } else { + s.serverError(w, err) + } + return nil + } + return p +} + +func (s *Server) handlePageForm(w http.ResponseWriter, r *http.Request) { + p := &store.Page{ShowInNav: true} + if r.PathValue("id") != "" { + if p = s.loadPage(w, r); p == nil { + return + } + } + s.render(w, r, "dashboard/page_form.html", map[string]any{"page": p}) +} + +func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + p := &store.Page{BlogID: blog.ID} + if r.PathValue("id") != "" { + if p = s.loadPage(w, r); p == nil { + return + } + } + p.Title = strings.TrimSpace(r.FormValue("title")) + p.Slug = strings.TrimSpace(r.FormValue("slug")) + p.IntroMD = r.FormValue("intro") + p.ShowInNav = r.FormValue("show_in_nav") == "on" + autoSlug := p.Slug == "" + if autoSlug { + p.Slug = slug.Make(p.Title) + } + var msg string + switch { + case p.Title == "" || len(p.Title) > 120: + msg = "Title is required (max 120 characters)." + case !slug.Valid(p.Slug) || reservedPageSlugs[p.Slug]: + msg = "Slug may only contain lowercase letters, digits and dashes (and not be a reserved word)." + } + if msg != "" { + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/page_form.html", map[string]any{"page": p, "error": msg}) + return + } + p.IntroHTML = markdown.Render(p.IntroMD) + var err error + base := p.Slug + for n := 1; ; n++ { // generated slugs get -2, -3… on collision; typed ones report the conflict + p.Slug = slug.WithSuffix(base, n) + if p.ID == 0 { + var created *store.Page + if created, err = s.st.CreatePage(r.Context(), p); err == nil { + p = created + } + } else { + err = s.st.UpdatePage(r.Context(), p) + } + if !errors.Is(err, store.ErrConflict) || !autoSlug || n >= 50 { + break + } + } + if err != nil { + if errors.Is(err, store.ErrConflict) { + s.renderStatus(w, r, http.StatusConflict, "dashboard/page_form.html", map[string]any{"page": p, "error": "A page with that slug already exists."}) + return + } + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/pages/"+strconv.FormatInt(p.ID, 10)+"/edit", "Saved. Refresh your blog to see it.") +} + +func (s *Server) handlePageDeleteConfirm(w http.ResponseWriter, r *http.Request) { + p := s.loadPage(w, r) + if p == nil { + return + } + s.render(w, r, "dashboard/confirm.html", map[string]any{ + "what": "the page \"" + p.Title + "\" and all " + strconv.Itoa(p.PostCount) + " of its posts", + "action": r.URL.Path, + "back": "/b/" + currentBlog(r).Subdomain + "/pages", + "isHome": p.IsHome, + }) +} + +func (s *Server) handlePageDelete(w http.ResponseWriter, r *http.Request) { + p := s.loadPage(w, r) + if p == nil { + return + } + if p.IsHome { + s.plainError(w, http.StatusBadRequest, "The home page cannot be deleted. Make another page the home page first.") + return + } + if err := s.st.DeletePage(r.Context(), p.BlogID, p.ID); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", "Page deleted.") +} + +func (s *Server) handlePageMove(w http.ResponseWriter, r *http.Request) { + p := s.loadPage(w, r) + if p == nil { + return + } + dir := 1 + if r.FormValue("dir") == "up" { + dir = -1 + } + if err := s.st.MovePage(r.Context(), p.BlogID, p.ID, dir); err != nil { + s.serverError(w, err) + return + } + http.Redirect(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", http.StatusSeeOther) +} + +func (s *Server) handlePageHome(w http.ResponseWriter, r *http.Request) { + p := s.loadPage(w, r) + if p == nil { + return + } + if err := s.st.SetHomePage(r.Context(), p.BlogID, p.ID); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", "\""+p.Title+"\" is now the home page.") +} diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go new file mode 100644 index 0000000..8678985 --- /dev/null +++ b/internal/web/handlers_posts.go @@ -0,0 +1,158 @@ +package web + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gramanas/blogspace/internal/markdown" + "github.com/gramanas/blogspace/internal/slug" + "github.com/gramanas/blogspace/internal/store" +) + +func (s *Server) handlePosts(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + pageID, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64) + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + posts, err := s.st.ListPosts(r.Context(), blog.ID, pageID) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "dashboard/posts.html", map[string]any{"posts": posts, "pages": pages, "pageID": pageID}) +} + +func (s *Server) loadPost(w http.ResponseWriter, r *http.Request) *store.Post { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + p, err := s.st.PostByID(r.Context(), currentBlog(r).ID, id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + } else { + s.serverError(w, err) + } + return nil + } + return p +} + +func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + p := &store.Post{Published: true} + if r.PathValue("id") != "" { + if p = s.loadPost(w, r); p == nil { + return + } + } else if pid, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64); pid != 0 { + p.PageID = pid + } else if hp, err := s.st.HomePage(r.Context(), blog.ID); err == nil { + p.PageID = hp.ID + } + s.render(w, r, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages}) +} + +func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + p := &store.Post{} + if r.PathValue("id") != "" { + if p = s.loadPost(w, r); p == nil { + return + } + } + p.Title = strings.TrimSpace(r.FormValue("title")) + p.Slug = strings.TrimSpace(r.FormValue("slug")) + p.BodyMD = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n") + p.Published = r.FormValue("published") == "on" + p.PageID, _ = strconv.ParseInt(r.FormValue("page_id"), 10, 64) + autoSlug := p.Slug == "" + if autoSlug { + p.Slug = slug.Make(p.Title) + } + fail := func(status int, msg string) { + s.renderStatus(w, r, status, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages, "error": msg}) + } + pageOK := false + for _, pg := range pages { + if pg.ID == p.PageID { + pageOK = true + } + } + switch { + case p.Title == "" || len(p.Title) > 200: + fail(http.StatusBadRequest, "Title is required (max 200 characters).") + return + case !slug.Valid(p.Slug): + fail(http.StatusBadRequest, "Slug may only contain lowercase letters, digits and dashes.") + return + case !pageOK: + fail(http.StatusBadRequest, "Pick a page for this post.") + return + case len(p.BodyMD) > 200_000: + fail(http.StatusBadRequest, "Post is too long (200 KB max).") + return + } + p.BodyHTML = markdown.Render(p.BodyMD) + base := p.Slug + for n := 1; ; n++ { // generated slugs get -2, -3… on collision; typed ones report the conflict + p.Slug = slug.WithSuffix(base, n) + if p.ID == 0 { + var created *store.Post + if created, err = s.st.CreatePost(r.Context(), p); err == nil { + p = created + } + } else { + err = s.st.UpdatePost(r.Context(), p) + } + if !errors.Is(err, store.ErrConflict) || !autoSlug || n >= 50 { + break + } + } + if err != nil { + if errors.Is(err, store.ErrConflict) { + fail(http.StatusConflict, "A post with that slug already exists on this page; choose another slug.") + return + } + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/posts/"+strconv.FormatInt(p.ID, 10)+"/edit", "Saved. Refresh your blog to see it.") +} + +func (s *Server) handlePostDeleteConfirm(w http.ResponseWriter, r *http.Request) { + p := s.loadPost(w, r) + if p == nil { + return + } + s.render(w, r, "dashboard/confirm.html", map[string]any{ + "what": "the post \"" + p.Title + "\"", + "action": r.URL.Path, + "back": "/b/" + currentBlog(r).Subdomain + "/posts", + }) +} + +func (s *Server) handlePostDelete(w http.ResponseWriter, r *http.Request) { + p := s.loadPost(w, r) + if p == nil { + return + } + if err := s.st.DeletePost(r.Context(), currentBlog(r).ID, p.ID); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/posts", "Post deleted.") +} diff --git a/internal/web/routes.go b/internal/web/routes.go new file mode 100644 index 0000000..c889aa1 --- /dev/null +++ b/internal/web/routes.go @@ -0,0 +1,81 @@ +package web + +import ( + "net/http" + "net/url" +) + +func urlQuery(s string) string { return url.QueryEscape(s) } + +func (s *Server) rootRoutes() http.Handler { + m := http.NewServeMux() + m.HandleFunc("GET /{$}", s.handleIndex) + m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) + m.HandleFunc("GET /login", s.handleLoginForm) + m.HandleFunc("POST /login", s.handleLogin) + m.HandleFunc("POST /logout", s.handleLogout) + m.HandleFunc("GET /dashboard", s.requireAuth(s.handleDashboard)) + m.HandleFunc("GET /account/password", s.requireAuth(s.handlePasswordForm)) + m.HandleFunc("POST /account/password", s.requireAuth(s.handlePassword)) + + // blog management (owner or superadmin) + m.HandleFunc("GET /b/{sub}/{$}", s.withBlog(s.handleBlogOverview)) + m.HandleFunc("GET /b/{sub}/settings", s.withBlog(s.handleSettingsForm)) + m.HandleFunc("POST /b/{sub}/settings", s.withBlog(s.handleSettings)) + m.HandleFunc("GET /b/{sub}/pages", s.withBlog(s.handlePages)) + m.HandleFunc("GET /b/{sub}/pages/new", s.withBlog(s.handlePageForm)) + m.HandleFunc("POST /b/{sub}/pages/new", s.withBlog(s.handlePageSave)) + m.HandleFunc("GET /b/{sub}/pages/{id}/edit", s.withBlog(s.handlePageForm)) + m.HandleFunc("POST /b/{sub}/pages/{id}/edit", s.withBlog(s.handlePageSave)) + m.HandleFunc("GET /b/{sub}/pages/{id}/delete", s.withBlog(s.handlePageDeleteConfirm)) + m.HandleFunc("POST /b/{sub}/pages/{id}/delete", s.withBlog(s.handlePageDelete)) + m.HandleFunc("POST /b/{sub}/pages/{id}/move", s.withBlog(s.handlePageMove)) + m.HandleFunc("POST /b/{sub}/pages/{id}/home", s.withBlog(s.handlePageHome)) + m.HandleFunc("GET /b/{sub}/posts", s.withBlog(s.handlePosts)) + m.HandleFunc("GET /b/{sub}/posts/new", s.withBlog(s.handlePostForm)) + m.HandleFunc("POST /b/{sub}/posts/new", s.withBlog(s.handlePostSave)) + m.HandleFunc("GET /b/{sub}/posts/{id}/edit", s.withBlog(s.handlePostForm)) + m.HandleFunc("POST /b/{sub}/posts/{id}/edit", s.withBlog(s.handlePostSave)) + m.HandleFunc("GET /b/{sub}/posts/{id}/delete", s.withBlog(s.handlePostDeleteConfirm)) + m.HandleFunc("POST /b/{sub}/posts/{id}/delete", s.withBlog(s.handlePostDelete)) + m.HandleFunc("GET /b/{sub}/design", s.withBlog(s.handleDesignForm)) + m.HandleFunc("POST /b/{sub}/design", s.withBlog(s.handleDesign)) + m.HandleFunc("GET /b/{sub}/images", s.withBlog(s.handleImages)) + m.HandleFunc("POST /b/{sub}/images/upload", s.withBlog(s.handleImageUpload)) + m.HandleFunc("POST /b/{sub}/images/{id}/delete", s.withBlog(s.handleImageDelete)) + + // superadmin + m.HandleFunc("GET /admin/{$}", s.requireAdmin(s.handleAdmin)) + m.HandleFunc("GET /admin/users/new", s.requireAdmin(s.handleAdminNewUserForm)) + m.HandleFunc("POST /admin/users/new", s.requireAdmin(s.handleAdminNewUser)) + m.HandleFunc("POST /admin/users/{id}/reset-password", s.requireAdmin(s.handleAdminResetPassword)) + m.HandleFunc("POST /admin/users/{id}/disable", s.requireAdmin(s.handleAdminSetDisabled(true))) + m.HandleFunc("POST /admin/users/{id}/enable", s.requireAdmin(s.handleAdminSetDisabled(false))) + m.HandleFunc("GET /admin/users/{id}/delete", s.requireAdmin(s.handleAdminDeleteUserConfirm)) + m.HandleFunc("POST /admin/users/{id}/delete", s.requireAdmin(s.handleAdminDeleteUser)) + + m.HandleFunc("GET /media/{id}", s.handleMedia) + m.Handle("GET /static/{file}", s.staticHandler()) + return s.session(m) +} + +func (s *Server) blogRoutes() http.Handler { + m := http.NewServeMux() + m.HandleFunc("GET /{$}", s.handleBlogHome) + m.HandleFunc("GET /feed.xml", s.handleBlogFeed) + m.HandleFunc("GET /media/{id}", s.handleMedia) + m.Handle("GET /static/{file}", s.staticHandler()) + m.HandleFunc("GET /{page}", s.handleBlogPage) + m.HandleFunc("GET /{page}/{post}", s.handleBlogPost) + return m +} + +func (s *Server) staticHandler() http.Handler { + fs := http.StripPrefix("/static/", http.FileServer(http.FS(s.tpl.AssetsFS()))) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.cfg.Dev { + w.Header().Set("Cache-Control", "public, max-age=3600") + } + fs.ServeHTTP(w, r) + }) +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..e8a5e6d --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,251 @@ +// Package web is the HTTP layer: host routing, handlers and templates. +package web + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + "strings" + + "github.com/gramanas/blogspace/internal/auth" + "github.com/gramanas/blogspace/internal/config" + "github.com/gramanas/blogspace/internal/store" +) + +// Subdomains that can never be blogs (kept free for infrastructure). +var reservedSubdomains = map[string]bool{"www": true, "admin": true, "api": true, "mail": true, "static": true, "media": true, "ftp": true, "smtp": true} + +type Server struct { + cfg *config.Config + st *store.Store + tpl *templates + root http.Handler + blog http.Handler +} + +func NewServer(cfg *config.Config, st *store.Store) *Server { + s := &Server{cfg: cfg, st: st, tpl: newTemplates(cfg.Dev, funcs)} + s.root = s.rootRoutes() + s.blog = s.blogRoutes() + return s +} + +// ServeHTTP dispatches on the Host header: the base domain is the management +// site, one label below it is a blog, anything else is a 404. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + host := hostname(r.Host) + switch { + case host == s.cfg.BaseDomain, host == "www."+s.cfg.BaseDomain: + s.root.ServeHTTP(w, r) + case strings.HasSuffix(host, "."+s.cfg.BaseDomain): + sub := strings.TrimSuffix(host, "."+s.cfg.BaseDomain) + if strings.Contains(sub, ".") || reservedSubdomains[sub] { + http.NotFound(w, r) + return + } + blog, err := s.st.BlogBySubdomain(r.Context(), sub) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + s.plainError(w, http.StatusNotFound, "No blog here (yet).") + return + } + s.serverError(w, err) + return + } + s.blog.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxBlog, blog))) + default: + s.plainError(w, http.StatusNotFound, fmt.Sprintf("Unknown host %q. Blogs live at .%s", host, s.cfg.BaseDomain)) + } +} + +func hostname(h string) string { + if host, _, err := net.SplitHostPort(h); err == nil { + h = host + } + return strings.ToLower(strings.TrimSuffix(h, ".")) +} + +// ---- context keys -------------------------------------------------------- + +type ctxKey int + +const ( + ctxUser ctxKey = iota + ctxBlog +) + +func currentUser(r *http.Request) *store.User { + u, _ := r.Context().Value(ctxUser).(*store.User) + return u +} + +func currentBlog(r *http.Request) *store.Blog { + b, _ := r.Context().Value(ctxBlog).(*store.Blog) + return b +} + +// ---- middleware ---------------------------------------------------------- + +// session loads the user from the JWT cookie (if any) into the context. +func (s *Server) session(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(auth.CookieName) + if err != nil || c.Value == "" { + next.ServeHTTP(w, r) + return + } + claims, err := auth.ParseToken(s.cfg.JWTSecret, c.Value) + if err != nil { + auth.ClearSessionCookie(w) + next.ServeHTTP(w, r) + return + } + u, err := s.st.UserByID(r.Context(), claims.UserID) + if err != nil || u.Disabled || u.TokenVersion != claims.TokenVersion { + auth.ClearSessionCookie(w) + next.ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, u))) + }) +} + +// requireAuth redirects anonymous users to the login page. +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if u == nil { + http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther) + return + } + if r.Method == http.MethodPost { + // Cap the request body before any form parsing (uploads included). + r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes+1<<20) + if err := parseForm(r); err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + s.plainError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Upload too large: the limit is %d MB.", s.cfg.MaxUploadBytes>>20)) + return + } + s.plainError(w, http.StatusBadRequest, "Could not read the form.") + return + } + if !auth.CheckCSRF(s.cfg.JWTSecret, u.ID, u.TokenVersion, r.FormValue("_csrf")) { + s.plainError(w, http.StatusForbidden, "Form expired or invalid. Go back, reload the page and try again.") + return + } + } + next(w, r) + } +} + +// parseForm parses urlencoded or multipart bodies, surfacing size errors. +func parseForm(r *http.Request) error { + ct := r.Header.Get("Content-Type") + if strings.HasPrefix(ct, "multipart/form-data") { + return r.ParseMultipartForm(1 << 20) + } + return r.ParseForm() +} + +func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { + return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { + if !currentUser(r).IsSuperadmin() { + s.plainError(w, http.StatusForbidden, "Superadmin only.") + return + } + next(w, r) + }) +} + +// withBlog resolves /b/{sub}/... and enforces owner-or-superadmin. +func (s *Server) withBlog(next http.HandlerFunc) http.HandlerFunc { + return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + blog, err := s.st.BlogBySubdomain(r.Context(), r.PathValue("sub")) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + s.serverError(w, err) + return + } + if blog.OwnerID != u.ID && !u.IsSuperadmin() { + s.plainError(w, http.StatusForbidden, "This is not your blog.") + return + } + next(w, r.WithContext(context.WithValue(r.Context(), ctxBlog, blog))) + }) +} + +// ---- rendering helpers --------------------------------------------------- + +// view is the common data every template receives; page data goes in Data. +type view struct { + User *store.User + CSRF string + Flash string + Error string + Blog *store.Blog + BlogURL string + RootURL string + Path string + Data map[string]any +} + +func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) { + s.renderStatus(w, r, http.StatusOK, name, data) +} + +func (s *Server) renderStatus(w http.ResponseWriter, r *http.Request, status int, name string, data map[string]any) { + if data == nil { + data = map[string]any{} + } + v := view{User: currentUser(r), Blog: currentBlog(r), RootURL: s.cfg.RootURL(), Path: r.URL.Path, Data: data} + if v.User != nil { + v.CSRF = auth.CSRFToken(s.cfg.JWTSecret, v.User.ID, v.User.TokenVersion) + } + if v.Blog != nil { + v.BlogURL = s.cfg.BlogURL(v.Blog.Subdomain) + } + v.Flash = r.URL.Query().Get("ok") + if e, ok := data["error"].(string); ok { + v.Error = e + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if err := s.tpl.render(w, name, v); err != nil { + log.Printf("render %s: %v", name, err) + fmt.Fprintf(w, "
template error: %v
", err) + } +} + +func (s *Server) serverError(w http.ResponseWriter, err error) { + log.Printf("error: %v", err) + s.plainError(w, http.StatusInternalServerError, "Something went wrong.") +} + +func (s *Server) plainError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + fmt.Fprintf(w, `%d

%d %s

%s

Home

`, + status, status, http.StatusText(status), htmlEscape(msg)) +} + +func htmlEscape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) + return r.Replace(s) +} + +// redirectOK redirects with a flash message shown by the layout. +func redirectOK(w http.ResponseWriter, r *http.Request, to, msg string) { + sep := "?" + if strings.Contains(to, "?") { + sep = "&" + } + http.Redirect(w, r, to+sep+"ok="+urlQuery(msg), http.StatusSeeOther) +} diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css new file mode 100644 index 0000000..b09689b --- /dev/null +++ b/internal/web/static/blog.css @@ -0,0 +1,50 @@ +/* Public blog base styles — colours, fonts and widths come from the inline theme + + +{{if eq .Data.theme.NavPosition "top-bar"}}{{template "blognav" .}}{{end}} + +{{if eq .Data.theme.NavPosition "below-header"}}{{template "blognav" .}}{{end}} +
+ {{if eq .Data.theme.NavPosition "left-sidebar"}}{{template "blognav" .}}{{end}} +
+ {{template "content" .}} +
+
+ + +{{end}} + +{{define "blognav"}}{{end}} diff --git a/internal/web/templates/layouts/dashboard.html b/internal/web/templates/layouts/dashboard.html new file mode 100644 index 0000000..6633c29 --- /dev/null +++ b/internal/web/templates/layouts/dashboard.html @@ -0,0 +1,17 @@ +{{define "layout"}} + + + + +{{block "title" .}}Blogspace{{end}} + + + +{{template "dashnav" .}} +
+{{if .Flash}}
{{.Flash}}
{{end}} +{{if .Error}}
{{.Error}}
{{end}} +{{template "content" .}} +
+ +{{end}} diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html new file mode 100644 index 0000000..13a04d0 --- /dev/null +++ b/internal/web/templates/partials/dashnav.html @@ -0,0 +1,27 @@ +{{define "dashnav"}}
+
+ Blogspace + {{if .User}} + {{.User.Username}}{{if .User.IsSuperadmin}} (superadmin){{end}} + + {{if .User.IsSuperadmin}}Admin{{end}} + Password +
+
+ {{end}} +
+
+{{if .Blog}} +
+
+ {{.Blog.Title}} + Overview + Posts + Pages + Design + Images + Settings + View blog ↗ +
+
+{{end}}{{end}} diff --git a/internal/web/templates/partials/imagepick.html b/internal/web/templates/partials/imagepick.html new file mode 100644 index 0000000..687c6cd --- /dev/null +++ b/internal/web/templates/partials/imagepick.html @@ -0,0 +1,11 @@ +{{define "imagepick"}}
+ + + {{if .current}} + {{end}} +
{{end}} diff --git a/internal/web/templates/partials/mdhelp.html b/internal/web/templates/partials/mdhelp.html new file mode 100644 index 0000000..878130e --- /dev/null +++ b/internal/web/templates/partials/mdhelp.html @@ -0,0 +1,9 @@ +{{define "mdhelp"}}
+

Formatting cheat-sheet

+ + + + +
# Heading, ## Smaller heading**bold**, *italic*
[link text](https://example.org)![description](/media/…) — copy the address from Images
- list item / 1. numbered> quote, `code`, --- for a line
+

Blank line = new paragraph. Press Enter once for a line break.

+
{{end}} diff --git a/internal/web/theme.go b/internal/web/theme.go new file mode 100644 index 0000000..40efc54 --- /dev/null +++ b/internal/web/theme.go @@ -0,0 +1,212 @@ +package web + +import ( + "bytes" + "encoding/json" + "net/url" + "regexp" + "strings" + "text/template" + + "github.com/google/uuid" +) + +// Theme is the structured, form-editable look of a blog. Stored as jsonb. +type Theme struct { + BgColor string `json:"bg_color"` + BgImage string `json:"bg_image"` // image uuid or "" + BgMode string `json:"bg_mode"` // cover | tile | fixed + + ContentBg string `json:"content_bg"` + TextColor string `json:"text_color"` + LinkColor string `json:"link_color"` + ContentWidth string `json:"content_width"` // narrow | medium | wide + Font string `json:"font"` // sans | serif | mono + FontSize string `json:"font_size"` // small | normal | large + + HeaderShowTitle bool `json:"header_show_title"` + HeaderImage string `json:"header_image"` + HeaderAlign string `json:"header_align"` // left | center + HeaderBg string `json:"header_bg"` + HeaderText string `json:"header_text"` + + NavPosition string `json:"nav_position"` // below-header | top-bar | left-sidebar + NavBg string `json:"nav_bg"` + NavText string `json:"nav_text"` + NavShowHome bool `json:"nav_show_home"` + + FooterText string `json:"footer_text"` + FooterBg string `json:"footer_bg"` + FooterColor string `json:"footer_color"` +} + +func DefaultTheme() Theme { + return Theme{ + BgColor: "#e9e6df", BgMode: "cover", + ContentBg: "#ffffff", TextColor: "#222222", LinkColor: "#1a5fb4", + ContentWidth: "medium", Font: "sans", FontSize: "normal", + HeaderShowTitle: true, HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", + NavPosition: "below-header", NavBg: "#ffffff", NavText: "#222222", NavShowHome: true, + FooterText: "", FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", + } +} + +// ParseTheme decodes stored JSON on top of the defaults so new fields get sane values. +func ParseTheme(raw json.RawMessage) Theme { + t := DefaultTheme() + if len(raw) > 0 { + _ = json.Unmarshal(raw, &t) + } + t.normalize() + return t +} + +var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +func pick(v string, allowed ...string) string { + for _, a := range allowed { + if v == a { + return v + } + } + return allowed[0] +} + +func color(v, def string) string { + v = strings.TrimSpace(v) + if hexColor.MatchString(v) { + return strings.ToLower(v) + } + return def +} + +func imageID(v string) string { + if _, err := uuid.Parse(v); err != nil { + return "" + } + return v +} + +// normalize clamps every field to an allowed value so the CSS template can trust them. +func (t *Theme) normalize() { + d := DefaultTheme() + t.BgColor = color(t.BgColor, d.BgColor) + t.BgImage = imageID(t.BgImage) + t.BgMode = pick(t.BgMode, "cover", "tile", "fixed") + t.ContentBg = color(t.ContentBg, d.ContentBg) + t.TextColor = color(t.TextColor, d.TextColor) + t.LinkColor = color(t.LinkColor, d.LinkColor) + t.ContentWidth = pick(t.ContentWidth, "medium", "narrow", "wide") + t.Font = pick(t.Font, "sans", "serif", "mono") + t.FontSize = pick(t.FontSize, "normal", "small", "large") + t.HeaderImage = imageID(t.HeaderImage) + t.HeaderAlign = pick(t.HeaderAlign, "left", "center") + t.HeaderBg = color(t.HeaderBg, d.HeaderBg) + t.HeaderText = color(t.HeaderText, d.HeaderText) + t.NavPosition = pick(t.NavPosition, "below-header", "top-bar", "left-sidebar") + t.NavBg = color(t.NavBg, d.NavBg) + t.NavText = color(t.NavText, d.NavText) + t.FooterBg = color(t.FooterBg, d.FooterBg) + t.FooterColor = color(t.FooterColor, d.FooterColor) + if len(t.FooterText) > 2000 { + t.FooterText = t.FooterText[:2000] + } +} + +// ThemeFromForm reads the design form on top of the current theme. +func ThemeFromForm(cur Theme, f url.Values) Theme { + t := cur + get := func(k string) string { return strings.TrimSpace(f.Get(k)) } + t.BgColor = get("bg_color") + t.BgMode = get("bg_mode") + t.ContentBg = get("content_bg") + t.TextColor = get("text_color") + t.LinkColor = get("link_color") + t.ContentWidth = get("content_width") + t.Font = get("font") + t.FontSize = get("font_size") + t.HeaderShowTitle = f.Get("header_show_title") == "on" + t.HeaderAlign = get("header_align") + t.HeaderBg = get("header_bg") + t.HeaderText = get("header_text") + t.NavPosition = get("nav_position") + t.NavBg = get("nav_bg") + t.NavText = get("nav_text") + t.NavShowHome = f.Get("nav_show_home") == "on" + t.FooterText = get("footer_text") + t.FooterBg = get("footer_bg") + t.FooterColor = get("footer_color") + if f.Get("bg_image_remove") == "on" { + t.BgImage = "" + } else if v := get("bg_image"); v != "" { + t.BgImage = v + } + if f.Get("header_image_remove") == "on" { + t.HeaderImage = "" + } else if v := get("header_image"); v != "" { + t.HeaderImage = v + } + t.normalize() + return t +} + +func (t Theme) JSON() json.RawMessage { + b, _ := json.Marshal(t) + return b +} + +// Helpers used by the CSS template. +func (t Theme) FontFamily() string { + switch t.Font { + case "serif": + return `Georgia, "Times New Roman", Times, serif` + case "mono": + return `"Courier New", Courier, monospace` + } + return `"Helvetica Neue", Helvetica, Arial, sans-serif` +} + +func (t Theme) FontSizePx() string { + switch t.FontSize { + case "small": + return "15px" + case "large": + return "19px" + } + return "17px" +} + +func (t Theme) MaxWidth() string { + switch t.ContentWidth { + case "narrow": + return "640px" + case "wide": + return "1100px" + } + return "840px" +} + +var themeCSS = template.Must(template.New("theme").Parse(` +body { margin:0; background-color:{{.BgColor}}; color:{{.TextColor}}; font-family:{{.FontFamily}}; font-size:{{.FontSizePx}}; line-height:1.55; +{{- if .BgImage}} background-image:url(/media/{{.BgImage}}); + {{- if eq .BgMode "cover"}} background-size:cover; background-position:center top; background-repeat:no-repeat; + {{- else if eq .BgMode "fixed"}} background-size:cover; background-position:center; background-attachment:fixed; background-repeat:no-repeat; + {{- else}} background-repeat:repeat;{{end}} +{{- end}} } +a { color:{{.LinkColor}}; } +.wrap { max-width:{{.MaxWidth}}; margin:0 auto; } +.site-header { background:{{.HeaderBg}}; color:{{.HeaderText}}; text-align:{{.HeaderAlign}}; } +.site-header a { color:{{.HeaderText}}; } +.site-nav { background:{{.NavBg}}; } +.site-nav a { color:{{.NavText}}; } +.content { background:{{.ContentBg}}; } +.site-footer { background:{{.FooterBg}}; color:{{.FooterColor}}; } +.site-footer a { color:{{.FooterColor}}; } +`)) + +// CSS renders the per-blog stylesheet; all values were normalized so it is safe to inline. +func (t Theme) CSS() string { + var b bytes.Buffer + _ = themeCSS.Execute(&b, t) + return b.String() +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..42e37a7 --- /dev/null +++ b/internal/web/web_test.go @@ -0,0 +1,73 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gramanas/blogspace/internal/config" +) + +func TestHostname(t *testing.T) { + cases := map[string]string{"Example.com:8080": "example.com", "a.example.com": "a.example.com", "example.com.": "example.com", "[::1]:80": "::1"} + for in, want := range cases { + if got := hostname(in); got != want { + t.Errorf("hostname(%q) = %q, want %q", in, got, want) + } + } +} + +// Host routing that does not need the database: unknown hosts and reserved/nested subdomains 404 before any lookup. +func TestHostRoutingWithoutDB(t *testing.T) { + cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20} + s := NewServer(cfg, nil) + for _, host := range []string{"evil.com", "a.b.example.com", "www.blog.example.com", "static.example.com", "notexample.com"} { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + req.Host = host + s.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("host %q: got %d, want 404", host, rec.Code) + } + } + // root domain (and www) reach the management mux: /login renders without DB access + for _, host := range []string{"example.com", "www.example.com", "example.com:8080"} { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/login", nil) + req.Host = host + s.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Log in") { + t.Errorf("host %q /login: got %d", host, rec.Code) + } + } +} + +func TestThemeNormalizeAndCSS(t *testing.T) { + th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","nav_position":"left-sidebar"}`)) + if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.NavPosition != "left-sidebar" { + t.Errorf("normalize: %+v", th) + } + f := url.Values{"bg_color": {"#ABCDEF"}, "content_width": {"wide"}, "header_show_title": {"on"}, "bg_image": {"not-a-uuid"}} + th = ThemeFromForm(DefaultTheme(), f) + if th.BgColor != "#abcdef" || th.ContentWidth != "wide" || !th.HeaderShowTitle || th.BgImage != "" { + t.Errorf("from form: %+v", th) + } + css := th.CSS() + if !strings.Contains(css, "background-color:#abcdef") || !strings.Contains(css, "max-width:1100px") || strings.Contains(css, "display:none") { + t.Errorf("css: %s", css) + } +} + +func TestAllTemplatesParse(t *testing.T) { + tpl := newTemplates(false, funcs) + for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html", + "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/images.html", "dashboard/settings.html", + "dashboard/password.html", "dashboard/confirm.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", + "blog/page.html", "blog/post.html", "blog/404.html"} { + if _, err := tpl.get(name); err != nil { + t.Errorf("%s: %v", name, err) + } + } +} -- cgit v1.2.3