package server import ( "embed" "encoding/json" "fmt" "io/fs" "log/slog" "net/http" "os" "regexp" "strings" "github.com/writekitapp/writekit/internal/auth" ) //go:embed templates/*.html var templatesFS embed.FS //go:embed static/* var staticFS embed.FS var subdomainRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`) func (s *Server) platformHome(w http.ResponseWriter, r *http.Request) { content, err := templatesFS.ReadFile("templates/index.html") if err != nil { slog.Error("read index template", "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } html := string(content) html = strings.ReplaceAll(html, "{{ACCENT}}", "#10b981") html = strings.ReplaceAll(html, "{{VERSION}}", "v1.0.0") html = strings.ReplaceAll(html, "{{COMMIT}}", "dev") html = strings.ReplaceAll(html, "{{DEMO_MINUTES}}", "15") w.Header().Set("Content-Type", "text/html") w.Write([]byte(html)) } func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { content, err := templatesFS.ReadFile("templates/404.html") if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusNotFound) w.Write(content) } func (s *Server) platformLogin(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/signup", http.StatusFound) } func (s *Server) platformSignup(w http.ResponseWriter, r *http.Request) { content, err := templatesFS.ReadFile("templates/signup.html") if err != nil { slog.Error("read signup template", "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } html := string(content) html = strings.ReplaceAll(html, "{{ACCENT}}", "#10b981") w.Header().Set("Content-Type", "text/html") w.Write([]byte(html)) } func (s *Server) platformDashboard(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.Write([]byte(` Dashboard - WriteKit

Dashboard

Create your blog or manage your existing one.

