aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorgramanas <grm@eyesin.space>2026-09-12 11:24:17 +0300
committergramanas <grm@eyesin.space>2026-09-12 11:24:17 +0300
commit3eb04b1a2bdf9e53231fe862cfd76327371a9741 (patch)
treeb38b2d82a47233fd8e0bb18c59e4a8f3dd2412d7 /cmd
downloadblogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.tar.gz
blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.tar.bz2
blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.zip
Initial multi-tenant blog host
Go + Postgres application serving a management dashboard on the base domain and one public blog per subdomain. Markdown posts organised in pages, form-based theme customisation, image uploads stored in Postgres, JWT cookie sessions with CSRF, superadmin user management, RSS feeds. Docker/compose deployment and a Makefile-driven dev environment with seed data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'cmd')
-rw-r--r--cmd/blogspace/main.go142
-rw-r--r--cmd/blogspace/seed.go108
2 files changed, 250 insertions, 0 deletions
diff --git a/cmd/blogspace/main.go b/cmd/blogspace/main.go
new file mode 100644
index 0000000..3a60bd4
--- /dev/null
+++ b/cmd/blogspace/main.go
@@ -0,0 +1,142 @@
+// Command blogspace runs the multi-blog server.
+//
+// blogspace start the HTTP server (default)
+// blogspace seed insert demo users/blogs for local development
+// blogspace migrate apply migrations and exit
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/gramanas/blogspace/internal/auth"
+ "github.com/gramanas/blogspace/internal/config"
+ "github.com/gramanas/blogspace/internal/db"
+ "github.com/gramanas/blogspace/internal/store"
+ "github.com/gramanas/blogspace/internal/web"
+)
+
+func main() {
+ log.SetFlags(log.LstdFlags | log.Lmsgprefix)
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatalf("config: %v", err)
+ }
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ cmd := "serve"
+ if len(os.Args) > 1 {
+ cmd = os.Args[1]
+ }
+ if err := run(ctx, cfg, cmd); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func run(ctx context.Context, cfg *config.Config, cmd string) error {
+ if err := waitForDB(ctx, cfg.DatabaseURL); err != nil {
+ return err
+ }
+ if err := db.Migrate(ctx, cfg.DatabaseURL); err != nil {
+ return fmt.Errorf("migrate: %w", err)
+ }
+ pool, err := db.Open(ctx, cfg.DatabaseURL)
+ if err != nil {
+ return err
+ }
+ defer pool.Close()
+ st := store.New(pool)
+
+ switch cmd {
+ case "migrate":
+ log.Println("migrations applied")
+ return nil
+ case "seed":
+ return seed(ctx, cfg, st)
+ case "serve":
+ if err := bootstrapSuperadmin(ctx, cfg, st); err != nil {
+ return err
+ }
+ return serve(ctx, cfg, st)
+ default:
+ return fmt.Errorf("unknown command %q (serve|seed|migrate)", cmd)
+ }
+}
+
+// waitForDB retries the connection so `docker compose up` ordering doesn't matter.
+func waitForDB(ctx context.Context, url string) error {
+ deadline := time.Now().Add(60 * time.Second)
+ for {
+ pool, err := db.Open(ctx, url)
+ if err == nil {
+ pool.Close()
+ return nil
+ }
+ if time.Now().After(deadline) {
+ return fmt.Errorf("database not reachable: %w", err)
+ }
+ log.Printf("waiting for database: %v", err)
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(2 * time.Second):
+ }
+ }
+}
+
+// bootstrapSuperadmin creates the first superadmin from the environment when none exists.
+func bootstrapSuperadmin(ctx context.Context, cfg *config.Config, st *store.Store) error {
+ n, err := st.CountSuperadmins(ctx)
+ if err != nil {
+ return err
+ }
+ if n > 0 {
+ return nil
+ }
+ pw := cfg.SuperadminPassword
+ if pw == "" {
+ if !cfg.Dev {
+ return errors.New("no superadmin exists: set SUPERADMIN_USERNAME and SUPERADMIN_PASSWORD for the first start")
+ }
+ pw = "admin"
+ }
+ hash, err := auth.HashPassword(pw)
+ if err != nil {
+ return err
+ }
+ if _, err := st.CreateUser(ctx, cfg.SuperadminUsername, hash, store.RoleSuperadmin); err != nil {
+ return fmt.Errorf("create superadmin: %w", err)
+ }
+ log.Printf("created superadmin %q", cfg.SuperadminUsername)
+ return nil
+}
+
+func serve(ctx context.Context, cfg *config.Config, st *store.Store) error {
+ srv := &http.Server{
+ Addr: cfg.Addr,
+ Handler: web.NewServer(cfg, st),
+ ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: 60 * time.Second,
+ WriteTimeout: 60 * time.Second,
+ IdleTimeout: 120 * time.Second,
+ }
+ errc := make(chan error, 1)
+ go func() { errc <- srv.ListenAndServe() }()
+ log.Printf("listening on %s — dashboard at %s, blogs at http://<name>.%s (dev=%v)", cfg.Addr, cfg.RootURL(), cfg.HostWithPort(), cfg.Dev)
+ select {
+ case err := <-errc:
+ return err
+ case <-ctx.Done():
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ return srv.Shutdown(shutdownCtx)
+ }
+}
diff --git a/cmd/blogspace/seed.go b/cmd/blogspace/seed.go
new file mode 100644
index 0000000..847324c
--- /dev/null
+++ b/cmd/blogspace/seed.go
@@ -0,0 +1,108 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "log"
+
+ "github.com/gramanas/blogspace/internal/auth"
+ "github.com/gramanas/blogspace/internal/config"
+ "github.com/gramanas/blogspace/internal/markdown"
+ "github.com/gramanas/blogspace/internal/slug"
+ "github.com/gramanas/blogspace/internal/store"
+ "github.com/gramanas/blogspace/internal/web"
+)
+
+// seed creates demo content for local development. It is idempotent: it
+// skips anything that already exists.
+func seed(ctx context.Context, cfg *config.Config, st *store.Store) error {
+ if err := bootstrapSuperadmin(ctx, cfg, st); err != nil {
+ return err
+ }
+ if _, err := st.UserByUsername(ctx, "alice"); err == nil {
+ log.Println("seed: alice already exists, nothing to do")
+ return nil
+ } else if !errors.Is(err, store.ErrNotFound) {
+ return err
+ }
+ hash, err := auth.HashPassword("alicealice")
+ if err != nil {
+ return err
+ }
+ _, blog, err := st.CreateBlogger(ctx, "alice", hash, "alice", "Alice's Corner")
+ if err != nil {
+ return err
+ }
+ if err := st.UpdateBlogSettings(ctx, blog.ID, blog.Title, "Notes on gardening, code and everything in between"); err != nil {
+ return err
+ }
+ home, err := st.HomePage(ctx, blog.ID)
+ if err != nil {
+ return err
+ }
+ pages := map[string]*store.Page{"home": home}
+ for _, p := range []store.Page{
+ {Slug: "about", Title: "About", IntroMD: "Hi, I'm **Alice**. I grow tomatoes and write Go. This page is a single-post page: just the intro text.\n\nYou can reach me on the [Contact](/contact) page.", ShowInNav: true},
+ {Slug: "news", Title: "News", IntroMD: "Short updates, newest first.", ShowInNav: true},
+ {Slug: "contact", Title: "Contact", IntroMD: "Email: alice@example.com\n\nOr find me at the Saturday market, stall 12.", ShowInNav: true},
+ } {
+ p.BlogID = blog.ID
+ p.IntroHTML = markdown.Render(p.IntroMD)
+ created, err := st.CreatePage(ctx, &p)
+ if err != nil {
+ return err
+ }
+ pages[p.Slug] = created
+ }
+
+ // a small gradient PNG so the background-image option has something to use
+ img := image.NewRGBA(image.Rect(0, 0, 256, 256))
+ for y := 0; y < 256; y++ {
+ for x := 0; x < 256; x++ {
+ img.Set(x, y, color.RGBA{R: uint8(200 + x/8), G: uint8(210 + y/8), B: 220, A: 255})
+ }
+ }
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ return err
+ }
+ bg, err := st.CreateImage(ctx, blog.ID, "gradient.png", "image/png", buf.Bytes())
+ if err != nil {
+ return err
+ }
+ theme := web.DefaultTheme()
+ theme.BgImage = bg.ID.String()
+ theme.BgMode = "tile"
+ theme.FooterText = "© Alice — made with Blogspace"
+ if err := st.UpdateBlogTheme(ctx, blog.ID, theme.JSON()); err != nil {
+ return err
+ }
+
+ // a second page of posts on the home page, to show pagination
+ for i := 0; i < 10; i++ {
+ body := fmt.Sprintf("Filler post number %d, here to demonstrate pagination.", i+1)
+ post := &store.Post{PageID: home.ID, Title: fmt.Sprintf("Filler post %d", i+1), Slug: fmt.Sprintf("filler-%d", i+1), BodyMD: body, BodyHTML: markdown.Render(body), Published: true}
+ if _, err := st.CreatePost(ctx, post); err != nil {
+ return err
+ }
+ }
+ posts := []struct{ page, title, body string }{
+ {"home", "Welcome to my corner of the web", "This is the **first post**. It lives on the home page.\n\nThings I plan to write about:\n\n- tomatoes\n- Go\n- the occasional recipe\n\n![gradient](/media/" + bg.ID.String() + ")\n\nThat image above was uploaded through the dashboard."},
+ {"home", "Why I still like plain HTML", "No frameworks, no build step. Just `<p>` tags and a stylesheet.\n\n> Simplicity is prerequisite for reliability. — Dijkstra\n\n```go\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n```"},
+ {"news", "Site is up", "The blog is live. Expect sporadic updates."},
+ {"news", "Tomato season", "First ripe tomato of the year! Table for the record:\n\n| Variety | Days |\n|---|---|\n| Cherry | 62 |\n| Beefsteak | 85 |"},
+ }
+ for _, p := range posts {
+ post := &store.Post{PageID: pages[p.page].ID, Title: p.title, Slug: slug.Make(p.title), BodyMD: p.body, BodyHTML: markdown.Render(p.body), Published: true}
+ if _, err := st.CreatePost(ctx, post); err != nil {
+ return err
+ }
+ }
+ log.Printf("seed: superadmin %q (password %q in dev), blogger alice / alicealice at %s", cfg.SuperadminUsername, "admin", cfg.BlogURL("alice"))
+ return nil
+}