Files
librenotes/cmd/librenotes/serve.go
T
libretechandClaude Opus 4.7 ee4de51728 Add librenotes serve command and public landing page
cmd/librenotes/serve.go wires the multi-tenant HTTP server:
storage + auth + httpapi packages, configurable via flags or
LIBRENOTES_* env vars. Embeds web/public/ for unauthenticated
static content. Generates an ephemeral JWT secret with a warning
when none is supplied. Adds security headers (CSP, nosniff,
DENY-frame, no-referrer) on every response. Background goroutine
purges expired magic-link tokens every 10 minutes.

cmd/librenotes/web/public/ provides the unauthenticated frontend:
- index.html: hero, features grid, fork attribution, footer.
  Mobile-first, responsive from 320px up via clamp() and
  auto-fit grid. SEO + Open Graph tags. No JS dependency.
- privacy.html: placeholder privacy policy (full text TBD).
- style.css: shared design tokens (light/dark via [data-theme]),
  used by landing, auth pages, and the post-login app shell.
- favicon.svg: minimal mark.

The "serve" command sits alongside the original notesium CLI
verbs; main.go dispatches "serve" to the new code path and
forwards everything else to notesium.Run().

Closes #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:24:37 +02:00

178 lines
5.0 KiB
Go

package main
import (
"context"
"crypto/rand"
"embed"
"encoding/hex"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"time"
"git.librete.ch/public/librenotes/internal/auth"
"git.librete.ch/public/librenotes/internal/httpapi"
"git.librete.ch/public/librenotes/internal/storage"
)
//go:embed all:web/public
var publicFS embed.FS
type serveConfig struct {
addr string
dataDir string
dbPath string
baseURL string
jwtSecret string
smtpHost string
smtpPort string
smtpUser string
smtpPass string
smtpFrom string
}
func loadConfig(args []string) (serveConfig, error) {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
c := serveConfig{}
fs.StringVar(&c.addr, "addr", envOr("LIBRENOTES_ADDR", ":8080"), "listen address")
fs.StringVar(&c.dataDir, "data-dir", envOr("LIBRENOTES_DATA_DIR", "./data"), "directory for per-tenant note storage")
fs.StringVar(&c.dbPath, "db", envOr("LIBRENOTES_DB", "./librenotes.db"), "SQLite database path")
fs.StringVar(&c.baseURL, "base-url", envOr("LIBRENOTES_BASE_URL", "http://localhost:8080"), "public origin used in magic links")
fs.StringVar(&c.jwtSecret, "jwt-secret", os.Getenv("LIBRENOTES_JWT_SECRET"), "HMAC secret for session JWTs (>=32 bytes)")
fs.StringVar(&c.smtpHost, "smtp-host", os.Getenv("LIBRENOTES_SMTP_HOST"), "SMTP host (empty = log to stdout)")
fs.StringVar(&c.smtpPort, "smtp-port", envOr("LIBRENOTES_SMTP_PORT", "587"), "SMTP port")
fs.StringVar(&c.smtpUser, "smtp-user", os.Getenv("LIBRENOTES_SMTP_USER"), "SMTP username")
fs.StringVar(&c.smtpPass, "smtp-pass", os.Getenv("LIBRENOTES_SMTP_PASS"), "SMTP password")
fs.StringVar(&c.smtpFrom, "smtp-from", os.Getenv("LIBRENOTES_SMTP_FROM"), "envelope From address")
if err := fs.Parse(args); err != nil {
return c, err
}
return c, nil
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func runServe(args []string) error {
c, err := loadConfig(args)
if err != nil {
return err
}
logger := log.New(os.Stderr, "librenotes ", log.LstdFlags|log.Lmsgprefix)
if c.jwtSecret == "" {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return fmt.Errorf("generate jwt secret: %w", err)
}
c.jwtSecret = hex.EncodeToString(buf)
logger.Printf("warning: no LIBRENOTES_JWT_SECRET set; generated ephemeral secret. Sessions will not survive restart.")
}
if len(c.jwtSecret) < 32 {
return fmt.Errorf("jwt secret must be at least 32 bytes")
}
if err := os.MkdirAll(c.dataDir, 0o700); err != nil {
return fmt.Errorf("mkdir data-dir: %w", err)
}
if err := os.MkdirAll(filepath.Dir(c.dbPath), 0o700); err != nil {
return fmt.Errorf("mkdir db dir: %w", err)
}
db, err := storage.Open(c.dbPath)
if err != nil {
return err
}
defer db.Close()
users := storage.NewUserStore(db)
tokens := auth.NewTokenStore(db)
limiter := auth.NewRateLimiter(db, 15*time.Minute, 5)
signer := auth.NewSigner([]byte(c.jwtSecret))
var mailer auth.Mailer
if c.smtpHost == "" {
logger.Printf("SMTP not configured; magic links will be logged to stdout")
mailer = auth.LogMailer{W: os.Stdout}
} else {
mailer = auth.SMTPMailer{
Host: c.smtpHost, Port: c.smtpPort,
Username: c.smtpUser, Password: c.smtpPass,
From: c.smtpFrom,
}
}
authSvc, err := auth.NewService(auth.Config{
Users: users, Tokens: tokens, Limiter: limiter,
Mailer: mailer, Signer: signer,
BaseURL: c.baseURL, DataDir: c.dataDir,
})
if err != nil {
return err
}
// Background: purge expired magic tokens every 10 minutes.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go purgeLoop(ctx, tokens, logger)
api := &httpapi.Server{
Auth: auth.Handlers{Service: authSvc},
Signer: signer,
Logger: logger,
}
root := http.NewServeMux()
apiHandler := api.Routes()
root.Handle("/auth/", apiHandler)
root.Handle("/api/", apiHandler)
pub, err := fs.Sub(publicFS, "web/public")
if err != nil {
return fmt.Errorf("public fs: %w", err)
}
root.Handle("/", http.FileServer(http.FS(pub)))
srv := &http.Server{
Addr: c.addr,
Handler: withSecurityHeaders(root),
ReadHeaderTimeout: 10 * time.Second,
}
logger.Printf("listening on %s, base URL %s, data dir %s", c.addr, c.baseURL, c.dataDir)
return srv.ListenAndServe()
}
func purgeLoop(ctx context.Context, tokens *auth.TokenStore, logger *log.Logger) {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := tokens.PurgeExpired(ctx, 24*time.Hour); err != nil {
logger.Printf("token purge: %v", err)
}
}
}
}
func withSecurityHeaders(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'")
h.ServeHTTP(w, r)
})
}