Codex first iteration

This commit is contained in:
2026-07-12 02:56:51 +08:00
parent 16c626fbd7
commit 7cb409f5a6
20 changed files with 1771 additions and 0 deletions
+30
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
.env
data/comic-animator/uploads/*
data/comic-animator/outputs/*
preface-tools
+13
View File
@@ -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"]
+46
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
module git.michelsen.id/phill/preface-tools
go 1.26.5
+63
View File
@@ -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
}
+42
View File
@@ -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)) }
+174
View File
@@ -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) }
+37
View File
@@ -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")
}
}
+226
View File
@@ -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 = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="htmx-config" content='{"responseHandling":[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"[45]..","swap":true,"error":true},{"code":"...","swap":false,"error":true}]}'><title>{{if .Title}}{{.Title}} · {{end}}Preface Tools</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/basecoat-css@1.0.2/dist/basecoat.cdn.min.css"><link rel="stylesheet" href="/static/app.css"><script src="https://cdn.jsdelivr.net/npm/basecoat-css@1.0.2/dist/js/all.min.js" defer></script><script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" defer></script><script src="/static/app.js" defer></script></head><body>`
const loginTemplate = head + `<main class="login"><h1>PREFACE TOOLS</h1>{{with .Error}}<p class="error">{{.}}</p>{{end}}<div class="login-grid"><form class="card" method="post" action="/login/student"><h2>Student Login</h2><p>Access classroom tools</p><input type="password" name="pin" required autocomplete="current-password" aria-label="Student PIN"><button class="btn primary">Log in</button></form><form class="card" method="post" action="/login/instructor"><h2>Instructor Login</h2><p>Review generated outputs</p><input type="password" name="pin" required autocomplete="current-password" aria-label="Instructor PIN"><button class="btn">Log in</button></form></div></main></body></html>`
const shellTemplate = head + `<header><a href="/">PREFACE TOOLS</a><nav>{{if .Instructor}}<span>Instructor</span>{{else}}<select id="tool-selector" aria-label="Choose tool">{{range .Tools}}<option value="/tools/{{.Definition.Key}}/" {{if eq .Definition.Key $.Selected}}selected{{end}}>{{.Definition.Name}}</option>{{end}}</select>{{end}}<form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button>Log out</button></form></nav></header><main>{{.Content}}</main></body></html>`
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()});`)
}
+84
View File
@@ -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)
}
+88
View File
@@ -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")
@@ -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)
}
@@ -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()
}
+311
View File
@@ -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, `<div class="preview"><img src="uploads/%s/preview" alt="Uploaded comic preview"><input type="hidden" name="upload_id" value="%s"><p>%s</p></div>`, 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, `<label for="reviewed_prompt">Editable video prompt</label><textarea id="reviewed_prompt" name="reviewed_prompt" rows="10" maxlength="12000" required>%s</textarea><small>%d characters</small>`, 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)
}
+62
View File
@@ -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")
}
}
+108
View File
@@ -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, `<p class="error" role="alert">%s</p>`, 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, `<article class="generation"%s><div><strong>%s</strong><small>%s</small></div>`, poll, template.HTMLEscapeString(strings.ReplaceAll(string(g.Status), "_", " ")), g.CreatedAt.Format(time.RFC3339))
if instructor {
fmt.Fprintf(w, `<details><summary>Reviewed prompt</summary><p>%s</p></details>`, template.HTMLEscapeString(g.ReviewedPrompt))
if g.ProviderJobID != "" {
fmt.Fprintf(w, `<small>Provider job: %s</small>`, template.HTMLEscapeString(g.ProviderJobID))
}
}
if g.Status == Completed {
prefix := ""
if instructor {
prefix = "generations/"
}
fmt.Fprintf(w, `<video controls preload="metadata" src="%s%s/video"></video><a class="btn" href="%s%s/download">Download</a>`, prefix, g.ID, prefix, g.ID)
} else if g.Status == Failed {
fmt.Fprintf(w, `<p class="error">%s</p>`, template.HTMLEscapeString(g.ErrorMessage))
} else {
fmt.Fprint(w, `<p class="muted">Working…</p>`)
}
fmt.Fprint(w, "</article>")
}
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 = `<section class="tool-grid"><article class="card"><h2>1. Source image</h2><form id="upload-form" hx-post="uploads" hx-target="#upload-preview" hx-encoding="multipart/form-data"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="file" name="image" accept="image/png,image/jpeg,image/webp" required><button class="btn" type="submit">Upload image</button></form><div id="upload-preview"></div></article><article class="card"><h2>2. Describe motion</h2><form id="comic-form"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><label for="description">Describe the story and movement</label><textarea id="description" name="description" rows="12" required placeholder="Explain the panels in order, character actions, expressions, environmental movement, timing, and anything that must stay unchanged."></textarea><button class="btn" type="button" hx-post="prompt" hx-include="#comic-form,#upload-preview" hx-target="#prompt-result" hx-indicator="#prompt-spinner">Generate Video Prompt</button><span id="prompt-spinner" class="htmx-indicator">Working…</span><div id="prompt-result"></div></form></article><article class="card"><h2>4. Generate</h2><label>Duration</label><input form="comic-form" name="duration" value="{{.Duration}}" readonly><label>Aspect ratio</label><select form="comic-form" name="aspect_ratio"><option>{{.AspectRatio}}</option></select><button class="btn primary" type="button" data-open-approval>Generate Video</button></article></section><section class="card recent"><h2>Recent animations</h2><div id="generation-list" hx-get="generations" hx-trigger="load"></div></section><dialog id="approval-dialog" class="dialog"><form method="dialog"><button aria-label="Close">×</button></form><h2>Instructor approval</h2><p>An instructor PIN is required for this paid generation.</p><form id="generation-form" hx-post="generations" hx-include="#comic-form,#upload-preview" hx-target="#generation-list" hx-swap="afterbegin"><input type="password" name="instructor_pin" autocomplete="off" required><div id="approval-error"></div><button type="button" data-close-approval>Cancel</button><button class="btn primary" type="submit">Approve and Generate</button></form></dialog>`
const instructorPage = `<section class="card"><h2>Current process generations</h2><div hx-get="generations" hx-trigger="load, every 10s"></div></section><section class="card recent"><h2>Downloaded output files</h2><p class="muted">Files survive restarts; this is not a complete audit log.</p>{{range .Outputs}}<article class="generation"><strong>{{.Name}}</strong><small>{{.Modified.UTC.Format "2006-01-02 15:04:05Z"}} · {{.Size}} bytes</small><video controls preload="metadata" src="outputs/{{.Name}}/download?preview=1"></video><a class="btn" href="outputs/{{.Name}}/download">Download</a></article>{{else}}<p class="muted">No downloaded videos yet.</p>{{end}}</section>`
+166
View File
@@ -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)
}
+57
View File
@@ -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
}
+32
View File
@@ -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)
}
}