writekit/internal/server/platform.go

650 lines
18 KiB
Go
Raw Normal View History

2026-01-09 00:16:46 +02:00
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(`<!DOCTYPE html>
<html>
<head><title>Dashboard - WriteKit</title></head>
<body>
<h1>Dashboard</h1>
<p>Create your blog or manage your existing one.</p>
</body>
</html>`))
}
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)
}
}