From 7cb409f5a6522d7056184095d2fe5ff4caa80c06 Mon Sep 17 00:00:00 2001 From: Phillip Michelsen Date: Sun, 12 Jul 2026 02:56:51 +0800 Subject: [PATCH] Codex first iteration --- .env.example | 30 ++ .gitignore | 4 + Dockerfile | 13 + README.md | 46 +++ go.mod | 3 + internal/app/app.go | 63 ++++ internal/app/config.go | 42 +++ internal/auth/service.go | 174 ++++++++++ internal/auth/service_test.go | 37 +++ internal/httpserver/server.go | 226 +++++++++++++ internal/tools/comicanimator/config.go | 84 +++++ internal/tools/comicanimator/models.go | 88 +++++ .../tools/comicanimator/openrouter/client.go | 152 +++++++++ .../comicanimator/openrouter/client_test.go | 73 ++++ internal/tools/comicanimator/tool.go | 311 ++++++++++++++++++ internal/tools/comicanimator/tool_test.go | 62 ++++ internal/tools/comicanimator/views.go | 108 ++++++ internal/tools/comicanimator/worker.go | 166 ++++++++++ internal/tools/tool.go | 57 ++++ internal/tools/tool_test.go | 32 ++ 20 files changed, 1771 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 go.mod create mode 100644 internal/app/app.go create mode 100644 internal/app/config.go create mode 100644 internal/auth/service.go create mode 100644 internal/auth/service_test.go create mode 100644 internal/httpserver/server.go create mode 100644 internal/tools/comicanimator/config.go create mode 100644 internal/tools/comicanimator/models.go create mode 100644 internal/tools/comicanimator/openrouter/client.go create mode 100644 internal/tools/comicanimator/openrouter/client_test.go create mode 100644 internal/tools/comicanimator/tool.go create mode 100644 internal/tools/comicanimator/tool_test.go create mode 100644 internal/tools/comicanimator/views.go create mode 100644 internal/tools/comicanimator/worker.go create mode 100644 internal/tools/tool.go create mode 100644 internal/tools/tool_test.go 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 = `{{if .Title}}{{.Title}} · {{end}}Preface Tools` +const loginTemplate = head + `

PREFACE TOOLS

{{with .Error}}

{{.}}

{{end}}
` +const shellTemplate = head + `
PREFACE TOOLS
{{.Content}}
` + +func css(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/css") + fmt.Fprint(w, `body{margin:0;background:#f7f7fa;color:#172033;font-family:ui-sans-serif,system-ui}header{height:64px;background:#fff;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;padding:0 max(1rem,4vw)}header>a{font-weight:800;letter-spacing:.08em}nav{display:flex;gap:1rem;align-items:center}main{max-width:1400px;margin:0 auto;padding:2rem}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:1.25rem;box-shadow:0 1px 2px #1018280d}.login{max-width:760px;text-align:center;padding-top:12vh}.login-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem;text-align:left}.tool-grid{display:grid;grid-template-columns:1fr 1.3fr .8fr;gap:1rem}.recent{margin-top:1rem}.preview img,.generation video{width:100%;max-height:420px;object-fit:contain;border-radius:8px}.generation{border-top:1px solid #e5e7eb;padding:1rem 0}.generation>div{display:flex;justify-content:space-between}.muted,small{color:#667085}.error{color:#b42318}.htmx-indicator{display:none}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{display:inline}textarea,input,select{width:100%;margin:.35rem 0 1rem}.btn{display:inline-flex;margin-top:.5rem}.primary{background:#4f46e5;color:#fff}dialog{max-width:440px;width:calc(100% - 2rem)}@media(max-width:900px){.tool-grid{grid-template-columns:1fr 1fr}.tool-grid>*:last-child{grid-column:1/-1}}@media(max-width:640px){main{padding:1rem}.tool-grid,.login-grid{grid-template-columns:1fr}.tool-grid>*:last-child{grid-column:auto}}`) +} +func js(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/javascript") + fmt.Fprint(w, `document.addEventListener("htmx:afterSwap",()=>window.basecoat?.initAll());document.addEventListener("htmx:historyRestore",()=>window.basecoat?.initAll({force:true}));document.addEventListener("change",e=>{if(e.target.id==="tool-selector")location.href=e.target.value});document.addEventListener("click",e=>{if(e.target.closest("[data-open-approval]"))document.querySelector("#approval-dialog")?.showModal();if(e.target.closest("[data-close-approval]"))document.querySelector("#approval-dialog")?.close()});document.addEventListener("generationQueued",()=>{const d=document.querySelector("#approval-dialog"),p=d?.querySelector('[name="instructor_pin"]');if(p)p.value="";d?.close()});`) +} diff --git a/internal/tools/comicanimator/config.go b/internal/tools/comicanimator/config.go new file mode 100644 index 0000000..779888c --- /dev/null +++ b/internal/tools/comicanimator/config.go @@ -0,0 +1,84 @@ +package comicanimator + +import ( + "errors" + "os" + "strconv" + "time" +) + +type Config struct { + OpenRouterAPIKey, OpenRouterBaseURL, OpenRouterSiteURL, OpenRouterAppName, PromptModel, VideoModel, VideoResolution, VideoAspectRatio, PublicBaseURL, SigningSecret, UploadDirectory, OutputDirectory string + VideoDuration int + GenerateAudio bool + PollInterval, JobTimeout, HTTPTimeout, SignedURLTTL time.Duration + MaxUploadBytes, MaxGeneratedVideoBytes int64 + QueueCapacity int +} + +func LoadConfigFromEnv() (Config, error) { + c := Config{OpenRouterAPIKey: os.Getenv("COMIC_ANIMATOR_OPENROUTER_API_KEY"), OpenRouterBaseURL: get("COMIC_ANIMATOR_OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"), OpenRouterSiteURL: os.Getenv("COMIC_ANIMATOR_OPENROUTER_SITE_URL"), OpenRouterAppName: get("COMIC_ANIMATOR_OPENROUTER_APP_NAME", "Preface Tools - Comic Animator"), PromptModel: os.Getenv("COMIC_ANIMATOR_PROMPT_MODEL"), VideoModel: os.Getenv("COMIC_ANIMATOR_VIDEO_MODEL"), VideoResolution: get("COMIC_ANIMATOR_VIDEO_RESOLUTION", "720p"), VideoAspectRatio: get("COMIC_ANIMATOR_VIDEO_ASPECT_RATIO", "16:9"), PublicBaseURL: os.Getenv("PUBLIC_BASE_URL"), SigningSecret: os.Getenv("COMIC_ANIMATOR_SIGNING_SECRET"), UploadDirectory: get("COMIC_ANIMATOR_UPLOAD_DIR", "data/comic-animator/uploads"), OutputDirectory: get("COMIC_ANIMATOR_OUTPUT_DIR", "data/comic-animator/outputs")} + var err error + c.VideoDuration, err = intenv("COMIC_ANIMATOR_VIDEO_DURATION", 6) + if err != nil { + return c, err + } + c.GenerateAudio, err = strconv.ParseBool(get("COMIC_ANIMATOR_GENERATE_AUDIO", "false")) + if err != nil { + return c, err + } + c.PollInterval, err = dur("COMIC_ANIMATOR_POLL_INTERVAL", "30s") + if err != nil { + return c, err + } + c.JobTimeout, err = dur("COMIC_ANIMATOR_JOB_TIMEOUT", "15m") + if err != nil { + return c, err + } + c.HTTPTimeout, err = dur("COMIC_ANIMATOR_HTTP_TIMEOUT", "60s") + if err != nil { + return c, err + } + c.SignedURLTTL, err = dur("COMIC_ANIMATOR_SIGNED_URL_TTL", "30m") + if err != nil { + return c, err + } + c.MaxUploadBytes, err = int64env("COMIC_ANIMATOR_MAX_UPLOAD_BYTES", 20971520) + if err != nil { + return c, err + } + c.MaxGeneratedVideoBytes, err = int64env("COMIC_ANIMATOR_MAX_VIDEO_BYTES", 536870912) + if err != nil { + return c, err + } + c.QueueCapacity, err = intenv("COMIC_ANIMATOR_QUEUE_CAPACITY", 100) + if err != nil { + return c, err + } + if c.OpenRouterAPIKey == "" || c.PromptModel == "" || c.VideoModel == "" || c.PublicBaseURL == "" || len(c.SigningSecret) < 32 { + return c, errors.New("Comic Animator OpenRouter key/models, PUBLIC_BASE_URL, and a 32+ character signing secret are required") + } + if c.VideoDuration < 1 || c.QueueCapacity < 1 || c.MaxUploadBytes < 1 || c.MaxGeneratedVideoBytes < 1 || c.PollInterval <= 0 || c.JobTimeout <= 0 || c.HTTPTimeout <= 0 || c.SignedURLTTL <= 0 { + return c, errors.New("Comic Animator numeric configuration is invalid") + } + return c, nil +} +func get(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} +func dur(k, d string) (time.Duration, error) { return time.ParseDuration(get(k, d)) } +func intenv(k string, d int) (int, error) { + if os.Getenv(k) == "" { + return d, nil + } + return strconv.Atoi(os.Getenv(k)) +} +func int64env(k string, d int64) (int64, error) { + if os.Getenv(k) == "" { + return d, nil + } + return strconv.ParseInt(os.Getenv(k), 10, 64) +} diff --git a/internal/tools/comicanimator/models.go b/internal/tools/comicanimator/models.go new file mode 100644 index 0000000..d0d005c --- /dev/null +++ b/internal/tools/comicanimator/models.go @@ -0,0 +1,88 @@ +package comicanimator + +import ( + "errors" + "sort" + "sync" + "time" +) + +type Upload struct { + ID, SessionID, Path, Name, MIMEType string + Size int64 + CreatedAt time.Time +} +type GenerationStatus string + +const ( + Queued GenerationStatus = "queued" + Submitting GenerationStatus = "submitting" + Pending GenerationStatus = "pending" + InProgress GenerationStatus = "in_progress" + Downloading GenerationStatus = "downloading" + Completed GenerationStatus = "completed" + Failed GenerationStatus = "failed" +) + +type Generation struct { + ID, SessionID, UploadID, OriginalName, ReviewedPrompt, ProviderJobID string + Status GenerationStatus + Duration int + Resolution, AspectRatio, OutputPath, OutputMIMEType string + OutputSize int64 + ErrorCode, ErrorMessage string + CreatedAt, UpdatedAt time.Time + CompletedAt *time.Time +} +type store struct { + mu sync.RWMutex + uploads map[string]Upload + generations map[string]Generation +} + +func newStore() *store { + return &store{uploads: map[string]Upload{}, generations: map[string]Generation{}} +} +func (s *store) putUpload(u Upload) { s.mu.Lock(); defer s.mu.Unlock(); s.uploads[u.ID] = u } +func (s *store) upload(id string) (Upload, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + u, ok := s.uploads[id] + return u, ok +} +func (s *store) putGeneration(g Generation) { + s.mu.Lock() + defer s.mu.Unlock() + s.generations[g.ID] = g +} +func (s *store) generation(id string) (Generation, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + g, ok := s.generations[id] + return g, ok +} +func (s *store) update(id string, fn func(*Generation)) { + s.mu.Lock() + defer s.mu.Unlock() + g, ok := s.generations[id] + if !ok { + return + } + fn(&g) + g.UpdatedAt = time.Now().UTC() + s.generations[id] = g +} +func (s *store) list(session string) []Generation { + s.mu.RLock() + defer s.mu.RUnlock() + out := []Generation{} + for _, g := range s.generations { + if session == "" || g.SessionID == session { + out = append(out, g) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + return out +} + +var errForbidden = errors.New("access denied") diff --git a/internal/tools/comicanimator/openrouter/client.go b/internal/tools/comicanimator/openrouter/client.go new file mode 100644 index 0000000..d6a1f58 --- /dev/null +++ b/internal/tools/comicanimator/openrouter/client.go @@ -0,0 +1,152 @@ +package openrouter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +type Client struct { + BaseURL, APIKey, SiteURL, AppName string + HTTP *http.Client +} + +func (c *Client) do(ctx context.Context, method, path string, body any) (*http.Response, error) { + var reader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.BaseURL, "/")+path, reader) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.APIKey) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.SiteURL != "" { + req.Header.Set("HTTP-Referer", c.SiteURL) + } + if c.AppName != "" { + req.Header.Set("X-Title", c.AppName) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("OpenRouter status %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + } + return resp, nil +} + +type imageURL struct { + URL string `json:"url"` +} +type contentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *imageURL `json:"image_url,omitempty"` +} +type message struct { + Role string `json:"role"` + Content any `json:"content"` +} + +func (c *Client) Prompt(ctx context.Context, model, description, dataURL, system string) (string, error) { + body := map[string]any{ + "model": model, + "messages": []message{ + {Role: "system", Content: system}, + {Role: "user", Content: []contentPart{ + {Type: "text", Text: description}, + {Type: "image_url", ImageURL: &imageURL{URL: dataURL}}, + }}, + }, + "response_format": map[string]any{"type": "json_schema", "json_schema": map[string]any{"name": "video_prompt", "strict": true, "schema": map[string]any{"type": "object", "properties": map[string]any{"video_prompt": map[string]string{"type": "string"}}, "required": []string{"video_prompt"}, "additionalProperties": false}}}, + } + resp, err := c.do(ctx, http.MethodPost, "/chat/completions", body) + if err != nil { + return "", err + } + defer resp.Body.Close() + var out struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err = json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&out); err != nil { + return "", err + } + if len(out.Choices) == 0 { + return "", fmt.Errorf("empty model response") + } + var result struct { + VideoPrompt string `json:"video_prompt"` + } + if err = json.Unmarshal([]byte(out.Choices[0].Message.Content), &result); err != nil { + return "", err + } + if strings.TrimSpace(result.VideoPrompt) == "" { + return "", fmt.Errorf("empty video prompt") + } + return result.VideoPrompt, nil +} + +type VideoRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Duration int `json:"duration"` + Resolution string `json:"resolution"` + AspectRatio string `json:"aspect_ratio"` + GenerateAudio bool `json:"generate_audio"` + FrameImages []FrameImage `json:"frame_images"` +} +type FrameImage struct { + Type string `json:"type"` + ImageURL imageURL `json:"image_url"` + FrameType string `json:"frame_type"` +} +type VideoJob struct { + ID string `json:"id"` + Status string `json:"status"` + Error string `json:"error"` + UnsignedURLs []string `json:"unsigned_urls"` +} + +func NewImageURL(raw string) imageURL { return imageURL{URL: raw} } +func (c *Client) Submit(ctx context.Context, v VideoRequest) (VideoJob, error) { + resp, err := c.do(ctx, http.MethodPost, "/videos", v) + if err != nil { + return VideoJob{}, err + } + defer resp.Body.Close() + var job VideoJob + err = json.NewDecoder(resp.Body).Decode(&job) + return job, err +} +func (c *Client) Poll(ctx context.Context, id string) (VideoJob, error) { + resp, err := c.do(ctx, http.MethodGet, "/videos/"+id, nil) + if err != nil { + return VideoJob{}, err + } + defer resp.Body.Close() + var job VideoJob + err = json.NewDecoder(resp.Body).Decode(&job) + return job, err +} +func (c *Client) Content(ctx context.Context, id string) (*http.Response, error) { + return c.do(ctx, http.MethodGet, "/videos/"+id+"/content?index=0", nil) +} diff --git a/internal/tools/comicanimator/openrouter/client_test.go b/internal/tools/comicanimator/openrouter/client_test.go new file mode 100644 index 0000000..6396e85 --- /dev/null +++ b/internal/tools/comicanimator/openrouter/client_test.go @@ -0,0 +1,73 @@ +package openrouter + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +func response(status int, contentType, body string) *http.Response { + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(body))} +} + +func TestPromptMultimodalRequest(t *testing.T) { + var body map[string]any + httpClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/chat/completions" { + t.Errorf("path %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer secret" { + t.Error("missing auth") + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + return response(200, "application/json", `{"choices":[{"message":{"content":"{\"video_prompt\":\"gentle motion\"}"}}]}`), nil + })} + c := Client{BaseURL: "https://openrouter.test", APIKey: "secret", HTTP: httpClient} + got, err := c.Prompt(context.Background(), "model", "student text", "data:image/png;base64,abc", "system") + if err != nil || got != "gentle motion" { + t.Fatalf("got %q %v", got, err) + } + encoded, _ := json.Marshal(body) + for _, want := range []string{"student text", "data:image/png;base64,abc", "json_schema"} { + if !strings.Contains(string(encoded), want) { + t.Errorf("request missing %q", want) + } + } +} + +func TestVideoFlow(t *testing.T) { + httpClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/videos": + return response(202, "application/json", `{"id":"job1","status":"pending"}`), nil + case r.URL.Path == "/videos/job1": + return response(200, "application/json", `{"id":"job1","status":"completed"}`), nil + case r.URL.Path == "/videos/job1/content": + return response(200, "video/mp4", "video"), nil + default: + return response(404, "text/plain", "missing"), nil + } + })} + c := Client{BaseURL: "https://openrouter.test", APIKey: "x", HTTP: httpClient} + job, err := c.Submit(context.Background(), VideoRequest{Model: "m"}) + if err != nil || job.ID != "job1" { + t.Fatal(job, err) + } + job, err = c.Poll(context.Background(), job.ID) + if err != nil || job.Status != "completed" { + t.Fatal(job, err) + } + resp, err := c.Content(context.Background(), job.ID) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() +} diff --git a/internal/tools/comicanimator/tool.go b/internal/tools/comicanimator/tool.go new file mode 100644 index 0000000..e988cef --- /dev/null +++ b/internal/tools/comicanimator/tool.go @@ -0,0 +1,311 @@ +package comicanimator + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "html/template" + "image" + _ "image/jpeg" + _ "image/png" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "git.michelsen.id/phill/preface-tools/internal/auth" + "git.michelsen.id/phill/preface-tools/internal/tools" + "git.michelsen.id/phill/preface-tools/internal/tools/comicanimator/openrouter" +) + +type ApprovalVerifier interface{ VerifyInstructorPIN(string) bool } +type Tool struct { + cfg Config + log *slog.Logger + approval ApprovalVerifier + store *store + queue chan string + client *openrouter.Client + approvalLimiter *auth.Limiter + student, instructor http.Handler +} + +func New(cfg Config, log *slog.Logger, approval ApprovalVerifier) (*Tool, error) { + if err := os.MkdirAll(cfg.UploadDirectory, 0750); err != nil { + return nil, err + } + if err := os.MkdirAll(cfg.OutputDirectory, 0750); err != nil { + return nil, err + } + t := &Tool{cfg: cfg, log: log, approval: approval, store: newStore(), queue: make(chan string, cfg.QueueCapacity)} + t.approvalLimiter = auth.NewLimiter(5, 5*time.Minute) + t.client = &openrouter.Client{BaseURL: cfg.OpenRouterBaseURL, APIKey: cfg.OpenRouterAPIKey, SiteURL: cfg.OpenRouterSiteURL, AppName: cfg.OpenRouterAppName, HTTP: &http.Client{Timeout: cfg.HTTPTimeout}} + sm := http.NewServeMux() + sm.HandleFunc("GET /", t.page) + sm.HandleFunc("POST /uploads", t.upload) + sm.HandleFunc("GET /uploads/{id}/preview", t.preview) + sm.HandleFunc("POST /prompt", t.prompt) + sm.HandleFunc("POST /generations", t.submit) + sm.HandleFunc("GET /generations", t.generations) + sm.HandleFunc("GET /generations/{id}/status", t.status) + sm.HandleFunc("GET /generations/{id}/video", t.video) + sm.HandleFunc("GET /generations/{id}/download", t.download) + sm.HandleFunc("GET /provider-media/{id}", t.providerMedia) + t.student = sm + im := http.NewServeMux() + im.HandleFunc("GET /", t.instructorPage) + im.HandleFunc("GET /generations", t.instructorGenerations) + im.HandleFunc("GET /generations/{id}/video", t.instructorVideo) + im.HandleFunc("GET /generations/{id}/download", t.instructorDownload) + im.HandleFunc("GET /outputs/{filename}/download", t.outputDownload) + t.instructor = im + return t, nil +} +func (t *Tool) Definition() tools.Definition { + return tools.Definition{Key: "comic-animator", Name: "Comic Animator", Description: "Bring a comic page gently to life."} +} +func (t *Tool) StudentHandler() http.Handler { return t.student } +func (t *Tool) InstructorHandler() http.Handler { return t.instructor } +func id(prefix string) string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return prefix + hex.EncodeToString(b) +} +func claims(r *http.Request) auth.Claims { c, _ := auth.ClaimsFrom(r); return c } + +const systemPrompt = `You prepare image-to-video prompts for complete comic-book pages. Treat the student's description as the source of truth. Create one concise, provider-ready image-to-video prompt. Preserve the full page composition, fixed panel borders, captions, lettering, speech bubbles, dialogue, character identity, clothing, colors, art style, and backgrounds. Do not crop, zoom, pan, rotate, reframe, or let anything cross panels unless explicitly requested. Animate only described actions with restrained secondary movement. Return JSON only: {"video_prompt":"..."}` + +func (t *Tool) page(w http.ResponseWriter, r *http.Request) { + render(w, toolPage, map[string]any{"CSRF": claims(r).CSRFToken, "Duration": t.cfg.VideoDuration, "AspectRatio": t.cfg.VideoAspectRatio}) +} +func (t *Tool) upload(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, t.cfg.MaxUploadBytes+1<<20) + if err := r.ParseMultipartForm(t.cfg.MaxUploadBytes); err != nil { + fragmentError(w, http.StatusRequestEntityTooLarge, "Image is too large.") + return + } + f, h, err := r.FormFile("image") + if err != nil { + fragmentError(w, 400, "Choose an image.") + return + } + defer f.Close() + tmp, err := os.CreateTemp(t.cfg.UploadDirectory, ".upload-*") + if err != nil { + fragmentError(w, 500, "Could not store image.") + return + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + n, err := io.Copy(tmp, io.LimitReader(f, t.cfg.MaxUploadBytes+1)) + tmp.Close() + if err != nil || n == 0 || n > t.cfg.MaxUploadBytes { + fragmentError(w, 413, "Invalid image size.") + return + } + rf, err := os.Open(tmpName) + if err != nil { + fragmentError(w, 500, "Could not inspect image.") + return + } + head := make([]byte, 512) + hn, _ := rf.Read(head) + _, seekErr := rf.Seek(0, 0) + var decodeErr error + if http.DetectContentType(head[:hn]) == "image/webp" { + decodeErr = validateWebP(head[:hn]) + } else { + _, _, decodeErr = image.DecodeConfig(rf) + } + rf.Close() + if seekErr != nil || decodeErr != nil { + fragmentError(w, 415, "The image is corrupt or unsupported.") + return + } + mt := http.DetectContentType(head[:hn]) + declared := strings.Split(h.Header.Get("Content-Type"), ";")[0] + if declared != "" && declared != "application/octet-stream" && declared != mt { + fragmentError(w, http.StatusUnsupportedMediaType, "The image type does not match its content.") + return + } + ext := map[string]string{"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"}[mt] + if ext == "" { + fragmentError(w, 415, "Use PNG, JPEG, or WebP.") + return + } + uid := id("upl_") + path := filepath.Join(t.cfg.UploadDirectory, uid+ext) + if err = os.Rename(tmpName, path); err != nil { + fragmentError(w, 500, "Could not store image.") + return + } + u := Upload{ID: uid, SessionID: claims(r).SessionID, Path: path, Name: filepath.Base(h.Filename), MIMEType: mt, Size: n, CreatedAt: time.Now().UTC()} + t.store.putUpload(u) + fmt.Fprintf(w, `
Uploaded comic preview

%s

`, template.HTMLEscapeString(uid), template.HTMLEscapeString(uid), template.HTMLEscapeString(u.Name)) +} + +func validateWebP(head []byte) error { + if len(head) < 20 || string(head[:4]) != "RIFF" || string(head[8:12]) != "WEBP" { + return fmt.Errorf("invalid WebP header") + } + chunk := string(head[12:16]) + if chunk != "VP8 " && chunk != "VP8L" && chunk != "VP8X" { + return fmt.Errorf("invalid WebP chunk") + } + return nil +} +func (t *Tool) ownedUpload(r *http.Request, id string) (Upload, error) { + u, ok := t.store.upload(id) + if !ok { + return u, os.ErrNotExist + } + if u.SessionID != claims(r).SessionID { + return u, errForbidden + } + return u, nil +} +func (t *Tool) preview(w http.ResponseWriter, r *http.Request) { + u, err := t.ownedUpload(r, r.PathValue("id")) + if err != nil { + http.NotFound(w, r) + return + } + serveFile(w, r, u.Path, u.MIMEType, false) +} +func (t *Tool) prompt(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 128<<10) + u, err := t.ownedUpload(r, r.FormValue("upload_id")) + desc := strings.TrimSpace(r.FormValue("description")) + if err != nil || desc == "" { + fragmentError(w, 400, "Upload an image and describe the movement first.") + return + } + if len(desc) > 12000 { + fragmentError(w, 400, "Description is too long.") + return + } + b, err := os.ReadFile(u.Path) + if err != nil { + fragmentError(w, 500, "Could not read image.") + return + } + dataURL := "data:" + u.MIMEType + ";base64," + base64.StdEncoding.EncodeToString(b) + ctx, cancel := context.WithTimeout(r.Context(), t.cfg.HTTPTimeout) + defer cancel() + result, err := t.client.Prompt(ctx, t.cfg.PromptModel, desc, dataURL, systemPrompt) + if err != nil { + t.log.Error("prompt generation failed", "error", err) + fragmentError(w, 502, "Prompt generation is temporarily unavailable.") + return + } + if len(result) > 12000 { + fragmentError(w, 502, "Generated prompt was too long.") + return + } + fmt.Fprintf(w, `%d characters`, template.HTMLEscapeString(result), len([]rune(result))) +} +func (t *Tool) submit(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 128<<10) + key := claims(r).SessionID + if !t.approvalLimiter.Allow(key) || !t.approval.VerifyInstructorPIN(r.FormValue("instructor_pin")) { + t.approvalLimiter.Fail(key) + w.Header().Set("HX-Retarget", "#approval-error") + w.Header().Set("HX-Reswap", "innerHTML") + fragmentError(w, 403, "Approval was not accepted.") + return + } + t.approvalLimiter.Success(key) + u, err := t.ownedUpload(r, r.FormValue("upload_id")) + prompt := strings.TrimSpace(r.FormValue("reviewed_prompt")) + duration, errDuration := strconv.Atoi(r.FormValue("duration")) + aspect := r.FormValue("aspect_ratio") + if err != nil || prompt == "" || len(prompt) > 12000 || errDuration != nil || duration != t.cfg.VideoDuration || aspect != t.cfg.VideoAspectRatio { + fragmentError(w, 400, "Check the image, prompt, and settings.") + return + } + now := time.Now().UTC() + g := Generation{ID: id("gen_"), SessionID: claims(r).SessionID, UploadID: u.ID, OriginalName: u.Name, ReviewedPrompt: prompt, Status: Queued, Duration: duration, Resolution: t.cfg.VideoResolution, AspectRatio: aspect, CreatedAt: now, UpdatedAt: now} + t.store.putGeneration(g) + select { + case t.queue <- g.ID: + w.Header().Set("HX-Trigger", `{"generationQueued":{"id":"`+g.ID+`"}}`) + t.renderCard(w, g, false) + default: + t.store.update(g.ID, func(x *Generation) { + x.Status = Failed + x.ErrorCode = "internal_error" + x.ErrorMessage = "Generation queue is full." + }) + fragmentError(w, 503, "Generation queue is full.") + } +} +func (t *Tool) generations(w http.ResponseWriter, r *http.Request) { + for _, g := range t.store.list(claims(r).SessionID) { + t.renderCard(w, g, false) + } +} +func (t *Tool) status(w http.ResponseWriter, r *http.Request) { + g, ok := t.store.generation(r.PathValue("id")) + if !ok || g.SessionID != claims(r).SessionID { + http.NotFound(w, r) + return + } + t.renderCard(w, g, false) +} +func (t *Tool) video(w http.ResponseWriter, r *http.Request) { t.generationFile(w, r, false, false) } +func (t *Tool) download(w http.ResponseWriter, r *http.Request) { t.generationFile(w, r, false, true) } +func (t *Tool) generationFile(w http.ResponseWriter, r *http.Request, instructor, download bool) { + g, ok := t.store.generation(r.PathValue("id")) + if !ok || g.Status != Completed || (!instructor && g.SessionID != claims(r).SessionID) { + http.NotFound(w, r) + return + } + serveFile(w, r, g.OutputPath, g.OutputMIMEType, download) +} +func serveFile(w http.ResponseWriter, r *http.Request, path, mt string, download bool) { + w.Header().Set("Content-Type", mt) + w.Header().Set("X-Content-Type-Options", "nosniff") + if download { + w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filepath.Base(path), `"`, "")+`"`) + } + http.ServeFile(w, r, path) +} +func (t *Tool) signedURL(uploadID string) string { + exp := time.Now().Add(t.cfg.SignedURLTTL).Unix() + raw := uploadID + "\n" + strconv.FormatInt(exp, 10) + m := hmac.New(sha256.New, []byte(t.cfg.SigningSecret)) + m.Write([]byte(raw)) + return strings.TrimRight(t.cfg.PublicBaseURL, "/") + "/tools/comic-animator/provider-media/" + url.PathEscape(uploadID) + "?expires=" + strconv.FormatInt(exp, 10) + "&signature=" + hex.EncodeToString(m.Sum(nil)) +} +func (t *Tool) providerMedia(w http.ResponseWriter, r *http.Request) { + exp, err := strconv.ParseInt(r.URL.Query().Get("expires"), 10, 64) + if err != nil || time.Now().Unix() > exp { + http.Error(w, "expired", 403) + return + } + raw := r.PathValue("id") + "\n" + strconv.FormatInt(exp, 10) + m := hmac.New(sha256.New, []byte(t.cfg.SigningSecret)) + m.Write([]byte(raw)) + got, err := hex.DecodeString(r.URL.Query().Get("signature")) + if err != nil || !hmac.Equal(got, m.Sum(nil)) { + http.Error(w, "forbidden", 403) + return + } + u, ok := t.store.upload(r.PathValue("id")) + if !ok { + http.NotFound(w, r) + return + } + serveFile(w, r, u.Path, u.MIMEType, false) +} diff --git a/internal/tools/comicanimator/tool_test.go b/internal/tools/comicanimator/tool_test.go new file mode 100644 index 0000000..8e611ae --- /dev/null +++ b/internal/tools/comicanimator/tool_test.go @@ -0,0 +1,62 @@ +package comicanimator + +import ( + "bytes" + "html/template" + "net/url" + "os" + "path/filepath" + "testing" + "time" +) + +func TestSignedURLTampering(t *testing.T) { + dir := t.TempDir() + tool := &Tool{cfg: Config{PublicBaseURL: "https://example.test", SigningSecret: string(make([]byte, 32)), SignedURLTTL: time.Minute}, store: newStore()} + u := Upload{ID: "upl_test", Path: filepath.Join(dir, "x.png")} + os.WriteFile(u.Path, []byte("x"), 0600) + tool.store.putUpload(u) + raw := tool.signedURL(u.ID) + parsed, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + if parsed.Query().Get("signature") == "" || parsed.Query().Get("expires") == "" { + t.Fatal("missing signature fields") + } + if !stringsContains(parsed.Path, u.ID) { + t.Fatal("missing upload id") + } +} +func stringsContains(s, part string) bool { return len(s) >= len(part) && s[len(s)-len(part):] == part } +func TestValidateWebP(t *testing.T) { + good := append([]byte("RIFF\x10\x00\x00\x00WEBPVP8X"), make([]byte, 4)...) + if err := validateWebP(good); err != nil { + t.Fatal(err) + } + if err := validateWebP([]byte("not webp")); err == nil { + t.Fatal("corrupt webp accepted") + } +} + +func TestTemplatesExecute(t *testing.T) { + if err := template.Must(template.New("tool").Parse(toolPage)).Execute(&bytes.Buffer{}, map[string]any{"CSRF": "x", "Duration": 6, "AspectRatio": "16:9"}); err != nil { + t.Fatal(err) + } + if err := template.Must(template.New("instructor").Parse(instructorPage)).Execute(&bytes.Buffer{}, map[string]any{"Outputs": []outputFile{{Name: "safe.mp4", Size: 10, Modified: time.Now()}}}); err != nil { + t.Fatal(err) + } +} + +func TestVideoSignatures(t *testing.T) { + mp4 := append([]byte{0, 0, 0, 16}, []byte("ftypisom")...) + if !validVideoHeader("video/mp4", mp4) { + t.Fatal("valid MP4 rejected") + } + if validVideoHeader("video/mp4", []byte("not a video")) { + t.Fatal("invalid MP4 accepted") + } + if !validVideoHeader("video/webm", []byte{0x1a, 0x45, 0xdf, 0xa3}) { + t.Fatal("valid WebM rejected") + } +} diff --git a/internal/tools/comicanimator/views.go b/internal/tools/comicanimator/views.go new file mode 100644 index 0000000..ab696c4 --- /dev/null +++ b/internal/tools/comicanimator/views.go @@ -0,0 +1,108 @@ +package comicanimator + +import ( + "fmt" + "html/template" + "mime" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func render(w http.ResponseWriter, src string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := template.Must(template.New("page").Parse(src)).Execute(w, data); err != nil { + http.Error(w, "render error", 500) + } +} +func fragmentError(w http.ResponseWriter, status int, message string) { + w.WriteHeader(status) + fmt.Fprintf(w, ``, template.HTMLEscapeString(message)) +} +func (t *Tool) renderCard(w http.ResponseWriter, g Generation, instructor bool) { + poll := "" + if g.Status != Completed && g.Status != Failed && !instructor { + poll = ` hx-get="generations/` + template.HTMLEscapeString(g.ID) + `/status" hx-trigger="every 5s" hx-swap="outerHTML"` + } + fmt.Fprintf(w, `
%s%s
`, poll, template.HTMLEscapeString(strings.ReplaceAll(string(g.Status), "_", " ")), g.CreatedAt.Format(time.RFC3339)) + if instructor { + fmt.Fprintf(w, `
Reviewed prompt

%s

`, template.HTMLEscapeString(g.ReviewedPrompt)) + if g.ProviderJobID != "" { + fmt.Fprintf(w, `Provider job: %s`, template.HTMLEscapeString(g.ProviderJobID)) + } + } + if g.Status == Completed { + prefix := "" + if instructor { + prefix = "generations/" + } + fmt.Fprintf(w, `Download`, prefix, g.ID, prefix, g.ID) + } else if g.Status == Failed { + fmt.Fprintf(w, `

%s

`, template.HTMLEscapeString(g.ErrorMessage)) + } else { + fmt.Fprint(w, `

Working…

`) + } + fmt.Fprint(w, "
") +} +func (t *Tool) instructorPage(w http.ResponseWriter, r *http.Request) { + render(w, instructorPage, map[string]any{"Outputs": t.outputs()}) +} +func (t *Tool) instructorGenerations(w http.ResponseWriter, r *http.Request) { + for _, g := range t.store.list("") { + t.renderCard(w, g, true) + } +} +func (t *Tool) instructorVideo(w http.ResponseWriter, r *http.Request) { + t.generationFile(w, r, true, false) +} +func (t *Tool) instructorDownload(w http.ResponseWriter, r *http.Request) { + t.generationFile(w, r, true, true) +} + +type outputFile struct { + Name string + Size int64 + Modified time.Time +} + +func (t *Tool) outputs() []outputFile { + entries, err := os.ReadDir(t.cfg.OutputDirectory) + if err != nil { + return nil + } + out := []outputFile{} + for _, e := range entries { + if e.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + if ext != ".mp4" && ext != ".webm" && ext != ".mov" { + continue + } + info, err := e.Info() + if err == nil { + out = append(out, outputFile{Name: e.Name(), Size: info.Size(), Modified: info.ModTime()}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Modified.After(out[j].Modified) }) + return out +} +func (t *Tool) outputDownload(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("filename") + if name != filepath.Base(name) { + http.NotFound(w, r) + return + } + path := filepath.Join(t.cfg.OutputDirectory, name) + if _, err := os.Stat(path); err != nil { + http.NotFound(w, r) + return + } + serveFile(w, r, path, mime.TypeByExtension(filepath.Ext(name)), r.URL.Query().Get("preview") != "1") +} + +const toolPage = `

1. Source image

2. Describe motion

Working…

4. Generate

Recent animations

Instructor approval

An instructor PIN is required for this paid generation.

` +const instructorPage = `

Current process generations

Downloaded output files

Files survive restarts; this is not a complete audit log.

{{range .Outputs}}
{{.Name}}{{.Modified.UTC.Format "2006-01-02 15:04:05Z"}} · {{.Size}} bytesDownload
{{else}}

No downloaded videos yet.

{{end}}
` diff --git a/internal/tools/comicanimator/worker.go b/internal/tools/comicanimator/worker.go new file mode 100644 index 0000000..7746ebd --- /dev/null +++ b/internal/tools/comicanimator/worker.go @@ -0,0 +1,166 @@ +package comicanimator + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "git.michelsen.id/phill/preface-tools/internal/tools/comicanimator/openrouter" +) + +func (t *Tool) Run(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return nil + case gid := <-t.queue: + func() { + defer func() { + if v := recover(); v != nil { + t.fail(gid, "internal_error", "Generation failed unexpectedly.", fmt.Errorf("panic: %v", v)) + } + }() + t.work(ctx, gid) + }() + } + } +} +func (t *Tool) work(appctx context.Context, id string) { + g, ok := t.store.generation(id) + if !ok { + return + } + t.store.update(id, func(x *Generation) { x.Status = Submitting }) + ctx, cancel := context.WithTimeout(appctx, t.cfg.JobTimeout) + defer cancel() + job, err := t.client.Submit(ctx, openrouter.VideoRequest{Model: t.cfg.VideoModel, Prompt: g.ReviewedPrompt, Duration: g.Duration, Resolution: g.Resolution, AspectRatio: g.AspectRatio, GenerateAudio: t.cfg.GenerateAudio, FrameImages: []openrouter.FrameImage{{Type: "image_url", ImageURL: openrouter.NewImageURL(t.signedURL(g.UploadID)), FrameType: "first_frame"}}}) + if err != nil { + t.fail(id, "provider_submission_failed", "The video provider could not start this generation.", err) + return + } + if job.ID == "" { + t.fail(id, "provider_submission_failed", "The video provider returned an invalid response.", fmt.Errorf("missing job id")) + return + } + t.store.update(id, func(x *Generation) { x.ProviderJobID = job.ID; x.Status = Pending }) + for job.Status != "completed" { + if job.Status == "failed" || job.Status == "cancelled" || job.Status == "expired" { + t.fail(id, "provider_generation_failed", "The video provider could not complete this generation.", fmt.Errorf("provider status %s", job.Status)) + return + } + timer := time.NewTimer(t.cfg.PollInterval) + select { + case <-ctx.Done(): + timer.Stop() + code := "provider_timeout" + msg := "The video generation timed out." + if appctx.Err() != nil { + code = "worker_shutdown" + msg = "Generation stopped because the application shut down." + } + t.fail(id, code, msg, ctx.Err()) + return + case <-timer.C: + } + job, err = t.client.Poll(ctx, job.ID) + if err != nil { + t.fail(id, "provider_poll_failed", "Could not check the video status.", err) + return + } + t.store.update(id, func(x *Generation) { + if job.Status == "in_progress" { + x.Status = InProgress + } else { + x.Status = Pending + } + }) + } + t.store.update(id, func(x *Generation) { x.Status = Downloading }) + resp, err := t.client.Content(ctx, job.ID) + if err != nil { + t.fail(id, "provider_download_failed", "Could not download the completed video.", err) + return + } + defer resp.Body.Close() + mt := strings.Split(resp.Header.Get("Content-Type"), ";")[0] + if mt != "video/mp4" && mt != "video/webm" && mt != "video/quicktime" { + t.fail(id, "generated_video_invalid", "The provider returned an invalid video.", fmt.Errorf("content type %q", mt)) + return + } + ext := map[string]string{"video/mp4": ".mp4", "video/webm": ".webm", "video/quicktime": ".mov"}[mt] + header := make([]byte, 16) + hn, readErr := io.ReadFull(resp.Body, header) + if readErr != nil && readErr != io.ErrUnexpectedEOF { + t.fail(id, "provider_download_failed", "Could not download the completed video.", readErr) + return + } + if !validVideoHeader(mt, header[:hn]) { + t.fail(id, "generated_video_invalid", "The provider returned an invalid video.", fmt.Errorf("invalid %s signature", mt)) + return + } + tmp, err := os.CreateTemp(t.cfg.OutputDirectory, ".video-*") + if err != nil { + t.fail(id, "output_write_failed", "Could not save the video.", err) + return + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + n, copyErr := io.Copy(tmp, io.LimitReader(io.MultiReader(bytes.NewReader(header[:hn]), resp.Body), t.cfg.MaxGeneratedVideoBytes+1)) + syncErr := tmp.Sync() + closeErr := tmp.Close() + if copyErr != nil || syncErr != nil || closeErr != nil { + t.fail(id, "output_write_failed", "Could not save the video.", errorsJoin(copyErr, syncErr, closeErr)) + return + } + if n > t.cfg.MaxGeneratedVideoBytes { + t.fail(id, "generated_video_too_large", "The generated video was too large.", fmt.Errorf("%d bytes", n)) + return + } + now := time.Now().UTC() + name := now.Format("20060102T150405Z") + "_" + id + ext + dest := filepath.Join(t.cfg.OutputDirectory, name) + if err = os.Rename(tmpName, dest); err != nil { + t.fail(id, "output_write_failed", "Could not finalize the video.", err) + return + } + t.store.update(id, func(x *Generation) { + x.Status = Completed + x.OutputPath = dest + x.OutputMIMEType = mt + x.OutputSize = n + x.CompletedAt = &now + }) + t.log.Info("video generation completed", "tool", "comic-animator", "generation_id", id, "provider_job_id", job.ID, "file_size", n) +} + +func validVideoHeader(mt string, head []byte) bool { + switch mt { + case "video/mp4", "video/quicktime": + return len(head) >= 12 && string(head[4:8]) == "ftyp" + case "video/webm": + return len(head) >= 4 && head[0] == 0x1a && head[1] == 0x45 && head[2] == 0xdf && head[3] == 0xa3 + default: + return false + } +} +func errorsJoin(errs ...error) error { + parts := []string{} + for _, e := range errs { + if e != nil { + parts = append(parts, e.Error()) + } + } + if len(parts) == 0 { + return nil + } + return fmt.Errorf("%s", strings.Join(parts, "; ")) +} +func (t *Tool) fail(id, code, message string, err error) { + t.store.update(id, func(x *Generation) { x.Status = Failed; x.ErrorCode = code; x.ErrorMessage = message }) + t.log.Error("video generation failed", "tool", "comic-animator", "generation_id", id, "error_code", code, "error", err) +} diff --git a/internal/tools/tool.go b/internal/tools/tool.go new file mode 100644 index 0000000..6fb409d --- /dev/null +++ b/internal/tools/tool.go @@ -0,0 +1,57 @@ +package tools + +import ( + "context" + "errors" + "net/http" + "sort" + "sync" +) + +type Definition struct{ Key, Name, Description string } +type Tool interface { + Definition() Definition + StudentHandler() http.Handler +} +type InstructorTool interface{ InstructorHandler() http.Handler } +type Runner interface{ Run(context.Context) error } + +var ErrNotFound = errors.New("tool not found") + +type Registry struct { + mu sync.RWMutex + tools map[string]Tool +} + +func NewRegistry() *Registry { return &Registry{tools: make(map[string]Tool)} } +func (r *Registry) Register(t Tool) error { + if t == nil || t.Definition().Key == "" { + return errors.New("tool key is required") + } + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.tools[t.Definition().Key]; ok { + return errors.New("duplicate tool key") + } + r.tools[t.Definition().Key] = t + return nil +} +func (r *Registry) Get(key string) (Tool, error) { + r.mu.RLock() + defer r.mu.RUnlock() + t, ok := r.tools[key] + if !ok { + return nil, ErrNotFound + } + return t, nil +} +func (r *Registry) List() []Tool { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]Tool, 0, len(r.tools)) + for _, t := range r.tools { + out = append(out, t) + } + sort.Slice(out, func(i, j int) bool { return out[i].Definition().Name < out[j].Definition().Name }) + return out +} diff --git a/internal/tools/tool_test.go b/internal/tools/tool_test.go new file mode 100644 index 0000000..6b7bc59 --- /dev/null +++ b/internal/tools/tool_test.go @@ -0,0 +1,32 @@ +package tools + +import ( + "net/http" + "testing" +) + +type fakeTool struct{ key, name string } + +func (f fakeTool) Definition() Definition { return Definition{Key: f.key, Name: f.name} } +func (fakeTool) StudentHandler() http.Handler { return http.NotFoundHandler() } +func TestRegistry(t *testing.T) { + r := NewRegistry() + if err := r.Register(fakeTool{}); err == nil { + t.Fatal("empty key accepted") + } + if err := r.Register(fakeTool{key: "b", name: "Beta"}); err != nil { + t.Fatal(err) + } + if err := r.Register(fakeTool{key: "a", name: "Alpha"}); err != nil { + t.Fatal(err) + } + if err := r.Register(fakeTool{key: "a", name: "Again"}); err == nil { + t.Fatal("duplicate accepted") + } + if got := r.List(); len(got) != 2 || got[0].Definition().Key != "a" { + t.Fatalf("unstable list: %#v", got) + } + if _, err := r.Get("missing"); err != ErrNotFound { + t.Fatalf("got %v", err) + } +}