PREFACE TOOLS
{{with .Error}}{{.}}
{{end}}diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..af4a70a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.env +data +preface-tools +README.md diff --git a/.env.example b/.env.example index ae246ae..96d767c 100644 --- a/.env.example +++ b/.env.example @@ -11,12 +11,12 @@ 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_OPENROUTER_APP_NAME="Preface Tools - Comic Animator" +COMIC_ANIMATOR_PROMPT_MODEL=openai/gpt-5.6-luna +COMIC_ANIMATOR_PROMPT_FILE=prompts/comic-animator-system.txt +COMIC_ANIMATOR_VIDEO_MODEL=alibaba/happyhorse-1.1 +COMIC_ANIMATOR_VIDEO_DURATION=4 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 @@ -28,3 +28,9 @@ 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 + +HTTP_READ_HEADER_TIMEOUT=5s +HTTP_READ_TIMEOUT=30s +HTTP_WRITE_TIMEOUT=10m +HTTP_IDLE_TIMEOUT=60s +HTTP_SHUTDOWN_TIMEOUT=10s diff --git a/Dockerfile b/Dockerfile index 97831a7..72c656e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,17 @@ FROM golang:1.26 AS build WORKDIR /src -COPY go.mod go.sum* ./ +COPY go.mod ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /preface-tools ./cmd/preface-tools +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /healthcheck ./cmd/healthcheck FROM gcr.io/distroless/static-debian12:nonroot WORKDIR /app COPY --from=build /preface-tools /usr/local/bin/preface-tools +COPY --from=build /healthcheck /usr/local/bin/healthcheck +COPY --from=build /src/prompts /app/prompts VOLUME ["/app/data"] EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD ["/usr/local/bin/healthcheck", "http://127.0.0.1:8080/readyz"] ENTRYPOINT ["/usr/local/bin/preface-tools"] diff --git a/README.md b/README.md index d8e36e1..3c4b5f8 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,263 @@ # 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. +Preface Tools is a small, database-free internal classroom application. Its +Comic Animator workflow lets a student: -## Run locally +1. upload a complete comic page; +2. describe the movement they want; +3. generate and edit an image-to-video prompt with an OpenRouter LLM; and +4. ask an instructor to approve the paid video-generation request with a PIN. -Requires Go 1.26 or later. Copy `.env.example` to `.env`, replace every secret -and model placeholder, export the variables, then run: +The application has separate student and instructor sessions, CSRF protection, +rate-limited PIN checks, signed provider-facing image URLs, bounded uploads and +downloads, and an instructor recovery view for completed files. + +## Requirements + +- Docker Engine with Docker Compose v2 for the recommended deployment, or Go + 1.26 or later for a local source build. +- An OpenRouter API key with access and sufficient credit for both configured + models. +- A public HTTPS URL that OpenRouter can reach. OpenRouter fetches the uploaded + comic through a short-lived signed URL when starting image-to-video jobs. +- A TLS-terminating reverse proxy for production. Caddy, Traefik, nginx, or an + existing internal ingress is sufficient. + +## Quick start for local development + +Copy the environment template and replace all secret placeholders: ```sh +cp .env.example .env 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. +Open `http://localhost:8080`. A local-only `PUBLIC_BASE_URL` is enough to view +the interface, but video generation cannot work until that value is an HTTPS +address reachable by OpenRouter. A temporary HTTPS tunnel is suitable for +development. -## Storage and restart behavior +The executable loads `.env` automatically and does not overwrite variables +already supplied by the process environment. -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. +## Configuration -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. +The supplied model and video defaults are: + +```env +COMIC_ANIMATOR_PROMPT_MODEL=openai/gpt-5.6-luna +COMIC_ANIMATOR_VIDEO_MODEL=alibaba/happyhorse-1.1 +COMIC_ANIMATOR_VIDEO_DURATION=4 +COMIC_ANIMATOR_VIDEO_RESOLUTION=720p +COMIC_ANIMATOR_GENERATE_AUDIO=false +``` + +`720p` is the recommended balance of cost, generation time, and classroom +quality. [OpenRouter's video API](https://openrouter.ai/docs/guides/overview/multimodal/video-generation) +also defines `480p`, `1080p`, `1K`, `2K`, and `4K`, but each model supports only +a subset. Confirm the current capabilities through the +[video-models endpoint](https://openrouter.ai/docs/api/api-reference/video-generation/list-videos-models) +before changing resolution or duration. HappyHorse 1.1 advertises output up to +1080p; unsupported combinations will be rejected by the provider. + +Important application variables: + +| Variable | Purpose | Example/default | +| --- | --- | --- | +| `APP_ENV` | Enables secure production cookies when set to `production` | `development` | +| `HTTP_ADDRESS` | Server listen address | `:8080` | +| `PUBLIC_BASE_URL` | Public HTTPS origin reachable by OpenRouter | required | +| `STUDENT_PIN` | Shared classroom login PIN | required | +| `INSTRUCTOR_PIN` | Instructor login and paid-action approval PIN | required | +| `SESSION_SIGNING_SECRET` | Signs browser sessions; at least 32 characters | required | +| `SESSION_DURATION` | Browser session lifetime | `4h` | +| `LOG_LEVEL` | `debug`, `info`, `warn`, or `error` | `info` | +| `LOG_FORMAT` | `text` or `json` | `text` | +| `COMIC_ANIMATOR_OPENROUTER_API_KEY` | OpenRouter bearer token | required | +| `COMIC_ANIMATOR_PROMPT_MODEL` | Multimodal model that writes the video prompt | `openai/gpt-5.6-luna` | +| `COMIC_ANIMATOR_VIDEO_MODEL` | Image-to-video model | `alibaba/happyhorse-1.1` | +| `COMIC_ANIMATOR_PROMPT_FILE` | Reloadable LLM system-prompt path | `prompts/comic-animator-system.txt` | +| `COMIC_ANIMATOR_VIDEO_DURATION` | Requested video length in seconds | `4` | +| `COMIC_ANIMATOR_VIDEO_RESOLUTION` | Provider-supported resolution | `720p` | +| `COMIC_ANIMATOR_GENERATE_AUDIO` | Requests provider audio when supported | `false` | +| `COMIC_ANIMATOR_UPLOAD_DIR` | Temporary uploaded comic storage | `data/comic-animator/uploads` | +| `COMIC_ANIMATOR_OUTPUT_DIR` | Completed video storage | `data/comic-animator/outputs` | +| `COMIC_ANIMATOR_MAX_UPLOAD_BYTES` | Maximum source-image size | `20971520` (20 MiB) | +| `COMIC_ANIMATOR_MAX_VIDEO_BYTES` | Maximum downloaded video size | `536870912` (512 MiB) | +| `COMIC_ANIMATOR_QUEUE_CAPACITY` | In-memory generation queue capacity | `100` | +| `COMIC_ANIMATOR_POLL_INTERVAL` | Provider status polling frequency | `30s` | +| `COMIC_ANIMATOR_JOB_TIMEOUT` | Whole video-job deadline | `15m` | +| `COMIC_ANIMATOR_HTTP_TIMEOUT` | Individual OpenRouter request deadline | `60s` | +| `COMIC_ANIMATOR_SIGNING_SECRET` | Signs temporary image URLs; at least 32 characters | required | +| `COMIC_ANIMATOR_SIGNED_URL_TTL` | Provider image URL lifetime | `30m` | + +Optional `COMIC_ANIMATOR_OPENROUTER_SITE_URL` and +`COMIC_ANIMATOR_OPENROUTER_APP_NAME` values populate OpenRouter attribution +headers. The complete template, including HTTP timeout settings, is in +`.env.example`. + +Generate independent secrets rather than copying the placeholders: + +```sh +openssl rand -hex 32 +openssl rand -hex 32 +``` + +Use the two results for `SESSION_SIGNING_SECRET` and +`COMIC_ANIMATOR_SIGNING_SECRET`. Choose non-trivial, different student and +instructor PINs. The `.env` file is ignored by Git; keep it readable only by the +deployment account. + +## Editing the LLM system prompt + +The LLM system message lives in +`prompts/comic-animator-system.txt`. The server reads it for every Generate +Prompt request, so saving the file changes the next request without restarting +the application. The student's movement description and uploaded image remain +a separate user message. + +The generated `video_prompt` string is displayed in the third panel and can be +edited before it is sent to the video model. Missing, empty, or oversized system +prompt files fail safely; the default file is also checked during startup. + +With Compose, the local `prompts` directory is mounted read-only inside the +container. Edit the host file normally; the updated content is visible to the +running process immediately. + +## Production deployment with Docker Compose + +1. Copy and secure the environment file: + + ```sh + cp .env.example .env + chmod 600 .env + ``` + +2. Set at least the API key, PINs, signing secrets, and public URL. The public + URL must be the final HTTPS origin, without a path, for example: + + ```env + APP_ENV=production + PUBLIC_BASE_URL=https://preface-tools.internal.example.com + ``` + +3. Build and start the service: + + ```sh + docker compose up -d --build + docker compose ps + docker compose logs -f preface-tools + ``` + +4. Put a TLS reverse proxy in front of `127.0.0.1:8080`. The Compose file binds + only to loopback deliberately. If TLS is terminated by an ingress on another + host, adjust the `ports` mapping or use an external Docker network while + keeping the application container otherwise private. + +The container runs as a non-root user with all Linux capabilities dropped, a +read-only root filesystem, `no-new-privileges`, and a named volume for runtime +data. It exposes: + +- `GET /healthz` for liveness; +- `GET /readyz` for readiness. + +Docker checks `/readyz` every 30 seconds. To inspect it manually: + +```sh +curl -fsS http://127.0.0.1:8080/readyz +``` + +### Reverse-proxy notes + +- Forward the original `Host` header and use HTTPS externally. +- Set `Strict-Transport-Security` at the TLS reverse proxy after confirming the + hostname is HTTPS-only. +- Do not expose port 8080 directly to an untrusted network. +- Allow normal video response sizes and request durations; completed downloads + may take several minutes on slow links. +- `PUBLIC_BASE_URL` must resolve publicly from OpenRouter even if the login UI + itself is restricted by VPN, identity-aware proxy, or network policy. The + provider-media route is protected by a short-lived signature and exposes only + the requested uploaded image. +- The browser currently loads DaisyUI/HTMX from jsDelivr and the Preface logo + from `preface.ai`, so client networks must allow those hosts. + +## Usage + +### Student workflow + +1. Sign in with the student PIN. +2. Upload one PNG, JPEG, or WebP comic page, up to 20 MiB by default. +3. Describe panel movement and click **Generate Prompt**. The button is disabled + while the request is running. +4. Review or edit the generated video prompt. +5. Click **Generate Video** and ask an instructor to enter their PIN. +6. Follow progress in Recent Animations, then play or download the completed + video. + +Recent student generations belong to that browser session. Logging out and back +in creates a new student session and therefore a new recent-generation view. + +### Instructor workflow + +Sign in with the instructor PIN to see generations retained by the current +process and completed output files found on disk. The disk-backed output view is +useful after restarts, but it is not a complete audit log. + +## Storage, backups, and restarts + +Uploads and generation metadata are held in memory. A restart loses queued and +in-progress jobs, student recent-generation associations, and detailed prompt +metadata. Completed video files are stored in the configured output directory +and survive Compose restarts in the `preface-data` volume. + +OpenRouter does not provide this application with a complete restart recovery +mechanism for its asynchronous job state. Avoid deploying multiple replicas: +sessions can reach any replica, while uploads, queues, and job registries are +process-local. A single worker processes video generations sequentially; later +approved requests remain in the bounded in-memory queue. + +Uploads and outputs are not automatically deleted. Monitor volume usage and +establish an internal retention process. Back up or export the named volume if +completed videos must be retained: + +```sh +docker compose stop +docker run --rm -v preface-tools_preface-data:/data -v "$PWD":/backup \ + alpine tar czf /backup/preface-data.tgz -C /data . +docker compose start +``` + +Adjust the generated volume name if the Compose project name differs. + +## Updating and rollback + +Build before replacing the running container, then inspect health and logs: + +```sh +docker compose build --pull +docker compose up -d +docker compose ps +docker compose logs --tail=100 preface-tools +``` + +For repeatable production rollbacks, tag images in a registry and replace +`preface-tools:local` in `compose.yml` with an immutable version tag rather than +building directly on the server. ## Verification +Run the local checks before deployment: + ```sh gofmt -w . go test ./... go test -race ./... go vet ./... go build ./... +docker compose config +docker build -t preface-tools:test . ``` -No database, Node.js, npm, frontend build, or live OpenRouter call is required -by the automated test suite. +The automated test suite does not make live OpenRouter calls and does not spend +API credit. diff --git a/cmd/healthcheck/main.go b/cmd/healthcheck/main.go new file mode 100644 index 0000000..4791350 --- /dev/null +++ b/cmd/healthcheck/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "time" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: healthcheck URL") + os.Exit(2) + } + client := &http.Client{Timeout: 2 * time.Second} + response, err := client.Get(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + fmt.Fprintln(os.Stderr, response.Status) + os.Exit(1) + } +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..c064be5 --- /dev/null +++ b/compose.yml @@ -0,0 +1,36 @@ +services: + preface-tools: + build: + context: . + image: preface-tools:local + restart: unless-stopped + init: true + env_file: + - .env + environment: + APP_ENV: production + HTTP_ADDRESS: :8080 + COMIC_ANIMATOR_PROMPT_FILE: /app/prompts/comic-animator-system.txt + COMIC_ANIMATOR_UPLOAD_DIR: /app/data/comic-animator/uploads + COMIC_ANIMATOR_OUTPUT_DIR: /app/data/comic-animator/outputs + ports: + - "127.0.0.1:8080:8080" + volumes: + - preface-data:/app/data + - ./prompts:/app/prompts:ro + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "/usr/local/bin/healthcheck", "http://127.0.0.1:8080/readyz"] + interval: 30s + timeout: 3s + start_period: 10s + retries: 3 + +volumes: + preface-data: diff --git a/internal/app/app.go b/internal/app/app.go index 3f6a2fb..b9590a6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,6 +3,7 @@ package app import ( "context" "errors" + "fmt" "log/slog" "net/http" "os" @@ -24,9 +25,14 @@ func Run() error { if err != nil { return err } - var handler slog.Handler = slog.NewTextHandler(os.Stdout, nil) + var level slog.Level + if err := level.UnmarshalText([]byte(cfg.LogLevel)); err != nil { + return fmt.Errorf("invalid LOG_LEVEL %q: %w", cfg.LogLevel, err) + } + handlerOptions := &slog.HandlerOptions{Level: level} + var handler slog.Handler = slog.NewTextHandler(os.Stdout, handlerOptions) if cfg.LogFormat == "json" { - handler = slog.NewJSONHandler(os.Stdout, nil) + handler = slog.NewJSONHandler(os.Stdout, handlerOptions) } log := slog.New(handler) a := auth.New(cfg.StudentPIN, cfg.InstructorPIN, cfg.SessionSecret, cfg.SessionDuration, cfg.Environment == "production") @@ -38,7 +44,7 @@ func Run() error { 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} + 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, MaxHeaderBytes: 64 << 10} ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() workerDone := make(chan error, 1) diff --git a/internal/app/config.go b/internal/app/config.go index 2a1d710..406863f 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -2,6 +2,7 @@ package app import ( "errors" + "net/url" "os" "time" ) @@ -21,7 +22,7 @@ func LoadConfigFromEnv() (Config, error) { 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"}} + }{{&c.ReadHeaderTimeout, "HTTP_READ_HEADER_TIMEOUT", "5s"}, {&c.ReadTimeout, "HTTP_READ_TIMEOUT", "30s"}, {&c.WriteTimeout, "HTTP_WRITE_TIMEOUT", "10m"}, {&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 { @@ -31,6 +32,16 @@ func LoadConfigFromEnv() (Config, error) { 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") } + if c.LogFormat != "text" && c.LogFormat != "json" { + return c, errors.New("LOG_FORMAT must be text or json") + } + publicURL, err := url.Parse(c.PublicBaseURL) + if err != nil || publicURL.Host == "" || (publicURL.Scheme != "http" && publicURL.Scheme != "https") || (publicURL.Path != "" && publicURL.Path != "/") || publicURL.RawQuery != "" || publicURL.Fragment != "" || publicURL.User != nil { + return c, errors.New("PUBLIC_BASE_URL must be an HTTP(S) origin without a path, query, credentials, or fragment") + } + if c.Environment == "production" && publicURL.Scheme != "https" { + return c, errors.New("PUBLIC_BASE_URL must use HTTPS in production") + } return c, nil } func get(k, d string) string { diff --git a/internal/app/config_test.go b/internal/app/config_test.go new file mode 100644 index 0000000..fe86fd9 --- /dev/null +++ b/internal/app/config_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "testing" + "time" +) + +func setRequiredConfig(t *testing.T) { + t.Helper() + t.Setenv("STUDENT_PIN", "student") + t.Setenv("INSTRUCTOR_PIN", "instructor") + t.Setenv("SESSION_SIGNING_SECRET", "12345678901234567890123456789012") + t.Setenv("LOG_LEVEL", "info") + t.Setenv("LOG_FORMAT", "text") +} + +func TestProductionConfigRequiresHTTPSPublicOrigin(t *testing.T) { + setRequiredConfig(t) + t.Setenv("APP_ENV", "production") + t.Setenv("PUBLIC_BASE_URL", "http://preface-tools.example.test") + if _, err := LoadConfigFromEnv(); err == nil { + t.Fatal("production HTTP public URL accepted") + } + t.Setenv("PUBLIC_BASE_URL", "https://preface-tools.example.test") + cfg, err := LoadConfigFromEnv() + if err != nil { + t.Fatal(err) + } + if cfg.WriteTimeout != 10*time.Minute { + t.Fatalf("write timeout = %s", cfg.WriteTimeout) + } +} + +func TestConfigRejectsPublicURLWithPath(t *testing.T) { + setRequiredConfig(t) + t.Setenv("APP_ENV", "development") + t.Setenv("PUBLIC_BASE_URL", "https://preface-tools.example.test/subpath") + if _, err := LoadConfigFromEnv(); err == nil { + t.Fatal("public URL with path accepted") + } +} diff --git a/internal/app/dotenv.go b/internal/app/dotenv.go new file mode 100644 index 0000000..bc56454 --- /dev/null +++ b/internal/app/dotenv.go @@ -0,0 +1,65 @@ +package app + +import ( + "bufio" + "errors" + "fmt" + "os" + "strings" +) + +// LoadDotEnv loads simple KEY=VALUE entries without replacing variables that +// are already present in the process environment. A missing file is allowed. +func LoadDotEnv(path string) error { + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("open dotenv file: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for lineNumber := 1; scanner.Scan(); lineNumber++ { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + key, value, found := strings.Cut(line, "=") + key = strings.TrimSpace(key) + if !found || !validEnvironmentKey(key) { + return fmt.Errorf("%s:%d: invalid environment assignment", path, lineNumber) + } + value = strings.TrimSpace(value) + if len(value) >= 2 && ((value[0] == '\'' && value[len(value)-1] == '\'') || (value[0] == '"' && value[len(value)-1] == '"')) { + value = value[1 : len(value)-1] + } + if _, exists := os.LookupEnv(key); !exists { + if err := os.Setenv(key, value); err != nil { + return fmt.Errorf("%s:%d: set environment: %w", path, lineNumber, err) + } + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("read dotenv file: %w", err) + } + return nil +} + +func validEnvironmentKey(key string) bool { + if key == "" || !isEnvironmentKeyStart(key[0]) { + return false + } + for i := 1; i < len(key); i++ { + if !isEnvironmentKeyStart(key[i]) && (key[i] < '0' || key[i] > '9') { + return false + } + } + return true +} + +func isEnvironmentKeyStart(char byte) bool { + return char == '_' || char >= 'A' && char <= 'Z' || char >= 'a' && char <= 'z' +} diff --git a/internal/app/dotenv_test.go b/internal/app/dotenv_test.go new file mode 100644 index 0000000..9b850e3 --- /dev/null +++ b/internal/app/dotenv_test.go @@ -0,0 +1,34 @@ +package app + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadDotEnv(t *testing.T) { + path := filepath.Join(t.TempDir(), ".env") + contents := "# comment\nDOTENV_TEST_ONE=value\nDOTENV_TEST_TWO=Preface Tools - Comic Animator\nDOTENV_TEST_THREE=\"quoted value\"\n" + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("DOTENV_TEST_ONE", "existing") + if err := LoadDotEnv(path); err != nil { + t.Fatal(err) + } + if got := os.Getenv("DOTENV_TEST_ONE"); got != "existing" { + t.Fatalf("existing environment overwritten: %q", got) + } + if got := os.Getenv("DOTENV_TEST_TWO"); got != "Preface Tools - Comic Animator" { + t.Fatalf("unquoted value: %q", got) + } + if got := os.Getenv("DOTENV_TEST_THREE"); got != "quoted value" { + t.Fatalf("quoted value: %q", got) + } +} + +func TestLoadDotEnvMissingFile(t *testing.T) { + if err := LoadDotEnv(filepath.Join(t.TempDir(), "missing")); err != nil { + t.Fatal(err) + } +} diff --git a/internal/httpserver/server.go b/internal/httpserver/server.go index aa48bcc..40a59a2 100644 --- a/internal/httpserver/server.go +++ b/internal/httpserver/server.go @@ -32,24 +32,28 @@ func New(a *auth.Service, r *tools.Registry, log *slog.Logger) *Server { 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) + mux.HandleFunc("/", 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)))) + mux.Handle("GET "+studentPrefix+"/provider-media/", suffix) + mux.Handle(studentPrefix+"/", s.withAuth(auth.Student, s.csrf(s.shell(tool, suffix, false, studentPrefix+"/")))) 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(prefix+"/", s.withAuth(auth.Instructor, s.csrf(s.shell(tool, http.StripPrefix(prefix, it.InstructorHandler()), true, prefix+"/")))) } } - mux.Handle("GET /instructor", s.withAuth(auth.Instructor, http.HandlerFunc(s.instructorRoot))) + mux.Handle("/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) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } c, err := s.auth.FromRequest(r) if err != nil { http.Redirect(w, r, "/login", http.StatusSeeOther) @@ -91,6 +95,10 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login", 303) } func (s *Server) instructorRoot(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } list := s.registry.List() for _, t := range list { if _, ok := t.(tools.InstructorTool); ok { @@ -102,6 +110,7 @@ func (s *Server) instructorRoot(w http.ResponseWriter, r *http.Request) { } func (s *Server) withAuth(role auth.Role, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") c, err := s.auth.FromRequest(r) if err != nil { http.Redirect(w, r, "/login", 303) @@ -136,9 +145,9 @@ func (s *Server) csrf(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } -func (s *Server) shell(tool tools.Tool, next http.Handler, instructor bool) http.Handler { +func (s *Server) shell(tool tools.Tool, next http.Handler, instructor bool, rootPath string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { + if r.URL.Path != rootPath { next.ServeHTTP(w, r) return } @@ -187,7 +196,7 @@ func (s *Server) middleware(next http.Handler) http.Handler { 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'") + 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: https://www.preface.ai; 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) @@ -208,19 +217,31 @@ func clientIP(r *http.Request) string { 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.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) template.Must(template.New("page").Parse(src)).Execute(w, data) } -const head = `
{{.}}
{{end}}