PREFACE TOOLS
{{with .Error}}{{.}}
{{end}}diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ae246ae --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +APP_ENV=development +HTTP_ADDRESS=:8080 +PUBLIC_BASE_URL=http://localhost:8080 +STUDENT_PIN=change-me +INSTRUCTOR_PIN=change-me-too +SESSION_SIGNING_SECRET=replace-with-at-least-32-random-characters +SESSION_DURATION=4h +LOG_LEVEL=info +LOG_FORMAT=text + +COMIC_ANIMATOR_OPENROUTER_API_KEY= +COMIC_ANIMATOR_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 +COMIC_ANIMATOR_OPENROUTER_SITE_URL= +COMIC_ANIMATOR_OPENROUTER_APP_NAME=Preface Tools - Comic Animator +COMIC_ANIMATOR_PROMPT_MODEL= +COMIC_ANIMATOR_VIDEO_MODEL= +COMIC_ANIMATOR_VIDEO_DURATION=6 +COMIC_ANIMATOR_VIDEO_RESOLUTION=720p +COMIC_ANIMATOR_VIDEO_ASPECT_RATIO=16:9 +COMIC_ANIMATOR_GENERATE_AUDIO=false +COMIC_ANIMATOR_HTTP_TIMEOUT=60s +COMIC_ANIMATOR_POLL_INTERVAL=30s +COMIC_ANIMATOR_JOB_TIMEOUT=15m +COMIC_ANIMATOR_SIGNING_SECRET=replace-with-another-32-character-secret +COMIC_ANIMATOR_SIGNED_URL_TTL=30m +COMIC_ANIMATOR_UPLOAD_DIR=data/comic-animator/uploads +COMIC_ANIMATOR_OUTPUT_DIR=data/comic-animator/outputs +COMIC_ANIMATOR_MAX_UPLOAD_BYTES=20971520 +COMIC_ANIMATOR_MAX_VIDEO_BYTES=536870912 +COMIC_ANIMATOR_QUEUE_CAPACITY=100 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28af297 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +data/comic-animator/uploads/* +data/comic-animator/outputs/* +preface-tools diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..97831a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.26 AS build +WORKDIR /src +COPY go.mod go.sum* ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /preface-tools ./cmd/preface-tools + +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR /app +COPY --from=build /preface-tools /usr/local/bin/preface-tools +VOLUME ["/app/data"] +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/preface-tools"] diff --git a/README.md b/README.md index e69de29..d8e36e1 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,46 @@ +# Preface Tools + +Preface Tools is a database-free classroom utility server. Its first tool, +Comic Animator, lets a student upload a comic page, create and edit a +multimodal OpenRouter prompt, obtain instructor approval, and generate a video. + +## Run locally + +Requires Go 1.26 or later. Copy `.env.example` to `.env`, replace every secret +and model placeholder, export the variables, then run: + +```sh +go run ./cmd/preface-tools +``` + +The process fails at startup when required configuration is missing. In +production, use HTTPS and set `APP_ENV=production` so the session cookie is +marked Secure. `PUBLIC_BASE_URL` must be an HTTPS address reachable by +OpenRouter because it fetches a short-lived, signed source-image URL. + +## Storage and restart behavior + +Uploads and runtime generation records are held in process-specific registries. +Completed videos are streamed immediately and atomically into +`data/comic-animator/outputs` (or the configured output directory). The +instructor page scans that directory, so downloaded videos remain recoverable +after restart. + +OpenRouter does not document an endpoint for listing all historical video jobs. +Consequently, queued and in-progress jobs and rich metadata cannot be recovered +after a restart. The downloaded-file view is a recovery aid, not an audit log. +Uploads and outputs are not automatically removed; operators must monitor disk +usage and introduce a retention policy appropriate to their deployment. + +## Verification + +```sh +gofmt -w . +go test ./... +go test -race ./... +go vet ./... +go build ./... +``` + +No database, Node.js, npm, frontend build, or live OpenRouter call is required +by the automated test suite. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..55b4e0d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.michelsen.id/phill/preface-tools + +go 1.26.5 diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..3f6a2fb --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,63 @@ +package app + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + + "git.michelsen.id/phill/preface-tools/internal/auth" + "git.michelsen.id/phill/preface-tools/internal/httpserver" + "git.michelsen.id/phill/preface-tools/internal/tools" + "git.michelsen.id/phill/preface-tools/internal/tools/comicanimator" +) + +func Run() error { + cfg, err := LoadConfigFromEnv() + if err != nil { + return err + } + comicCfg, err := comicanimator.LoadConfigFromEnv() + if err != nil { + return err + } + var handler slog.Handler = slog.NewTextHandler(os.Stdout, nil) + if cfg.LogFormat == "json" { + handler = slog.NewJSONHandler(os.Stdout, nil) + } + log := slog.New(handler) + a := auth.New(cfg.StudentPIN, cfg.InstructorPIN, cfg.SessionSecret, cfg.SessionDuration, cfg.Environment == "production") + comic, err := comicanimator.New(comicCfg, log, a) + if err != nil { + return err + } + registry := tools.NewRegistry() + if err = registry.Register(comic); err != nil { + return err + } + srv := &http.Server{Addr: cfg.Address, Handler: httpserver.New(a, registry, log).Handler(), ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + workerDone := make(chan error, 1) + go func() { workerDone <- comic.Run(ctx) }() + serverDone := make(chan error, 1) + go func() { log.Info("server starting", "address", cfg.Address); serverDone <- srv.ListenAndServe() }() + select { + case err = <-serverDone: + if !errors.Is(err, http.ErrServerClosed) { + stop() + return err + } + case <-ctx.Done(): + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + return err + } + stop() + return <-workerDone +} diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..2a1d710 --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,42 @@ +package app + +import ( + "errors" + "os" + "time" +) + +type Config struct { + Environment, Address, PublicBaseURL, StudentPIN, InstructorPIN, SessionSecret, LogLevel, LogFormat string + SessionDuration, ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout, ShutdownTimeout time.Duration +} + +func LoadConfigFromEnv() (Config, error) { + c := Config{Environment: get("APP_ENV", "development"), Address: get("HTTP_ADDRESS", ":8080"), PublicBaseURL: os.Getenv("PUBLIC_BASE_URL"), StudentPIN: os.Getenv("STUDENT_PIN"), InstructorPIN: os.Getenv("INSTRUCTOR_PIN"), SessionSecret: os.Getenv("SESSION_SIGNING_SECRET"), LogLevel: get("LOG_LEVEL", "info"), LogFormat: get("LOG_FORMAT", "text")} + var err error + c.SessionDuration, err = duration("SESSION_DURATION", "4h") + if err != nil { + return c, err + } + values := []struct { + target *time.Duration + key, fallback string + }{{&c.ReadHeaderTimeout, "HTTP_READ_HEADER_TIMEOUT", "5s"}, {&c.ReadTimeout, "HTTP_READ_TIMEOUT", "30s"}, {&c.WriteTimeout, "HTTP_WRITE_TIMEOUT", "30s"}, {&c.IdleTimeout, "HTTP_IDLE_TIMEOUT", "60s"}, {&c.ShutdownTimeout, "HTTP_SHUTDOWN_TIMEOUT", "10s"}} + for _, value := range values { + *value.target, err = duration(value.key, value.fallback) + if err != nil { + return c, err + } + } + if c.StudentPIN == "" || c.InstructorPIN == "" || len(c.SessionSecret) < 32 || c.PublicBaseURL == "" { + return c, errors.New("STUDENT_PIN, INSTRUCTOR_PIN, PUBLIC_BASE_URL, and a 32+ character SESSION_SIGNING_SECRET are required") + } + return c, nil +} +func get(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} +func duration(k, d string) (time.Duration, error) { return time.ParseDuration(get(k, d)) } diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..466be11 --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,174 @@ +package auth + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "strings" + "sync" + "time" +) + +type Role string + +const ( + Student Role = "student" + Instructor Role = "instructor" + CookieName = "preface_session" +) + +type Claims struct { + Role Role `json:"role"` + SessionID string `json:"session_id,omitempty"` + IssuedAt int64 `json:"issued_at"` + ExpiresAt int64 `json:"expires_at"` + Version string `json:"credential_version"` + CSRFToken string `json:"csrf_token"` +} +type Service struct { + studentPIN, instructorPIN string + secret []byte + duration time.Duration + secure bool + limiter *Limiter +} + +func New(studentPIN, instructorPIN, secret string, duration time.Duration, secure bool) *Service { + return &Service{studentPIN: studentPIN, instructorPIN: instructorPIN, secret: []byte(secret), duration: duration, secure: secure, limiter: NewLimiter(5, 5*time.Minute)} +} +func constantEqual(a, b string) bool { return hmac.Equal([]byte(a), []byte(b)) } +func (s *Service) VerifyInstructorPIN(pin string) bool { return constantEqual(pin, s.instructorPIN) } +func randomToken() string { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b) +} +func (s *Service) version(role Role) string { + pin := s.studentPIN + if role == Instructor { + pin = s.instructorPIN + } + m := hmac.New(sha256.New, s.secret) + m.Write([]byte(string(role) + pin)) + return base64.RawURLEncoding.EncodeToString(m.Sum(nil)[:16]) +} +func (s *Service) Authenticate(role Role, pin, key string) (Claims, error) { + if !s.limiter.Allow(key) { + return Claims{}, errors.New("too many attempts") + } + expected := s.studentPIN + if role == Instructor { + expected = s.instructorPIN + } + if !constantEqual(pin, expected) { + s.limiter.Fail(key) + return Claims{}, errors.New("invalid PIN") + } + s.limiter.Success(key) + now := time.Now() + c := Claims{Role: role, IssuedAt: now.Unix(), ExpiresAt: now.Add(s.duration).Unix(), Version: s.version(role), CSRFToken: randomToken()} + if role == Student { + c.SessionID = randomToken() + } + return c, nil +} +func (s *Service) Sign(c Claims) string { + b, _ := json.Marshal(c) + p := base64.RawURLEncoding.EncodeToString(b) + m := hmac.New(sha256.New, s.secret) + m.Write([]byte(p)) + return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil)) +} +func (s *Service) Parse(raw string) (Claims, error) { + var c Claims + parts := strings.Split(raw, ".") + if len(parts) != 2 { + return c, errors.New("bad session") + } + sig, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return c, err + } + m := hmac.New(sha256.New, s.secret) + m.Write([]byte(parts[0])) + if !hmac.Equal(sig, m.Sum(nil)) { + return c, errors.New("bad signature") + } + b, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return c, err + } + if err = json.Unmarshal(b, &c); err != nil { + return c, err + } + if time.Now().Unix() >= c.ExpiresAt || c.Version != s.version(c.Role) { + return c, errors.New("expired session") + } + return c, nil +} +func (s *Service) SetCookie(w http.ResponseWriter, c Claims) { + http.SetCookie(w, &http.Cookie{Name: CookieName, Value: s.Sign(c), Path: "/", HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode, MaxAge: int(s.duration.Seconds())}) +} +func (s *Service) ClearCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: CookieName, Path: "/", HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode, MaxAge: -1}) +} +func (s *Service) FromRequest(r *http.Request) (Claims, error) { + c, err := r.Cookie(CookieName) + if err != nil { + return Claims{}, err + } + return s.Parse(c.Value) +} + +type contextKey struct{} + +func WithClaims(r *http.Request, c Claims) *http.Request { + return r.WithContext(context.WithValue(r.Context(), contextKey{}, c)) +} +func ClaimsFrom(r *http.Request) (Claims, bool) { + c, ok := r.Context().Value(contextKey{}).(Claims) + return c, ok +} + +type attempt struct { + failures int + reset time.Time +} +type Limiter struct { + mu sync.Mutex + max int + window time.Duration + entries map[string]attempt +} + +func NewLimiter(max int, window time.Duration) *Limiter { + return &Limiter{max: max, window: window, entries: map[string]attempt{}} +} +func (l *Limiter) Allow(k string) bool { + l.mu.Lock() + defer l.mu.Unlock() + a := l.entries[k] + if time.Now().After(a.reset) { + delete(l.entries, k) + return true + } + return a.failures < l.max +} +func (l *Limiter) Fail(k string) { + l.mu.Lock() + defer l.mu.Unlock() + a := l.entries[k] + if time.Now().After(a.reset) { + a = attempt{reset: time.Now().Add(l.window)} + } + a.failures++ + l.entries[k] = a +} +func (l *Limiter) Success(k string) { l.mu.Lock(); defer l.mu.Unlock(); delete(l.entries, k) } diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go new file mode 100644 index 0000000..8de120c --- /dev/null +++ b/internal/auth/service_test.go @@ -0,0 +1,37 @@ +package auth + +import ( + "testing" + "time" +) + +func TestSessionsAndRotation(t *testing.T) { + s := New("1234", "9876", string(make([]byte, 32)), time.Hour, false) + c, err := s.Authenticate(Student, "1234", "ip") + if err != nil { + t.Fatal(err) + } + if c.SessionID == "" || c.CSRFToken == "" { + t.Fatal("missing random claims") + } + raw := s.Sign(c) + got, err := s.Parse(raw) + if err != nil || got.Role != Student { + t.Fatalf("parse: %#v %v", got, err) + } + rotated := New("4321", "9876", string(make([]byte, 32)), time.Hour, false) + if _, err := rotated.Parse(raw); err == nil { + t.Fatal("PIN rotation did not invalidate session") + } +} +func TestWrongPINAndExpiry(t *testing.T) { + s := New("1234", "9876", string(make([]byte, 32)), time.Hour, false) + if _, err := s.Authenticate(Instructor, "bad", "ip"); err == nil { + t.Fatal("wrong PIN accepted") + } + c, _ := s.Authenticate(Student, "1234", "another") + c.ExpiresAt = time.Now().Add(-time.Second).Unix() + if _, err := s.Parse(s.Sign(c)); err == nil { + t.Fatal("expired session accepted") + } +} diff --git a/internal/httpserver/server.go b/internal/httpserver/server.go new file mode 100644 index 0000000..aa48bcc --- /dev/null +++ b/internal/httpserver/server.go @@ -0,0 +1,226 @@ +package httpserver + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "html/template" + "log/slog" + "net" + "net/http" + "strings" + "time" + + "git.michelsen.id/phill/preface-tools/internal/auth" + "git.michelsen.id/phill/preface-tools/internal/tools" +) + +type Server struct { + auth *auth.Service + registry *tools.Registry + log *slog.Logger + handler http.Handler +} + +func New(a *auth.Service, r *tools.Registry, log *slog.Logger) *Server { + s := &Server{auth: a, registry: r, log: log} + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) }) + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ready\n")) }) + mux.HandleFunc("GET /static/app.css", css) + mux.HandleFunc("GET /static/app.js", js) + mux.HandleFunc("GET /login", s.loginPage) + mux.HandleFunc("POST /login/{role}", s.login) + mux.Handle("POST /logout", s.withAuth("", s.csrf(http.HandlerFunc(s.logout)))) + mux.HandleFunc("GET /", s.root) + for _, tool := range r.List() { + key := tool.Definition().Key + studentPrefix := "/tools/" + key + suffix := http.StripPrefix(studentPrefix, tool.StudentHandler()) + mux.Handle(studentPrefix+"/provider-media/", suffix) + mux.Handle(studentPrefix+"/", s.withAuth(auth.Student, s.csrf(s.shell(tool, suffix, false)))) + if it, ok := tool.(tools.InstructorTool); ok { + prefix := "/instructor/tools/" + key + mux.Handle(prefix+"/", s.withAuth(auth.Instructor, s.csrf(s.shell(tool, http.StripPrefix(prefix, it.InstructorHandler()), true)))) + } + } + mux.Handle("GET /instructor", s.withAuth(auth.Instructor, http.HandlerFunc(s.instructorRoot))) + s.handler = s.middleware(mux) + return s +} +func (s *Server) Handler() http.Handler { return s.handler } +func (s *Server) root(w http.ResponseWriter, r *http.Request) { + c, err := s.auth.FromRequest(r) + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + if c.Role == auth.Instructor { + http.Redirect(w, r, "/instructor", 303) + return + } + list := s.registry.List() + if len(list) == 0 { + http.Error(w, "no tools configured", 503) + return + } + http.Redirect(w, r, "/tools/"+list[0].Definition().Key+"/", 303) +} +func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { render(w, loginTemplate, nil) } +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 4096) + role := auth.Role(r.PathValue("role")) + if role != auth.Student && role != auth.Instructor { + http.NotFound(w, r) + return + } + c, err := s.auth.Authenticate(role, r.FormValue("pin"), clientIP(r)+":"+string(role)) + if err != nil { + renderStatus(w, loginTemplate, map[string]any{"Error": "Login was not accepted."}, 401) + return + } + s.auth.SetCookie(w, c) + if role == auth.Instructor { + http.Redirect(w, r, "/instructor", 303) + } else { + http.Redirect(w, r, "/", 303) + } +} +func (s *Server) logout(w http.ResponseWriter, r *http.Request) { + s.auth.ClearCookie(w) + http.Redirect(w, r, "/login", 303) +} +func (s *Server) instructorRoot(w http.ResponseWriter, r *http.Request) { + list := s.registry.List() + for _, t := range list { + if _, ok := t.(tools.InstructorTool); ok { + http.Redirect(w, r, "/instructor/tools/"+t.Definition().Key+"/", 303) + return + } + } + http.Error(w, "no instructor tools", 404) +} +func (s *Server) withAuth(role auth.Role, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := s.auth.FromRequest(r) + if err != nil { + http.Redirect(w, r, "/login", 303) + return + } + if role != "" && c.Role != role { + http.Error(w, "forbidden", 403) + return + } + next.ServeHTTP(w, auth.WithClaims(r, c)) + }) +} +func (s *Server) csrf(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet || r.Method == http.MethodHead { + next.ServeHTTP(w, r) + return + } + c, _ := auth.ClaimsFrom(r) + token := r.Header.Get("X-CSRF-Token") + if token == "" { + token = r.FormValue("csrf_token") + } + if token == "" || token != c.CSRFToken { + http.Error(w, "invalid CSRF token", 403) + return + } + if origin := r.Header.Get("Origin"); origin != "" && !strings.HasPrefix(origin, "http://"+r.Host) && !strings.HasPrefix(origin, "https://"+r.Host) { + http.Error(w, "invalid origin", 403) + return + } + next.ServeHTTP(w, r) + }) +} +func (s *Server) shell(tool tools.Tool, next http.Handler, instructor bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + next.ServeHTTP(w, r) + return + } + rw := &capture{header: http.Header{}} + next.ServeHTTP(rw, r) + if rw.status >= 400 { + copyHeader(w.Header(), rw.header) + w.WriteHeader(rw.status) + w.Write(rw.body) + return + } + c, _ := auth.ClaimsFrom(r) + data := map[string]any{"Title": tool.Definition().Name, "Content": template.HTML(rw.body), "Tools": s.registry.List(), "Selected": tool.Definition().Key, "Instructor": instructor, "CSRF": c.CSRFToken} + render(w, shellTemplate, data) + }) +} + +type capture struct { + header http.Header + body []byte + status int +} + +func (c *capture) Header() http.Header { return c.header } +func (c *capture) WriteHeader(n int) { c.status = n } +func (c *capture) Write(b []byte) (int, error) { + if c.status == 0 { + c.status = 200 + } + c.body = append(c.body, b...) + return len(b), nil +} +func copyHeader(dst, src http.Header) { + for k, v := range src { + dst[k] = v + } +} +func (s *Server) middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + b := make([]byte, 8) + rand.Read(b) + rid := hex.EncodeToString(b) + w.Header().Set("X-Request-ID", rid) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "same-origin") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' https://cdn.jsdelivr.net; script-src 'self' https://cdn.jsdelivr.net; img-src 'self' data:; media-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'") + defer func() { + if v := recover(); v != nil { + s.log.Error("request panic", "request_id", rid, "error", v) + http.Error(w, "internal server error", 500) + } + s.log.Info("request", "request_id", rid, "method", r.Method, "path", r.URL.Path, "duration", time.Since(start)) + }() + next.ServeHTTP(w, r) + }) +} +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return r.RemoteAddr +} +func render(w http.ResponseWriter, src string, data any) { renderStatus(w, src, data, 200) } +func renderStatus(w http.ResponseWriter, src string, data any, status int) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + template.Must(template.New("page").Parse(src)).Execute(w, data) +} + +const head = `
{{.}}
{{end}}