diff options
| author | gramanas <grm@eyesin.space> | 2026-09-12 11:24:17 +0300 |
|---|---|---|
| committer | gramanas <grm@eyesin.space> | 2026-09-12 11:24:17 +0300 |
| commit | 3eb04b1a2bdf9e53231fe862cfd76327371a9741 (patch) | |
| tree | b38b2d82a47233fd8e0bb18c59e4a8f3dd2412d7 /cmd | |
| download | blogspace-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.go | 142 | ||||
| -rw-r--r-- | cmd/blogspace/seed.go | 108 |
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 + ")\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 +} |