`)) } func (s *Server) serveStaticAssets(w http.ResponseWriter, r *http.Request) { sub, err := fs.Sub(staticFS, "static") if err != nil { http.NotFound(w, r) return } http.StripPrefix("/assets/", http.FileServer(http.FS(sub))).ServeHTTP(w, r) } func (s *Server) checkSubdomain(w http.ResponseWriter, r *http.Request) { subdomain := strings.ToLower(r.URL.Query().Get("subdomain")) if subdomain == "" { jsonError(w, http.StatusBadRequest, "subdomain required") return } if !subdomainRegex.MatchString(subdomain) { jsonResponse(w, http.StatusOK, map[string]any{ "available": false, "reason": "invalid format", }) return } available, err := s.database.IsSubdomainAvailable(r.Context(), subdomain) if err != nil { slog.Error("check subdomain", "error", err) jsonError(w, http.StatusInternalServerError, "internal error") return } jsonResponse(w, http.StatusOK, map[string]any{"available": available}) } func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r) var req struct { Subdomain string `json:"subdomain"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, http.StatusBadRequest, "invalid request") return } subdomain := strings.ToLower(req.Subdomain) if !subdomainRegex.MatchString(subdomain) { jsonError(w, http.StatusBadRequest, "invalid subdomain format") return } existing, err := s.database.GetTenantByOwner(r.Context(), userID) if err != nil { slog.Error("check existing tenant", "error", err) jsonError(w, http.StatusInternalServerError, "internal error") return } if existing != nil { jsonResponse(w, http.StatusConflict, map[string]any{ "error": "you already have a blog", "url": s.buildURL(existing.Subdomain), }) return } available, err := s.database.IsSubdomainAvailable(r.Context(), subdomain) if err != nil { slog.Error("check availability", "error", err) jsonError(w, http.StatusInternalServerError, "internal error") return } if !available { jsonError(w, http.StatusConflict, "subdomain not available") return } tenant, err := s.database.CreateTenant(r.Context(), userID, subdomain) if err != nil { slog.Error("create tenant", "error", err) jsonError(w, http.StatusInternalServerError, "failed to create tenant") return } if _, err := s.tenantPool.Get(tenant.ID); err != nil { slog.Error("init tenant db", "tenant_id", tenant.ID, "error", err) } user, _ := s.database.GetUserByID(r.Context(), userID) if user != nil { if tenantDB, err := s.tenantPool.Get(tenant.ID); err == nil { tenantDB.Exec(`INSERT INTO site_settings (key, value) VALUES ('author_name', ?) ON CONFLICT DO NOTHING`, user.Name) tenantDB.Exec(`INSERT INTO site_settings (key, value) VALUES ('author_avatar', ?) ON CONFLICT DO NOTHING`, user.AvatarURL) } } slog.Info("tenant created", "subdomain", subdomain, "user_id", userID, "tenant_id", tenant.ID) jsonResponse(w, http.StatusCreated, map[string]any{ "subdomain": subdomain, "url": s.buildURL(subdomain), "tenant_id": tenant.ID, }) } func (s *Server) getTenant(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r) tenant, err := s.database.GetTenantByOwner(r.Context(), userID) if err != nil { slog.Error("get tenant", "error", err) jsonError(w, http.StatusInternalServerError, "internal error") return } if tenant == nil { jsonResponse(w, http.StatusOK, map[string]any{"has_tenant": false}) return } jsonResponse(w, http.StatusOK, map[string]any{ "has_tenant": true, "tenant": map[string]any{ "id": tenant.ID, "subdomain": tenant.Subdomain, "url": s.buildURL(tenant.Subdomain), "premium": tenant.Premium, }, }) } func jsonResponse(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } func jsonError(w http.ResponseWriter, status int, msg string) { jsonResponse(w, status, map[string]string{"error": msg}) } func (s *Server) buildURL(subdomain string) string { scheme := "https" if env := os.Getenv("ENV"); env != "prod" { scheme = "http" } return fmt.Sprintf("%s://%s.%s", scheme, subdomain, s.domain) } func (s *Server) createDemo(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") referer := r.Header.Get("Referer") validOrigins := []string{ "https://" + s.domain, "https://www." + s.domain, } if env := os.Getenv("ENV"); env != "prod" { validOrigins = append(validOrigins, "http://"+s.domain, "http://www."+s.domain, ) } isValidOrigin := func(o string) bool { for _, v := range validOrigins { if o == v { return true } } return false } isValidReferer := func(ref string) bool { for _, v := range validOrigins { if strings.HasPrefix(ref, v) { return true } } return false } if origin != "" && !isValidOrigin(origin) { jsonError(w, http.StatusForbidden, "forbidden") return } if origin == "" && referer != "" && !isValidReferer(referer) { jsonError(w, http.StatusForbidden, "forbidden") return } var req struct { Name string `json:"name"` Color string `json:"color"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, http.StatusBadRequest, "invalid request") return } if req.Name == "" { req.Name = "Demo User" } if req.Color == "" { req.Color = "#10b981" } demo, err := s.database.CreateDemo(r.Context()) if err != nil { slog.Error("create demo", "error", err) jsonError(w, http.StatusInternalServerError, "failed to create demo") return } s.tenantPool.MarkAsDemo(demo.ID) if _, err := s.tenantPool.Get(demo.ID); err != nil { slog.Error("init demo db", "demo_id", demo.ID, "error", err) } if err := s.seedDemoContent(demo.ID, req.Name, req.Color); err != nil { slog.Error("seed demo content", "demo_id", demo.ID, "error", err) } slog.Info("demo created", "subdomain", demo.Subdomain, "demo_id", demo.ID, "expires_at", demo.ExpiresAt) jsonResponse(w, http.StatusCreated, map[string]any{ "subdomain": demo.Subdomain, "url": s.buildURL(demo.Subdomain), "expires_at": demo.ExpiresAt.Format("2006-01-02T15:04:05Z"), }) } func (s *Server) seedDemoContent(demoID, authorName, accentColor string) error { db, err := s.tenantPool.Get(demoID) if err != nil { return fmt.Errorf("get tenant pool: %w", err) } result, err := db.Exec(`INSERT INTO site_settings (key, value) VALUES ('site_name', ?), ('site_description', 'Thoughts on building software, developer tools, and the craft of engineering.'), ('accent_color', ?), ('author_name', ?), ('is_demo', 'true') ON CONFLICT DO NOTHING`, authorName+"'s Blog", accentColor, authorName) if err != nil { return fmt.Errorf("insert settings: %w", err) } rows, _ := result.RowsAffected() slog.Info("seed demo settings", "demo_id", demoID, "rows_affected", rows) _, err = db.Exec(`INSERT INTO posts (id, slug, title, description, content_md, tags, cover_image, is_published, published_at, created_at, modified_at) VALUES ('demo-post-1', 'shipping-a-side-project', 'I Finally Shipped My Side Project', 'After mass years of abandoned repos, here is what finally worked.', 'I have mass abandoned side projects. We all do. But last month, I actually shipped one. Here''s what I did differently this time. ## The Graveyard Before we get to what worked, let me be honest about what didn''t. My GitHub is full of: - [ ] A "better" todo app (mass of features planned, mass built) - [ ] A CLI tool I used once - [ ] Three different blog engines (ironic, I know) - [x] This project — finally shipped The pattern was always the same: mass enthusiasm for a week, mass silence forever. ## What Changed This time, I set one rule: **ship in two weeks or kill it**. > "If you''re not embarrassed by the first version of your product, you''ve launched too late." — Reid Hoffman I printed that quote and stuck it on my monitor. Every time I wanted to add "just one more feature," I looked at it. ## The Stack I kept it minimal: | Layer | Choice | Why | |-------|--------|-----| | Frontend | React | I know it well | | Backend | Go | Fast, simple deploys | | Database | SQLite | No ops overhead | | Hosting | Fly.io | $0 to start | ~~I originally planned to use Kubernetes~~ — glad I didn''t. SQLite on a single server handles more traffic than I''ll ever get. ## The Launch I posted on Twitter, mass likes. mass signups. Then... mass complaints about bugs I hadn''t tested for. But here''s the thing: **those bugs only exist because I shipped**. I fixed them in a day. If I''d waited for "perfect," I''d still be tweaking CSS. --- Ship something this week. mass needs to be big. Your mass abandoned project could be mass person''s favorite tool.', '["career","shipping"]', 'https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=1200&h=630&fit=crop', 1, datetime('now', '-2 days'), datetime('now', '-2 days'), datetime('now', '-2 days')), ('demo-post-2', 'debugging-production-like-a-detective', 'Debugging Production Like a Detective', 'A systematic approach to finding bugs when console.log is not enough.', 'Last week, our API started returning 500 errors. Not always — just enough to be infuriating. Here''s how I tracked it down. ## The Symptoms Users reported "random failures." The logs showed: ` + "```" + ` error: connection refused error: connection refused error: context deadline exceeded ` + "```" + ` Helpful, right? ## Step 1: Gather Evidence First, I needed data. I added structured logging: ` + "```go" + ` slog.Error("request failed", "endpoint", r.URL.Path, "user_id", userID, "duration_ms", time.Since(start).Milliseconds(), "error", err, ) ` + "```" + ` Within an hour, a pattern emerged: | Time | Endpoint | Duration | Result | |------|----------|----------|--------| | 14:01 | /api/posts | 45ms | OK | | 14:01 | /api/posts | 52ms | OK | | 14:02 | /api/posts | 30,004ms | FAIL | | 14:02 | /api/users | 48ms | OK | The failures were always *exactly* 30 seconds — our timeout value. ## Step 2: Form a Hypothesis > The 30-second timeout suggested a connection hanging, not failing fast. I suspected connection pool exhaustion. Our pool was set to 10 connections. Under load, requests would wait for a free connection, then timeout. ## Step 3: Test the Theory I checked the pool stats: ` + "```sql" + ` SELECT count(*) FROM pg_stat_activity WHERE application_name = ''myapp''; ` + "```" + ` Result: **10**. Exactly at the limit. ## The Fix ` + "```go" + ` db.SetMaxOpenConns(25) // was: 10 db.SetMaxIdleConns(10) // was: 2 db.SetConnMaxLifetime(time.Hour) ` + "```" + ` Three lines. Two days of debugging. Zero more timeouts. --- The lesson: **when debugging production, be a detective, not a guesser**. Gather evidence, form hypotheses, test them.', '["debugging","golang","databases"]', 'https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=1200&h=630&fit=crop', 1, datetime('now', '-5 days'), datetime('now', '-5 days'), datetime('now', '-5 days')), ('demo-post-3', 'sqlite-in-production', 'Yes, You Can Use SQLite in Production', 'How to scale SQLite further than you think — and when to finally migrate.', 'Every time I mention SQLite in production, someone says "that doesn''t scale." Let me share some numbers. ## The Reality SQLite handles: - **Millions** of reads per second - **Thousands** of writes per second (with WAL mode) - Databases up to **281 TB** (theoretical limit) For context, that''s more than most apps will ever need. ## Configuration That Matters The defaults are conservative. For production, I use: ` + "```sql" + ` PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA cache_size = -64000; -- 64MB cache PRAGMA busy_timeout = 5000; ` + "```" + ` This gives you: | Setting | Default | Production | Impact | |---------|---------|------------|--------| | journal_mode | DELETE | WAL | Concurrent reads during writes | | synchronous | FULL | NORMAL | 10x faster writes, still safe | | cache_size | -2000 | -64000 | Fewer disk reads | ## When to Migrate SQLite is **not** right when you need: 1. Multiple servers writing to the same database 2. ~~Horizontal scaling~~ (though read replicas now exist via Litestream) 3. Sub-millisecond writes under heavy contention If you''re running a single server — which is most apps — SQLite is great. > "I''ve never used a database that made me this happy." — every SQLite user, probably ## Getting Started Here''s my standard setup: ` + "```go" + ` db, err := sql.Open("sqlite3", "file:app.db?_journal=WAL&_timeout=5000") if err != nil { log.Fatal(err) } db.SetMaxOpenConns(1) // SQLite writes are serialized anyway // Run migrations if _, err := db.Exec(schema); err != nil { log.Fatal(err) } ` + "```" + ` --- Stop overengineering. Start with SQLite. Migrate when you *actually* hit limits — you probably never will.', '["sqlite","databases","architecture"]', 'https://images.unsplash.com/photo-1544383835-bda2bc66a55d?w=1200&h=630&fit=crop', 1, datetime('now', '-8 days'), datetime('now', '-8 days'), datetime('now', '-8 days')), ('demo-post-4', 'my-2024-reading-list', 'My 2024 Reading List', '', 'Here are the books that shaped my thinking this year. ## Technical Books - **Designing Data-Intensive Applications** by Martin Kleppmann - **The Pragmatic Programmer** by David Thomas - **Staff Engineer** by Will Larson ## Non-Technical - **The Mom Test** by Rob Fitzpatrick - **Building a Second Brain** by Tiago Forte More thoughts on each coming soon...', '["books","learning"]', '', 1, datetime('now', '-12 days'), datetime('now', '-12 days'), datetime('now', '-12 days')), ('demo-draft-1', 'understanding-react-server-components', 'Understanding React Server Components', 'A deep dive into RSC architecture and when to use them.', 'React Server Components are confusing. Let me try to explain them. ## The Problem RSC Solves Traditional React apps ship a lot of JavaScript to the browser. RSC lets you run components on the server and send only the HTML. ## Key Concepts TODO: Add diagrams ## When to Use RSC - Data fetching - Heavy dependencies - SEO-critical pages ## When NOT to Use RSC - Interactive components - Client state - Event handlers Still writing this one...', '["react","architecture"]', '', 0, NULL, datetime('now', '-1 days'), datetime('now', '-2 hours')), ('demo-draft-2', 'vim-tricks-i-use-daily', 'Vim Tricks I Use Daily', 'The shortcuts that actually stuck after 5 years of using Vim.', 'After mass years of Vim, these are the commands I use daily. ## Navigation - Ctrl+d and Ctrl+u for half page down/up - zz to center current line ## Editing Still collecting my favorites...', '["vim","productivity"]', '', 0, NULL, datetime('now', '-3 days'), datetime('now', '-1 days')) ON CONFLICT DO NOTHING`) if err != nil { return fmt.Errorf("insert posts: %w", err) } slog.Info("seed demo posts", "demo_id", demoID) return nil } func (s *Server) ensureDemoSeeded(demoID string) { db, err := s.tenantPool.Get(demoID) if err != nil { slog.Error("ensureDemoSeeded: get pool", "demo_id", demoID, "error", err) return } var count int err = db.QueryRow("SELECT COUNT(*) FROM posts").Scan(&count) if err != nil { slog.Error("ensureDemoSeeded: count posts", "demo_id", demoID, "error", err) return } if count > 0 { return } slog.Info("re-seeding demo content", "demo_id", demoID) if err := s.seedDemoContent(demoID, "Demo User", "#10b981"); err != nil { slog.Error("ensureDemoSeeded: seed failed", "demo_id", demoID, "error", err) } }