diff --git a/lib/dream/README.md b/lib/dream/README.md new file mode 100644 index 00000000..d7c533b5 --- /dev/null +++ b/lib/dream/README.md @@ -0,0 +1,79 @@ +# dream-on-sx + +OCaml's [Dream](https://aantron.github.io/dream/) web framework, reimplemented in +**plain SX** on the CEK evaluator. Dream is the cleanest middleware-shaped HTTP +framework in any language, and it maps onto SX with almost no impedance: + +| Dream | SX | +|-------|-----| +| `handler = request -> response promise` | `(fn (req) … (perform …))` | +| `middleware = handler -> handler` | `(fn (next) (fn (req) …))` | +| `m1 @@ m2 @@ handler` | `(m1 (m2 handler))` — left fold | +| `Dream.run handler` | `(dream-run handler)` → `(perform (:http/listen …))` | + +There are five types — **request, response, route**, and (as plain functions) +**handler** and **middleware**. Everything else is a function over them. + +## Quickstart + +```lisp +(dream-run + (dream-make-app + (list + (dream-get "/" (fn (req) (dream-html "

Hello, World!

"))) + (dream-get "/hello/:name" + (fn (req) (dream-text (str "Hi, " (dream-param req "name")))))))) +``` + +`dream-make-app` wraps the router in the default stack (error catch + content-type). +`dream-run` installs the root handler on the existing SX HTTP server — it does **not** +open its own socket. + +## Public surface + +- **types** — `dream-request`/`dream-response`/`dream-route`, accessors + (`dream-method`/`-path`/`-body`/`-header`/`-query-param`/`-param`), smart + constructors (`dream-html`/`-text`/`-json`/`-empty`/`-not-found`/`-redirect`), + convenience (`dream-queries`, `*-or` defaults, `dream-accepts?`/`dream-wants-json?`). +- **router** — `dream-get`/`-post`/`-put`/`-delete`/`-patch`/`-head`/`-options`/`-any`, + `dream-router`, `dream-scope` (prefix + middleware), `:name` params + `**` catch-all, + 405 + `Allow`, automatic HEAD. +- **middleware** — `dream-pipeline`, `dream-no-middleware`, `dream-logger`, + `dream-content-type`, `dream-set-header`, `dream-tap-request`. +- **session** — `dream-sessions` / `dream-sessions-signed`, `dream-session-field` / + `dream-set-session-field` / `dream-session-all` / `dream-invalidate-session`; cookie + helpers (`dream-cookie`, `dream-set-cookie`, `dream-cookie-sign`/`-unsign`). +- **flash** — `dream-flash`, `dream-add-flash-message`, `dream-flash-messages`. +- **form** — `dream-form` (Ok/Err), `dream-form-fields`, `dream-multipart`, CSRF + (`dream-csrf` / `dream-csrf-protect` / `dream-csrf-token` / `dream-csrf-tag`). +- **websocket** — `dream-websocket`, `dream-send`/`-receive`/`-close`/`-broadcast`. +- **static** — `dream-static` (mime, ETags, 304, ranges, traversal guard). +- **error** — `dream-catch`, `dream-status-text`/`-line`, `dream-status-page`. +- **cors** — `dream-cors`, `dream-cors-origin`, `dream-cors-with`. +- **json** — `dream-json-encode`/`-parse`, `dream-json-value`, `dream-json-body`. +- **run / api** — `dream-run`/`-port`/`-opts`, `dream-app`, `dream-make-app`, + `dream-serve`. + +## Testing story + +Every effectful concern is **dependency-injected**, so the whole framework is testable +without a running host: + +- sessions take a backend `(fn (op) …)` — `dream-memory-sessions` for tests, + `dream-perform-sessions` in production; +- static files take an fs — `dream-memory-fs` vs `dream-static-perform-fs`; +- websockets take an io — `dream-mock-ws` vs `dream-ws-perform-io`; +- `dream-run` takes a listen transport (`dream-run-with`). + +Run the suite: `bash lib/dream/conformance.sh` (367 tests, 14 suites). + +## Notes & caveats + +- Headers are dicts with **lowercased string keys** (in SX keywords *are* strings, so + `:content-type` == `"content-type"`). +- Outgoing cookies accumulate in a `:set-cookies` list on the response so multiple + `Set-Cookie` headers don't collide. +- The CSRF/cookie/ETag signing uses a pure-SX keyed hash — **not cryptographic**. + Production should inject a host HMAC (`dream-csrf-with`, and the signed-session + secret path). +- JSON and multipart are in-memory (not streaming). diff --git a/lib/dream/api.sx b/lib/dream/api.sx new file mode 100644 index 00000000..63b1850b --- /dev/null +++ b/lib/dream/api.sx @@ -0,0 +1,33 @@ +;; lib/dream/api.sx — Dream-on-SX public facade. +;; Loaded last; bundles the modules into a batteries-included surface. The full +;; public API is the `dream-*` functions across types/router/middleware/session/ +;; flash/form/websocket/static/error/cors/json/run; this file adds convenience +;; app builders. Depends on all other dream modules. + +(define dream-version "0.1.0") + +;; standard middleware stack (pure — no IO): error catch outermost, then +;; content-type sniffing. Logger is opt-in since it performs host IO. +(define + dream-defaults + (fn + (handler) + (dream-pipeline (list dream-catch dream-content-type) handler))) + +;; build a complete app handler from a route list with the default stack +(define + dream-make-app + (fn (routes) (dream-defaults (dream-router routes)))) + +;; build an app and wrap it with extra middleware (outermost first) +(define + dream-make-app-with + (fn + (middlewares routes) + (dream-pipeline middlewares (dream-make-app routes)))) + +;; one-call serve: routes + opts -> installed on the host +(define + dream-serve + (fn (routes opts) (dream-run-opts (dream-make-app routes) opts))) +(define dream-serve-port (fn (routes port) (dream-serve routes {:port port}))) diff --git a/lib/dream/auth.sx b/lib/dream/auth.sx new file mode 100644 index 00000000..16c5dd61 --- /dev/null +++ b/lib/dream/auth.sx @@ -0,0 +1,172 @@ +;; lib/dream/auth.sx — Dream-on-SX authentication helpers. +;; HTTP Basic auth (with a pure-SX base64 codec) and Bearer-token guards. +;; Depends on types.sx. + +;; ── base64 (pure SX; arithmetic, no bitwise) ─────────────────────── +(define + dr/b64-alpha + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/") +(define dr/b64-char (fn (n) (char-at dr/b64-alpha n))) +(define dr/b64-index (fn (c) (index-of dr/b64-alpha c))) + +(define + dr/b64-encode-loop + (fn + (s i n acc) + (if + (>= i n) + acc + (let + ((b0 (char-code (char-at s i))) (rem (- n i))) + (cond + ((>= rem 3) + (let + ((triple (+ (* b0 65536) (* (char-code (char-at s (+ i 1))) 256) (char-code (char-at s (+ i 2)))))) + (dr/b64-encode-loop + s + (+ i 3) + n + (str + acc + (dr/b64-char (mod (quotient triple 262144) 64)) + (dr/b64-char (mod (quotient triple 4096) 64)) + (dr/b64-char (mod (quotient triple 64) 64)) + (dr/b64-char (mod triple 64)))))) + ((= rem 2) + (let + ((triple (+ (* b0 65536) (* (char-code (char-at s (+ i 1))) 256)))) + (str + acc + (dr/b64-char (mod (quotient triple 262144) 64)) + (dr/b64-char (mod (quotient triple 4096) 64)) + (dr/b64-char (mod (quotient triple 64) 64)) + "="))) + (else + (let + ((triple (* b0 65536))) + (str + acc + (dr/b64-char (mod (quotient triple 262144) 64)) + (dr/b64-char (mod (quotient triple 4096) 64)) + "==")))))))) + +(define + dream-base64-encode + (fn (s) (dr/b64-encode-loop s 0 (string-length s) ""))) + +(define + dr/b64-decode-loop + (fn + (s i n acc) + (if + (>= i n) + acc + (let + ((p2 (char-at s (+ i 2))) + (p3 (char-at s (+ i 3)))) + (let + ((c0 (dr/b64-index (char-at s i))) + (c1 (dr/b64-index (char-at s (+ i 1)))) + (c2 (if (= p2 "=") 0 (dr/b64-index p2))) + (c3 (if (= p3 "=") 0 (dr/b64-index p3)))) + (let + ((triple (+ (* c0 262144) (* c1 4096) (* c2 64) c3))) + (dr/b64-decode-loop + s + (+ i 4) + n + (str + acc + (char-from-code + (mod (quotient triple 65536) 256)) + (if + (= p2 "=") + "" + (char-from-code + (mod (quotient triple 256) 256))) + (if (= p3 "=") "" (char-from-code (mod triple 256))))))))))) + +(define + dream-base64-decode + (fn + (s) + (if (= s "") "" (dr/b64-decode-loop s 0 (string-length s) "")))) + +;; ── Authorization header parsing ─────────────────────────────────── +(define dream-authorization (fn (req) (dream-header req "authorization"))) + +(define + dream-bearer-token + (fn + (req) + (let + ((a (dream-authorization req))) + (if (and a (starts-with? a "Bearer ")) (substr a 7) nil)))) + +(define + dream-basic-credentials + (fn + (req) + (let + ((a (dream-authorization req))) + (if + (and a (starts-with? a "Basic ")) + (let + ((decoded (dream-base64-decode (substr a 6)))) + (let + ((colon (index-of decoded ":"))) + (if (< colon 0) nil {:pass (substr decoded (+ colon 1)) :user (substr decoded 0 colon)}))) + nil)))) + +;; ── Basic auth middleware ────────────────────────────────────────── +;; check is (fn (user pass) -> bool). On success the request gains :dream-user. +(define + dr/www-authenticate + (fn + (realm) + (dream-add-header + (dream-response 401 {:content-type "text/plain; charset=utf-8"} "Unauthorized") + "www-authenticate" + (str "Basic realm=\"" realm "\"")))) + +(define + dream-basic-auth + (fn + (realm check) + (fn + (next) + (fn + (req) + (let + ((creds (dream-basic-credentials req))) + (if + (and creds (check (get creds :user) (get creds :pass))) + (next (assoc req :dream-user (get creds :user))) + (dr/www-authenticate realm))))))) + +(define dream-user (fn (req) (get req :dream-user))) + +;; ── Bearer-token middleware ──────────────────────────────────────── +;; check is (fn (token) -> principal | nil). On success the request gains +;; :dream-principal. Missing/invalid -> 401. +(define + dream-require-bearer + (fn + (check) + (fn + (next) + (fn + (req) + (let + ((tok (dream-bearer-token req))) + (let + ((principal (if tok (check tok) nil))) + (if + (nil? principal) + (dream-add-header + (dream-response 401 {:content-type "text/plain; charset=utf-8"} "Unauthorized") + "www-authenticate" + "Bearer") + (next (assoc req :dream-principal principal))))))))) + +(define dream-principal (fn (req) (get req :dream-principal))) diff --git a/lib/dream/conformance.sh b/lib/dream/conformance.sh new file mode 100644 index 00000000..bba0c225 --- /dev/null +++ b/lib/dream/conformance.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# dream-on-sx conformance runner — loads all dream modules + test suites in one +# sx_server process and reports pass/fail per suite. +# +# Usage: +# bash lib/dream/conformance.sh # run all suites +# bash lib/dream/conformance.sh -v # verbose (list each suite) + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}" +if [ ! -x "$SX_SERVER" ]; then + SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe" +fi +if [ ! -x "$SX_SERVER" ]; then + echo "ERROR: sx_server.exe not found." >&2 + exit 1 +fi + +VERBOSE="${1:-}" + +# Dream library modules loaded before any test suite. +MODULES=( + "lib/dream/types.sx" + "lib/dream/router.sx" + "lib/dream/middleware.sx" + "lib/dream/session.sx" + "lib/dream/flash.sx" + "lib/dream/form.sx" + "lib/dream/websocket.sx" + "lib/dream/static.sx" + "lib/dream/error.sx" + "lib/dream/cors.sx" + "lib/dream/json.sx" + "lib/dream/auth.sx" + "lib/dream/html.sx" + "lib/dream/headers.sx" + "lib/dream/run.sx" + "lib/dream/api.sx" + "lib/dream/demos/hello.sx" + "lib/dream/demos/counter.sx" + "lib/dream/demos/chat.sx" + "lib/dream/demos/todo.sx" +) + +# Suites: NAME RUNNER-FN PATH +SUITES=( + "types dream-ty-tests-run! lib/dream/tests/types.sx" + "router dream-rt-tests-run! lib/dream/tests/router.sx" + "middleware dream-mw-tests-run! lib/dream/tests/middleware.sx" + "session dream-ss-tests-run! lib/dream/tests/session.sx" + "flash dream-fl-tests-run! lib/dream/tests/flash.sx" + "form dream-fo-tests-run! lib/dream/tests/form.sx" + "websocket dream-ws-tests-run! lib/dream/tests/websocket.sx" + "static dream-st-tests-run! lib/dream/tests/static.sx" + "error dream-er-tests-run! lib/dream/tests/error.sx" + "cors dream-co-tests-run! lib/dream/tests/cors.sx" + "json dream-js-tests-run! lib/dream/tests/json.sx" + "auth dream-au-tests-run! lib/dream/tests/auth.sx" + "html dream-ht-tests-run! lib/dream/tests/html.sx" + "headers dream-hd-tests-run! lib/dream/tests/headers.sx" + "run dream-rn-tests-run! lib/dream/tests/run.sx" + "api dream-ap-tests-run! lib/dream/tests/api.sx" + "demos dream-dm-tests-run! lib/dream/tests/demos.sx" +) + +TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT +EPOCH=1 +emit_load () { echo "(epoch $EPOCH)"; echo "(load \"$1\")"; EPOCH=$((EPOCH+1)); } +emit_eval () { echo "(epoch $EPOCH)"; echo "(eval \"$1\")"; EPOCH=$((EPOCH+1)); } + +{ + for M in "${MODULES[@]}"; do emit_load "$M"; done + for SUITE in "${SUITES[@]}"; do + read -r _NAME _RUNNER FILE <<< "$SUITE" + emit_load "$FILE" + emit_eval "($_RUNNER)" + done +} > "$TMPFILE" + +OUTPUT=$(timeout 540 "$SX_SERVER" < "$TMPFILE" 2>&1 || true) + +TOTAL_PASS=0 +TOTAL_FAIL=0 +FAILED_SUITES=() +LAST_DICT_LINES=$(echo "$OUTPUT" | grep -E '^\{:' || true) + +I=0 +while read -r LINE; do + [ -z "$LINE" ] && continue + P=$(echo "$LINE" | grep -oE ':passed [0-9]+' | awk '{print $2}') + F=$(echo "$LINE" | grep -oE ':failed [0-9]+' | awk '{print $2}') + [ -z "$P" ] && P=0 + [ -z "$F" ] && F=0 + SUITE_INFO="${SUITES[$I]}" + SUITE_NAME=$(echo "$SUITE_INFO" | awk '{print $1}') + TOTAL_PASS=$((TOTAL_PASS + P)) + TOTAL_FAIL=$((TOTAL_FAIL + F)) + if [ "$F" -gt 0 ]; then + FAILED_SUITES+=("$SUITE_NAME: $P/$((P+F))") + printf 'X %-12s %d/%d\n' "$SUITE_NAME" "$P" "$((P+F))" + echo "$LINE" | grep -oE ':name "[^"]*"' | sed 's/:name / fail: /' + elif [ "$VERBOSE" = "-v" ]; then + printf 'ok %-12s %d passed\n' "$SUITE_NAME" "$P" + fi + I=$((I+1)) +done <<< "$LAST_DICT_LINES" + +TOTAL=$((TOTAL_PASS + TOTAL_FAIL)) +if [ "$TOTAL" -eq 0 ]; then + echo "ERROR: no suite results parsed. Raw output:" >&2 + echo "$OUTPUT" >&2 + exit 1 +fi +if [ $TOTAL_FAIL -eq 0 ]; then + echo "ok $TOTAL_PASS/$TOTAL dream-on-sx tests passed (${#SUITES[@]} suites)" +else + echo "FAIL $TOTAL_PASS/$TOTAL passed, $TOTAL_FAIL failed:" + for S in "${FAILED_SUITES[@]}"; do echo " $S"; done + exit 1 +fi diff --git a/lib/dream/cors.sx b/lib/dream/cors.sx new file mode 100644 index 00000000..c0d25756 --- /dev/null +++ b/lib/dream/cors.sx @@ -0,0 +1,51 @@ +;; lib/dream/cors.sx — Dream-on-SX CORS middleware. +;; Decorates responses with Access-Control-Allow-* headers and short-circuits +;; preflight OPTIONS requests with a 204. Depends on types.sx. + +(define dream-cors-defaults {:methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" :headers "Content-Type" :max-age 86400 :credentials false :origin "*"}) + +(define + dr/cors-origin-headers + (fn + (opts resp) + (let + ((r1 (dream-add-header resp "access-control-allow-origin" (get opts :origin)))) + (if + (get opts :credentials) + (dream-add-header r1 "access-control-allow-credentials" "true") + r1)))) + +(define + dr/cors-preflight + (fn + (opts) + (dr/cors-origin-headers + opts + (dream-add-header + (dream-add-header + (dream-add-header + (dream-empty 204) + "access-control-allow-methods" + (get opts :methods)) + "access-control-allow-headers" + (get opts :headers)) + "access-control-max-age" + (str (get opts :max-age)))))) + +(define + dream-cors-with + (fn + (opts) + (fn + (next) + (fn + (req) + (if + (= (dream-method req) "OPTIONS") + (dr/cors-preflight opts) + (dr/cors-origin-headers opts (next req))))))) + +(define dream-cors (dream-cors-with dream-cors-defaults)) +(define + dream-cors-origin + (fn (origin) (dream-cors-with (assoc dream-cors-defaults :origin origin)))) diff --git a/lib/dream/demos/chat.sx b/lib/dream/demos/chat.sx new file mode 100644 index 00000000..b932d085 --- /dev/null +++ b/lib/dream/demos/chat.sx @@ -0,0 +1,46 @@ +;; lib/dream/demos/chat.sx — multi-room WebSocket chat (chat.ml). +;; A room registry holds the live connections per room; each ws session joins its +;; room, broadcasts every received message to the room, and leaves on close. + +(define dream-chat-rooms (fn () (let ((rooms {})) {:join (fn (room ws) (set! rooms (assoc rooms room (concat (or (get rooms room) (list)) (list ws))))) :broadcast (fn (room msg) (for-each (fn (w) (dream-send w msg)) (or (get rooms room) (list)))) :members (fn (room) (or (get rooms room) (list))) :leave (fn (room ws) (set! rooms (assoc rooms room (filter (fn (w) (not (= w ws))) (or (get rooms room) (list))))))}))) + +(define + dream-chat-loop + (fn + (rooms room ws) + (let + ((m (dream-receive ws))) + (if + (nil? m) + (begin ((get rooms :leave) room ws) (dream-close ws)) + (begin + ((get rooms :broadcast) room m) + (dream-chat-loop rooms room ws)))))) + +(define + dream-chat-session + (fn + (rooms room) + (fn + (ws) + (begin ((get rooms :join) room ws) (dream-chat-loop rooms room ws))))) + +(define + dream-chat-route + (fn + (rooms) + (fn + (req) + ((dream-websocket (dream-chat-session rooms (dream-param req "room"))) + req)))) + +(define + dream-chat-app-with + (fn + (rooms) + (dream-router + (list + (dream-get "/" (fn (req) (dream-html "

Rooms

"))) + (dream-get "/chat/:room" (dream-chat-route rooms)))))) + +;; entry point: (dream-run (dream-chat-app-with (dream-chat-rooms))) diff --git a/lib/dream/demos/counter.sx b/lib/dream/demos/counter.sx new file mode 100644 index 00000000..4166d7f7 --- /dev/null +++ b/lib/dream/demos/counter.sx @@ -0,0 +1,35 @@ +;; lib/dream/demos/counter.sx — per-session visit counter (counter.ml). +;; Demonstrates the session middleware: each browser session keeps its own count. + +(define + dream-counter-handler + (fn + (req) + (let + ((n (+ 1 (or (dream-session-field req "count") 0)))) + (begin + (dream-set-session-field req "count" n) + (dream-html (str "

You have visited this page " n " time(s).

")))))) + +;; reset clears the session counter +(define + dream-counter-reset + (fn + (req) + (begin + (dream-set-session-field req "count" 0) + (dream-redirect "/")))) + +(define + dream-counter-app-with + (fn + (backend) + ((dream-sessions backend) + (dream-router + (list + (dream-get "/" dream-counter-handler) + (dream-post "/reset" dream-counter-reset)))))) + +(define dream-counter-app (dream-counter-app-with (dream-memory-sessions))) + +;; entry point: (dream-run (dream-counter-app-with (dream-memory-sessions))) diff --git a/lib/dream/demos/hello.sx b/lib/dream/demos/hello.sx new file mode 100644 index 00000000..0082dc25 --- /dev/null +++ b/lib/dream/demos/hello.sx @@ -0,0 +1,16 @@ +;; lib/dream/demos/hello.sx — the canonical Dream "Hello, World!" (hello.ml). +;; Dream.run (Dream.router [Dream.get "/" (fun _ -> Dream.html "Hello!")]). + +(define + dream-hello-app + (dream-router + (list + (dream-get "/" (fn (req) (dream-html "

Hello, World!

"))) + (dream-get + "/hello/:name" + (fn + (req) + (dream-html (str "

Hello, " (dream-param req "name") "!

"))))))) + +;; entry point (installs the handler on the host): +;; (dream-run dream-hello-app) diff --git a/lib/dream/demos/todo.sx b/lib/dream/demos/todo.sx new file mode 100644 index 00000000..ab367199 --- /dev/null +++ b/lib/dream/demos/todo.sx @@ -0,0 +1,96 @@ +;; lib/dream/demos/todo.sx — CRUD todo list with forms + CSRF (todo.ml). +;; An in-memory store holds items; add/toggle/delete go through POST forms guarded +;; by the CSRF middleware. User text is HTML-escaped on render (dream-escape). +;; Wires session -> csrf -> router. + +(define + dream-todo-store + (fn () (let ((items (list)) (next-id 0)) {:all (fn () items) :add (fn (text) (begin (set! next-id (+ next-id 1)) (set! items (concat items (list {:id next-id :text text :done false}))) next-id)) :delete (fn (id) (set! items (filter (fn (it) (not (= (get it :id) id))) items))) :toggle (fn (id) (set! items (map (fn (it) (if (= (get it :id) id) (assoc it :done (not (get it :done))) it)) items)))}))) + +(define + dr/todo-render + (fn + (store req) + (str + "" + "
" + (dream-csrf-tag req) + "
"))) + +(define + dream-todo-index + (fn (store) (fn (req) (dream-html (dr/todo-render store req))))) + +(define + dream-todo-add + (fn + (store) + (fn + (req) + (let + ((r (dream-form req))) + (if + (dream-ok? r) + (begin + ((get store :add) (get (dream-ok-value r) "text")) + (dream-redirect "/")) + (dream-html-status + 403 + (str "Rejected: " (dream-err-reason r)))))))) + +(define + dream-todo-toggle + (fn + (store) + (fn + (req) + (let + ((r (dream-form req))) + (if + (dream-ok? r) + (begin + ((get store :toggle) (parse-int (dream-param req "id"))) + (dream-redirect "/")) + (dream-html-status 403 "Rejected")))))) + +(define + dream-todo-delete + (fn + (store) + (fn + (req) + (let + ((r (dream-form req))) + (if + (dream-ok? r) + (begin + ((get store :delete) (parse-int (dream-param req "id"))) + (dream-redirect "/")) + (dream-html-status 403 "Rejected")))))) + +(define + dream-todo-app-with + (fn + (store backend secret) + ((dream-sessions backend) + ((dream-csrf secret) + (dream-router + (list + (dream-get "/" (dream-todo-index store)) + (dream-post "/add" (dream-todo-add store)) + (dream-post "/toggle/:id" (dream-todo-toggle store)) + (dream-post "/delete/:id" (dream-todo-delete store)))))))) + +;; entry: (dream-run (dream-todo-app-with (dream-todo-store) (dream-memory-sessions) "change-me")) diff --git a/lib/dream/error.sx b/lib/dream/error.sx new file mode 100644 index 00000000..9f1d3174 --- /dev/null +++ b/lib/dream/error.sx @@ -0,0 +1,41 @@ +;; lib/dream/error.sx — Dream-on-SX status phrases + error-handling middleware. +;; dream-catch wraps a handler and turns a raised error into a 500 response (or a +;; custom page). Depends on types.sx. + +;; ── status reason phrases ────────────────────────────────────────── +(define dr/status-texts {:206 "Partial Content" :202 "Accepted" :422 "Unprocessable Entity" :400 "Bad Request" :302 "Found" :204 "No Content" :502 "Bad Gateway" :429 "Too Many Requests" :301 "Moved Permanently" :415 "Unsupported Media Type" :405 "Method Not Allowed" :303 "See Other" :401 "Unauthorized" :304 "Not Modified" :503 "Service Unavailable" :404 "Not Found" :308 "Permanent Redirect" :504 "Gateway Timeout" :416 "Range Not Satisfiable" :500 "Internal Server Error" :307 "Temporary Redirect" :201 "Created" :501 "Not Implemented" :409 "Conflict" :200 "OK" :410 "Gone" :403 "Forbidden"}) + +(define + dream-status-text + (fn (status) (or (get dr/status-texts (str status)) "Unknown"))) +(define + dream-status-line + (fn (status) (str status " " (dream-status-text status)))) + +;; ── error-handling middleware ────────────────────────────────────── +(define + dream-default-error-page + (fn + (req e) + (dream-html-status + 500 + (str "

" (dream-status-line 500) "

")))) + +(define + dream-catch-with + (fn + (on-error) + (fn + (next) + (fn (req) (guard (e (true (on-error req e))) (next req)))))) + +(define dream-catch (dream-catch-with dream-default-error-page)) + +;; a fallback handler that renders a status page for any code +(define + dream-status-page + (fn + (status) + (dream-html-status + status + (str "

" (dream-status-line status) "

")))) diff --git a/lib/dream/flash.sx b/lib/dream/flash.sx new file mode 100644 index 00000000..c026fdf6 --- /dev/null +++ b/lib/dream/flash.sx @@ -0,0 +1,91 @@ +;; lib/dream/flash.sx — Dream-on-SX flash messages. +;; A single-request cookie store: messages added during one request are read on +;; the NEXT request, then the cookie is cleared. Depends on types.sx + session.sx +;; (shared cookie helpers). A message is {:category c :message m}. + +;; ── cookie codec ─────────────────────────────────────────────────── +;; escape the field separators so categories/messages round-trip safely +(define + dr/flash-esc + (fn (s) (replace (replace (replace s "%" "%25") "|" "%7C") "~" "%7E"))) +(define + dr/flash-unesc + (fn (s) (replace (replace (replace s "%7E" "~") "%7C" "|") "%25" "%"))) + +(define + dr/flash-encode + (fn + (msgs) + (join + "~" + (map + (fn + (m) + (str + (dr/flash-esc (get m :category)) + "|" + (dr/flash-esc (get m :message)))) + msgs)))) + +(define + dr/flash-decode + (fn + (s) + (if + (= s "") + (list) + (map + (fn (part) (let ((i (index-of part "|"))) {:message (dr/flash-unesc (substr part (+ i 1))) :category (dr/flash-unesc (substr part 0 i))})) + (split s "~"))))) + +;; ── mutable outbox cell ──────────────────────────────────────────── +(define dr/flash-box (fn () (let ((items (list))) {:add (fn (x) (set! items (concat items (list x)))) :get (fn () items)}))) + +;; ── middleware ───────────────────────────────────────────────────── +(define dream-flash-cookie-name "dream.flash") + +(define + dream-flash + (fn + (next) + (fn + (req) + (let + ((incoming (dr/flash-decode (or (dream-cookie req dream-flash-cookie-name) ""))) + (box (dr/flash-box))) + (let + ((resp (next (assoc req :dream-flash {:box box :incoming incoming})))) + (let + ((out ((get box :get)))) + (cond + ((not (empty? out)) + (dream-set-cookie + resp + dream-flash-cookie-name + (dr/flash-encode out) + {:path "/" :http-only true :same-site "Lax"})) + ((not (empty? incoming)) + (dream-drop-cookie resp dream-flash-cookie-name)) + (else resp)))))))) + +;; ── handler-facing API ───────────────────────────────────────────── +(define + dream-add-flash-message + (fn + (req category msg) + (begin ((get (get (get req :dream-flash) :box) :add) {:message msg :category category}) req))) + +(define + dream-flash-messages + (fn (req) (get (get req :dream-flash) :incoming))) +(define dream-flash-category (fn (m) (get m :category))) +(define dream-flash-message (fn (m) (get m :message))) + +;; convenience: only messages of a given category +(define + dream-flash-of + (fn + (req category) + (filter + (fn (m) (= (get m :category) category)) + (dream-flash-messages req)))) diff --git a/lib/dream/form.sx b/lib/dream/form.sx new file mode 100644 index 00000000..1593b698 --- /dev/null +++ b/lib/dream/form.sx @@ -0,0 +1,366 @@ +;; lib/dream/form.sx — Dream-on-SX forms + CSRF. +;; Parses application/x-www-form-urlencoded bodies; CSRF tokens are stateless, +;; signed, and session-scoped. The signing function is injectable (a pure-SX keyed +;; hash by default — production should swap in a host HMAC). Depends on types.sx + +;; session.sx. dream-form returns an Ok/Err result value. + +;; ── Result (Ok/Err) ──────────────────────────────────────────────── +(define dream-ok (fn (v) {:value v :result "ok"})) +(define dream-err (fn (r) {:reason r :result "err"})) +(define dream-ok? (fn (x) (= (get x :result) "ok"))) +(define dream-err? (fn (x) (= (get x :result) "err"))) +(define dream-ok-value (fn (x) (get x :value))) +(define dream-err-reason (fn (x) (get x :reason))) + +;; ── percent decoding ─────────────────────────────────────────────── +(define + dr/hex-digit + (fn + (c) + (let + ((n (char-code c))) + (cond + ((and (>= n 48) (<= n 57)) (- n 48)) + ((and (>= n 65) (<= n 70)) + (+ 10 (- n 65))) + ((and (>= n 97) (<= n 102)) + (+ 10 (- n 97))) + (else 0))))) + +(define + dr/url-decode-loop + (fn + (s i n acc) + (if + (>= i n) + acc + (let + ((c (char-at s i))) + (if + (and (= c "%") (< (+ i 2) n)) + (dr/url-decode-loop + s + (+ i 3) + n + (str + acc + (char-from-code + (+ + (* 16 (dr/hex-digit (char-at s (+ i 1)))) + (dr/hex-digit (char-at s (+ i 2))))))) + (dr/url-decode-loop s (+ i 1) n (str acc c))))))) + +(define + dr/url-decode + (fn + (s) + (let + ((s2 (replace s "+" " "))) + (dr/url-decode-loop s2 0 (string-length s2) "")))) + +;; ── substring splitter (split primitive is char-class based) ─────── +(define + dr/split-on + (fn + (s sep) + (let + ((i (index-of s sep))) + (if + (< i 0) + (list s) + (cons + (substr s 0 i) + (dr/split-on (substr s (+ i (string-length sep))) sep)))))) + +;; ── urlencoded body parsing ──────────────────────────────────────── +(define + dr/parse-form-body + (fn + (body) + (if + (= body "") + {} + (reduce + (fn + (acc pair) + (if + (= pair "") + acc + (let + ((j (index-of pair "="))) + (if + (< j 0) + (assoc acc (dr/url-decode pair) "") + (assoc + acc + (dr/url-decode (substr pair 0 j)) + (dr/url-decode (substr pair (+ j 1)))))))) + {} + (split body "&"))))) + +;; raw fields, no CSRF check +(define dream-form-fields (fn (req) (dr/parse-form-body (dream-body req)))) +(define + dream-form-field + (fn (req name) (get (dream-form-fields req) name))) + +;; ── CSRF signing (injectable; pure-SX keyed hash default) ────────── +(define + dr/poly-hash + (fn (s base seed) (dr/poly-loop s 0 (string-length s) seed base))) +(define + dr/poly-loop + (fn + (s i n h base) + (if + (>= i n) + h + (dr/poly-loop + s + (+ i 1) + n + (mod (+ (* h base) (char-code (char-at s i))) 2147483647) + base)))) + +;; NOTE: not cryptographic — adequate to demonstrate stateless CSRF; production +;; should inject a real HMAC via dream-csrf-with. +(define + dream-csrf-sign-default + (fn + (secret msg) + (let + ((m (str secret "|" msg))) + (str + (dr/poly-hash m 131 7) + "-" + (dr/poly-hash m 137 13))))) + +(define dream-csrf-field-name "dream.csrf") + +(define + dr/csrf-make-token + (fn (sign secret sid) (str sid "." (sign secret sid)))) + +(define + dr/csrf-valid? + (fn + (sign secret sid token) + (if + (or (nil? token) (= token "")) + false + (let + ((dot (index-of token "."))) + (if + (< dot 0) + false + (let + ((tsid (substr token 0 dot)) + (tsig (substr token (+ dot 1)))) + (and (= tsid sid) (= tsig (sign secret sid))))))))) + +;; ── CSRF middleware: attach signing context (needs session upstream) ── +(define + dream-csrf-with + (fn + (secret sign) + (fn (next) (fn (req) (next (assoc req :dream-csrf {:sign sign :sid (dream-session-id req) :secret secret})))))) + +(define + dream-csrf + (fn (secret) (dream-csrf-with secret dream-csrf-sign-default))) + +(define dr/csrf-of (fn (req) (get req :dream-csrf))) + +;; current token + hidden-input tag for templates +(define + dream-csrf-token + (fn + (req) + (let + ((c (dr/csrf-of req))) + (dr/csrf-make-token (get c :sign) (get c :secret) (get c :sid))))) + +(define + dream-csrf-tag + (fn + (req) + (str + ""))) + +;; ── dream-form: parse + verify CSRF -> Ok fields | Err reason ────── +(define + dream-form + (fn + (req) + (let + ((c (dr/csrf-of req))) + (if + (nil? c) + (dream-err :csrf-context-missing) + (let + ((fields (dream-form-fields req))) + (if + (dr/csrf-valid? + (get c :sign) + (get c :secret) + (get c :sid) + (get fields dream-csrf-field-name)) + (dream-ok fields) + (dream-err :csrf-token-invalid))))))) + +;; ── CSRF auto-rejecting middleware (unsafe methods need a valid token) ── +(define + dr/csrf-safe-method? + (fn (m) (or (= m "GET") (= m "HEAD") (= m "OPTIONS")))) + +(define + dream-csrf-protect-with + (fn + (secret sign) + (fn + (next) + (fn + (req) + (let + ((req2 (assoc req :dream-csrf {:sign sign :sid (dream-session-id req) :secret secret}))) + (if + (dr/csrf-safe-method? (dream-method req2)) + (next req2) + (let + ((token (get (dream-form-fields req2) dream-csrf-field-name))) + (if + (dr/csrf-valid? sign secret (dream-session-id req2) token) + (next req2) + (dream-html-status 403 "CSRF token invalid"))))))))) + +(define + dream-csrf-protect + (fn (secret) (dream-csrf-protect-with secret dream-csrf-sign-default))) + +;; ── multipart/form-data parsing ──────────────────────────────────── +;; In-memory (not yet streaming): parses the whole body into parts, each +;; {:name :filename :content-type :content}. Returns Ok parts | Err :not-multipart. +(define + dr/multipart-boundary + (fn + (ctype) + (let + ((i (index-of ctype "boundary="))) + (if + (< i 0) + "" + (let + ((raw (trim (substr ctype (+ i 9))))) + (if + (starts-with? raw "\"") + (substr raw 1 (- (string-length raw) 2)) + raw)))))) + +;; strip one leading and one trailing CRLF +(define + dr/strip-edges + (fn + (s) + (let + ((s1 (if (starts-with? s "\r\n") (substr s 2) s))) + (if + (ends-with? s1 "\r\n") + (substr s1 0 (- (string-length s1) 2)) + s1)))) + +;; value of attr="..." within a header block +(define + dr/cd-attr + (fn + (block attr) + (let + ((key (str attr "=\""))) + (let + ((i (index-of block key))) + (if + (< i 0) + nil + (let + ((rest (substr block (+ i (string-length key))))) + (substr rest 0 (index-of rest "\"")))))))) + +;; value of a named header line within a header block +(define + dr/block-header + (fn + (block name) + (reduce + (fn + (acc line) + (if + (and + (nil? acc) + (starts-with? (lower line) (str (lower name) ":"))) + (trim (substr line (+ (index-of line ":") 1))) + acc)) + nil + (dr/split-on block "\r\n")))) + +(define + dr/parse-part + (fn + (seg) + (let + ((s (dr/strip-edges seg))) + (let + ((sp (index-of s "\r\n\r\n"))) + (if + (< sp 0) + nil + (let + ((block (substr s 0 sp)) + (content (substr s (+ sp 4)))) + {:name (dr/cd-attr block "name") :filename (dr/cd-attr block "filename") :content-type (dr/block-header block "content-type") :content content})))))) + +(define + dream-multipart + (fn + (req) + (let + ((boundary (dr/multipart-boundary (or (dream-header req "content-type") "")))) + (if + (= boundary "") + (dream-err :not-multipart) + (let + ((segs (dr/split-on (dream-body req) (str "--" boundary)))) + (dream-ok + (filter + (fn (p) (not (nil? p))) + (map + dr/parse-part + (filter (fn (seg) (starts-with? seg "\r\n")) segs))))))))) + +;; accessors over a parts list +(define + dream-multipart-field + (fn + (parts name) + (reduce + (fn + (acc p) + (if (and (nil? acc) (= (get p :name) name)) (get p :content) acc)) + nil + parts))) + +(define + dream-multipart-file + (fn + (parts name) + (reduce + (fn + (acc p) + (if + (and (nil? acc) (= (get p :name) name) (get p :filename)) + p + acc)) + nil + parts))) diff --git a/lib/dream/headers.sx b/lib/dream/headers.sx new file mode 100644 index 00000000..2d1ec4d3 --- /dev/null +++ b/lib/dream/headers.sx @@ -0,0 +1,54 @@ +;; lib/dream/headers.sx — Dream-on-SX security headers + cache-control helpers. +;; Depends on types.sx. + +;; ── security headers middleware ──────────────────────────────────── +(define dream-security-defaults {:x-frame-options "DENY" :referrer-policy "no-referrer" :x-content-type-options "nosniff" :hsts false}) + +(define + dr/apply-security + (fn + (opts resp) + (let + ((r1 (dream-add-header (dream-add-header (dream-add-header resp "x-content-type-options" (get opts :x-content-type-options)) "x-frame-options" (get opts :x-frame-options)) "referrer-policy" (get opts :referrer-policy)))) + (if + (get opts :hsts) + (dream-add-header + r1 + "strict-transport-security" + "max-age=31536000; includeSubDomains") + r1)))) + +(define + dream-security-headers-with + (fn (opts) (fn (next) (fn (req) (dr/apply-security opts (next req)))))) +(define + dream-security-headers + (dream-security-headers-with dream-security-defaults)) + +;; ── cache-control response helpers ───────────────────────────────── +(define + dream-cache + (fn + (resp seconds) + (dream-add-header resp "cache-control" (str "public, max-age=" seconds)))) +(define + dream-private-cache + (fn + (resp seconds) + (dream-add-header resp "cache-control" (str "private, max-age=" seconds)))) +(define + dream-no-store + (fn (resp) (dream-add-header resp "cache-control" "no-store"))) +(define + dream-no-cache + (fn + (resp) + (dream-add-header + resp + "cache-control" + "no-cache, no-store, must-revalidate"))) + +;; cache-control middleware: stamp a max-age on every response +(define + dream-cache-for + (fn (seconds) (fn (next) (fn (req) (dream-cache (next req) seconds))))) diff --git a/lib/dream/html.sx b/lib/dream/html.sx new file mode 100644 index 00000000..feeec7a8 --- /dev/null +++ b/lib/dream/html.sx @@ -0,0 +1,24 @@ +;; lib/dream/html.sx — Dream-on-SX HTML escaping for safe templating. +;; Interpolating user input into HTML without escaping is an XSS hole; dream-escape +;; neutralises it. Depends on nothing (pure string ops). + +;; escape text for HTML element content / double-quoted attributes +(define + dream-escape + (fn + (s) + (replace + (replace + (replace (replace (replace s "&" "&") "<" "<") ">" ">") + "\"" + """) + "'" + "'"))) + +;; build a single attribute: name="escaped-value" +(define dream-attr (fn (name val) (str name "=\"" (dream-escape val) "\""))) + +;; join escaped text with a separator, escaping each piece +(define + dream-escape-join + (fn (sep pieces) (join sep (map dream-escape pieces)))) diff --git a/lib/dream/json.sx b/lib/dream/json.sx new file mode 100644 index 00000000..92d6cadc --- /dev/null +++ b/lib/dream/json.sx @@ -0,0 +1,183 @@ +;; lib/dream/json.sx — Dream-on-SX JSON encode/parse (pure SX). +;; The host JSON primitives live in the ocaml-on-sx runtime, not the base env, so +;; Dream ships its own. Depends on types.sx. (number? is unreliable in this env — +;; type-of "number" is used instead.) + +;; ── encoding ─────────────────────────────────────────────────────── +(define + dr/json-escape + (fn + (s) + (replace + (replace + (replace (replace (replace s "\\" "\\\\") "\"" "\\\"") "\n" "\\n") + "\r" + "\\r") + "\t" + "\\t"))) +(define dr/json-quote (fn (s) (str "\"" (dr/json-escape s) "\""))) + +(define + dream-json-encode + (fn + (v) + (cond + ((nil? v) "null") + ((boolean? v) (if v "true" "false")) + ((= (type-of v) "number") (str v)) + ((string? v) (dr/json-quote v)) + ((list? v) (str "[" (join "," (map dream-json-encode v)) "]")) + ((dict? v) + (str + "{" + (join + "," + (map + (fn + (k) + (str (dr/json-quote k) ":" (dream-json-encode (get v k)))) + (keys v))) + "}")) + (else (dr/json-quote (str v)))))) + +;; ── parsing (recursive descent; returns {:val :pos}) ─────────────── +(define + dr/json-space? + (fn (c) (or (= c " ") (= c "\n") (= c "\r") (= c "\t")))) +(define + dr/json-ws + (fn + (s i) + (if + (and (< i (string-length s)) (dr/json-space? (char-at s i))) + (dr/json-ws s (+ i 1)) + i))) + +(define + dr/json-digit? + (fn + (c) + (let ((n (char-code c))) (and (>= n 48) (<= n 57))))) +(define + dr/json-num-char? + (fn + (c) + (or + (dr/json-digit? c) + (= c "-") + (= c "+") + (= c ".") + (= c "e") + (= c "E")))) +(define + dr/json-num-end + (fn + (s i) + (if + (and (< i (string-length s)) (dr/json-num-char? (char-at s i))) + (dr/json-num-end s (+ i 1)) + i))) +(define + dr/json-to-number + (fn + (str-val) + (if + (or + (contains? str-val ".") + (contains? str-val "e") + (contains? str-val "E")) + (parse-float str-val) + (parse-int str-val)))) + +(define + dr/json-str + (fn + (s i acc) + (let + ((c (char-at s i))) + (cond + ((= c "\"") {:val acc :pos (+ i 1)}) + ((= c "\\") + (let + ((e (char-at s (+ i 1)))) + (cond + ((= e "n") (dr/json-str s (+ i 2) (str acc "\n"))) + ((= e "r") (dr/json-str s (+ i 2) (str acc "\r"))) + ((= e "t") (dr/json-str s (+ i 2) (str acc "\t"))) + (else (dr/json-str s (+ i 2) (str acc e)))))) + (else (dr/json-str s (+ i 1) (str acc c))))))) + +(define + dr/json-num + (fn (s i) (let ((j (dr/json-num-end s i))) {:val (dr/json-to-number (substr s i (- j i))) :pos j}))) + +(define + dr/json-arr + (fn + (s i acc) + (let + ((i (dr/json-ws s i))) + (if + (= (char-at s i) "]") + {:val acc :pos (+ i 1)} + (let + ((r (dr/json-val s i))) + (let + ((i2 (dr/json-ws s (get r :pos)))) + (if + (= (char-at s i2) ",") + (dr/json-arr + s + (+ i2 1) + (concat acc (list (get r :val)))) + {:val (concat acc (list (get r :val))) :pos (+ i2 1)}))))))) + +(define + dr/json-obj + (fn + (s i acc) + (let + ((i (dr/json-ws s i))) + (if + (= (char-at s i) "}") + {:val acc :pos (+ i 1)} + (let + ((kr (dr/json-str s (+ i 1) ""))) + (let + ((i2 (dr/json-ws s (get kr :pos)))) + (let + ((vr (dr/json-val s (+ i2 1)))) + (let + ((i3 (dr/json-ws s (get vr :pos)))) + (if + (= (char-at s i3) ",") + (dr/json-obj + s + (+ i3 1) + (assoc acc (get kr :val) (get vr :val))) + {:val (assoc acc (get kr :val) (get vr :val)) :pos (+ i3 1)}))))))))) + +(define + dr/json-val + (fn + (s i) + (let + ((i (dr/json-ws s i))) + (let + ((c (char-at s i))) + (cond + ((= c "{") (dr/json-obj s (+ i 1) {})) + ((= c "[") (dr/json-arr s (+ i 1) (list))) + ((= c "\"") (dr/json-str s (+ i 1) "")) + ((= c "t") {:val true :pos (+ i 4)}) + ((= c "f") {:val false :pos (+ i 5)}) + ((= c "n") {:val nil :pos (+ i 4)}) + (else (dr/json-num s i))))))) + +(define dream-json-parse (fn (s) (get (dr/json-val s 0) :val))) + +;; ── responses ────────────────────────────────────────────────────── +;; encode a value into a JSON response (dream-json takes a raw string body) +(define dream-json-value (fn (v) (dream-json (dream-json-encode v)))) +;; read + parse the request body as JSON +(define dream-json-body (fn (req) (dream-json-parse (dream-body req)))) diff --git a/lib/dream/middleware.sx b/lib/dream/middleware.sx new file mode 100644 index 00000000..9a980a90 --- /dev/null +++ b/lib/dream/middleware.sx @@ -0,0 +1,92 @@ +;; lib/dream/middleware.sx — Dream-on-SX middleware. +;; A middleware is handler->handler. Composition is plain function composition: +;; m1 @@ m2 @@ handler = (m1 (m2 handler)). Depends on types.sx + router.sx +;; (reuses dr/apply-middlewares for the fold). + +;; ── composition ──────────────────────────────────────────────────── +;; (dream-pipeline (list m1 m2 m3) handler) = (m1 (m2 (m3 handler))). +(define + dream-pipeline + (fn (middlewares handler) (dr/apply-middlewares middlewares handler))) + +;; identity middleware +(define dream-no-middleware (fn (next) next)) + +;; ── logger ───────────────────────────────────────────────────────── +;; Parameterised on a clock and a sink so it is testable without IO. +;; sink receives {:method :path :status :elapsed}. +(define + dream-logger-with + (fn + (clock sink) + (fn + (next) + (fn + (req) + (let + ((t0 (clock))) + (let ((resp (next req))) (begin (sink {:path (dream-path req) :status (dream-status resp) :method (dream-method req) :elapsed (- (clock) t0)}) resp))))))) + +;; default logger performs host effects for the clock and the log sink +(define + dream-logger + (dream-logger-with + (fn () (perform (:dream-clock))) + (fn (entry) (perform (:dream-log entry))))) + +;; format a log entry as a one-line string (apache-ish) +(define + dream-log-line + (fn + (entry) + (str + (get entry :method) + " " + (get entry :path) + " -> " + (get entry :status) + " (" + (get entry :elapsed) + "ms)"))) + +;; ── content-type sniffer ─────────────────────────────────────────── +(define + dr/sniff-content-type + (fn + (body) + (cond + ((= body "") "text/plain; charset=utf-8") + ((starts-with? body "<") "text/html; charset=utf-8") + ((starts-with? body "{") "application/json") + ((starts-with? body "[") "application/json") + (else "text/plain; charset=utf-8")))) + +;; sets Content-Type from the body only when the handler left it unset +(define + dream-content-type + (fn + (next) + (fn + (req) + (let + ((resp (next req))) + (if + (dream-resp-header resp "content-type") + resp + (dream-add-header + resp + "content-type" + (dr/sniff-content-type (dream-resp-body resp)))))))) + +;; ── small reusable middlewares ───────────────────────────────────── +;; always attach a response header +(define + dream-set-header + (fn + (name val) + (fn (next) (fn (req) (dream-add-header (next req) name val))))) + +;; rewrite/observe the request before the handler sees it +(define + dream-tap-request + (fn (f) (fn (next) (fn (req) (next (f req)))))) diff --git a/lib/dream/router.sx b/lib/dream/router.sx new file mode 100644 index 00000000..9158ee7d --- /dev/null +++ b/lib/dream/router.sx @@ -0,0 +1,170 @@ +;; lib/dream/router.sx — Dream-on-SX routing. +;; Routes are dicts {:method :path :handler}; a router is a handler that +;; dispatches request -> response by method + path, extracting :name path +;; params and binding a ** catch-all. No path match -> 404; path matches but +;; method doesn't -> 405 + Allow. HEAD falls back to the GET handler with an +;; empty body. Depends on types.sx. + +;; ── route constructors (one per HTTP method) ─────────────────────── +(define dream-get (fn (path handler) (dream-route "GET" path handler))) +(define dream-post (fn (path handler) (dream-route "POST" path handler))) +(define dream-put (fn (path handler) (dream-route "PUT" path handler))) +(define + dream-delete + (fn (path handler) (dream-route "DELETE" path handler))) +(define dream-patch (fn (path handler) (dream-route "PATCH" path handler))) +(define dream-head (fn (path handler) (dream-route "HEAD" path handler))) +(define + dream-options + (fn (path handler) (dream-route "OPTIONS" path handler))) +(define dream-any (fn (path handler) (dream-route "ANY" path handler))) + +;; ── path segmentation ────────────────────────────────────────────── +;; "/users/42/" -> ("users" "42"); "/" -> () +(define + dr/segs + (fn (path) (filter (fn (s) (not (= s ""))) (split path "/")))) + +(define + dr/join-path + (fn + (prefix path) + (str "/" (join "/" (concat (dr/segs prefix) (dr/segs path)))))) + +;; ── segment matching ─────────────────────────────────────────────── +;; Returns a params dict on match (possibly empty {}), nil on no match. +(define + dr/match-segs + (fn + (pat path params) + (cond + ((and (empty? pat) (empty? path)) params) + ((empty? pat) nil) + (else + (let + ((ps (first pat))) + (cond + ((= ps "**") (assoc params "**" (join "/" path))) + ((empty? path) nil) + ((starts-with? ps ":") + (dr/match-segs + (rest pat) + (rest path) + (assoc params (substr ps 1) (first path)))) + ((= ps (first path)) + (dr/match-segs (rest pat) (rest path) params)) + (else nil))))))) + +;; path-only match: returns params dict or nil +(define + dr/route-params + (fn + (r req) + (dr/match-segs + (dr/segs (dream-route-path r)) + (dr/segs (dream-path req)) + {}))) + +;; method acceptance: exact, ANY, or HEAD served by a GET route +(define + dr/method-accepts? + (fn + (route-method req-method) + (or + (= route-method "ANY") + (= route-method req-method) + (and (= req-method "HEAD") (= route-method "GET"))))) + +;; ── middleware pipeline (shared with middleware.sx) ──────────────── +;; m1 @@ m2 @@ handler = (m1 (m2 handler)); first in list is outermost. +(define + dr/apply-middlewares + (fn (mws handler) (reduce (fn (h mw) (mw h)) handler (reverse mws)))) + +;; ── scope: prefix mount + middleware chain ───────────────────────── +;; Returns a flat list of routes; nested scopes flatten correctly. +(define + dr/flatten-routes + (fn + (items) + (reduce + (fn + (acc it) + (if + (dream-route? it) + (concat acc (list it)) + (concat acc (dr/flatten-routes it)))) + (list) + items))) + +(define + dream-scope + (fn + (prefix middlewares routes) + (map + (fn + (r) + (dream-route + (dream-route-method r) + (dr/join-path prefix (dream-route-path r)) + (dr/apply-middlewares middlewares (dream-route-handler r)))) + (dr/flatten-routes routes)))) + +;; ── dispatch ─────────────────────────────────────────────────────── +;; allowed = methods of routes whose PATH matched (for 405 + Allow). +(define + dr/dispatch + (fn + (routes req allowed) + (if + (empty? routes) + (if + (empty? allowed) + (dream-not-found) + (dream-method-not-allowed allowed)) + (let + ((r (first routes))) + (let + ((params (dr/route-params r req))) + (if + (nil? params) + (dr/dispatch (rest routes) req allowed) + (if + (dr/method-accepts? (dream-route-method r) (dream-method req)) + (dr/run-route r req params) + (dr/dispatch + (rest routes) + req + (concat allowed (list (dream-route-method r))))))))))) + +;; run a matched route; blank the body for an auto-HEAD on a GET route +(define + dr/run-route + (fn + (r req params) + (let + ((resp (dream-coerce-response ((dream-route-handler r) (dream-with-params req params))))) + (if + (and + (= (dream-method req) "HEAD") + (not (= (dream-route-method r) "HEAD"))) + (dream-response (dream-status resp) (dream-headers resp) "") + resp)))) + +;; 405 response with an Allow header listing the path's methods +(define + dream-method-not-allowed + (fn + (allowed) + (dream-add-header + (dream-response 405 {:content-type "text/plain; charset=utf-8"} "Method Not Allowed") + "allow" + (join ", " allowed)))) + +(define + dream-router + (fn + (routes) + (let + ((flat (dr/flatten-routes routes))) + (fn (req) (dr/dispatch flat req (list)))))) diff --git a/lib/dream/run.sx b/lib/dream/run.sx new file mode 100644 index 00000000..18401f86 --- /dev/null +++ b/lib/dream/run.sx @@ -0,0 +1,42 @@ +;; lib/dream/run.sx — Dream-on-SX entry point. +;; dream-run installs a root handler into the existing SX HTTP server via +;; (perform (:http/listen …)) — it does NOT implement its own socket loop. The +;; host invokes the installed app per request with a raw request dict; the app +;; adapts it to a dream-request, runs the handler, and serialises the response +;; (status/headers/body/set-cookies, or a websocket upgrade). Depends on types.sx +;; + websocket.sx. The listen transport is injectable for testing. + +;; ── response serialisation for the host ──────────────────────────── +(define + dr/serialize-response + (fn (resp) (if (dream-websocket? resp) {:websocket (dream-ws-handler resp) :body "" :headers (dream-headers resp) :status 101 :set-cookies (list)} {:body (dream-resp-body resp) :headers (dream-headers resp) :status (dream-status resp) :set-cookies (dream-resp-cookies resp)}))) + +;; ── the app: raw host request -> serialised response ─────────────── +(define + dream-app + (fn + (handler) + (fn + (raw) + (let + ((req (dream-request (or (get raw :method) "GET") (or (get raw :target) (or (get raw :path) "/")) (or (get raw :headers) {}) (or (get raw :body) "")))) + (dr/serialize-response (dream-coerce-response (handler req))))))) + +;; ── dream-run ────────────────────────────────────────────────────── +(define dream-default-port 8080) + +(define dream-run-with (fn (listen handler opts) (listen {:op "http/listen" :port (or (get opts :port) dream-default-port) :app (dream-app handler) :host (or (get opts :host) "0.0.0.0")}))) + +(define dream-perform-listen (fn (op) (perform op))) + +(define + dream-run + (fn (handler) (dream-run-with dream-perform-listen handler {}))) +(define + dream-run-port + (fn + (handler port) + (dream-run-with dream-perform-listen handler {:port port}))) +(define + dream-run-opts + (fn (handler opts) (dream-run-with dream-perform-listen handler opts))) diff --git a/lib/dream/session.sx b/lib/dream/session.sx new file mode 100644 index 00000000..cb6c647a --- /dev/null +++ b/lib/dream/session.sx @@ -0,0 +1,238 @@ +;; lib/dream/session.sx — Dream-on-SX cookie-backed sessions. +;; The session cookie carries only a session id; fields live in a back-end store. +;; The store is injectable: production wires it to (perform op); tests pass an +;; in-memory store. Depends on types.sx. Also hosts shared cookie helpers reused +;; by flash.sx and form.sx. + +;; ── cookie helpers (shared) ──────────────────────────────────────── +(define + dr/parse-cookies + (fn + (header) + (if + (or (nil? header) (= header "")) + {} + (reduce + (fn + (acc part) + (let + ((kv (trim part))) + (let + ((j (index-of kv "="))) + (if + (< j 0) + acc + (assoc + acc + (substr kv 0 j) + (substr kv (+ j 1))))))) + {} + (split header ";"))))) + +(define + dream-cookie + (fn (req name) (get (dr/parse-cookies (dream-header req "cookie")) name))) +(define + dream-cookies + (fn (req) (dr/parse-cookies (dream-header req "cookie")))) + +(define + dr/build-cookie + (fn + (name val opts) + (let + ((o (if (nil? opts) {} opts))) + (str + name + "=" + val + "; Path=" + (or (get o :path) "/") + (if (get o :http-only) "; HttpOnly" "") + (if (get o :secure) "; Secure" "") + (if (get o :same-site) (str "; SameSite=" (get o :same-site)) "") + (if (get o :max-age) (str "; Max-Age=" (get o :max-age)) ""))))) + +(define + dream-set-cookie + (fn + (resp name val opts) + (assoc + resp + :set-cookies (concat + (or (get resp :set-cookies) (list)) + (list (dr/build-cookie name val opts)))))) + +(define + dream-resp-cookies + (fn (resp) (or (get resp :set-cookies) (list)))) + +;; expire a cookie on the client +(define + dream-drop-cookie + (fn (resp name) (dream-set-cookie resp name "" {:max-age 0}))) + +;; ── signed cookie values (tamper-evident) ────────────────────────── +;; NOTE: pure-SX keyed hash — not cryptographic; production should inject a host +;; HMAC. Value carries no "." so the first "." splits value from signature. +(define + dr/sess-hash + (fn (s) (dr/sess-hash-loop s 0 (string-length s) 7))) +(define + dr/sess-hash-loop + (fn + (s i n h) + (if + (>= i n) + h + (dr/sess-hash-loop + s + (+ i 1) + n + (mod (+ (* h 131) (char-code (char-at s i))) 2147483647))))) +(define + dr/sess-sig + (fn (secret val) (str (dr/sess-hash (str secret "|" val))))) + +(define + dream-cookie-sign + (fn (secret val) (str val "." (dr/sess-sig secret val)))) +(define + dream-cookie-unsign + (fn + (secret signed) + (if + (or (nil? signed) (= signed "")) + nil + (let + ((dot (index-of signed "."))) + (if + (< dot 0) + nil + (let + ((val (substr signed 0 dot)) + (sig (substr signed (+ dot 1)))) + (if (= sig (dr/sess-sig secret val)) val nil))))))) + +;; ── in-memory session store (tests + demos) ──────────────────────── +;; A backend is (fn (op) result) where op is a dict {:op ... :sid ... :key ...}. +(define + dream-memory-sessions + (fn + () + (let + ((store {}) (counter 0)) + (fn + (op) + (let + ((kind (get op :op))) + (cond + ((= kind "session/create") + (begin + (set! counter (+ counter 1)) + (let + ((sid (str "s" counter))) + (begin (set! store (assoc store sid {})) sid)))) + ((= kind "session/exists") (has-key? store (get op :sid))) + ((= kind "session/get") + (get (or (get store (get op :sid)) {}) (get op :key))) + ((= kind "session/set") + (let + ((sid (get op :sid))) + (set! + store + (assoc + store + sid + (assoc + (or (get store sid) {}) + (get op :key) + (get op :val)))))) + ((= kind "session/load") + (or (get store (get op :sid)) {})) + ((= kind "session/clear") + (set! store (dissoc store (get op :sid)))) + (else nil))))))) + +;; production back-end: every op suspends to the host +(define dream-perform-sessions (fn (op) (perform op))) + +;; ── session middleware ───────────────────────────────────────────── +(define dream-session-cookie-name "dream.session") + +(define + dream-sessions + (fn + (backend) + (fn + (next) + (fn + (req) + (let + ((sid0 (dream-cookie req dream-session-cookie-name))) + (let + ((have (and sid0 (backend {:op "session/exists" :sid sid0})))) + (let + ((sid (if have sid0 (backend {:op "session/create"})))) + (let + ((resp (next (assoc req :dream-session {:io backend :sid sid})))) + (if + have + resp + (dream-set-cookie + resp + dream-session-cookie-name + sid + {:path "/" :http-only true :same-site "Lax"})))))))))) + +;; signed variant: the cookie value is signed so a guessed/forged sid is rejected +(define + dream-sessions-signed + (fn + (backend secret) + (fn + (next) + (fn + (req) + (let + ((sid0 (dream-cookie-unsign secret (dream-cookie req dream-session-cookie-name)))) + (let + ((have (and sid0 (backend {:op "session/exists" :sid sid0})))) + (let + ((sid (if have sid0 (backend {:op "session/create"})))) + (let + ((resp (next (assoc req :dream-session {:io backend :sid sid})))) + (if + have + resp + (dream-set-cookie + resp + dream-session-cookie-name + (dream-cookie-sign secret sid) + {:path "/" :http-only true :same-site "Lax"})))))))))) + +;; ── handler-facing session API ───────────────────────────────────── +(define dr/session-of (fn (req) (get req :dream-session))) +(define dream-session-id (fn (req) (get (dr/session-of req) :sid))) + +(define + dream-session-field + (fn + (req key) + (let ((s (dr/session-of req))) ((get s :io) {:key key :op "session/get" :sid (get s :sid)})))) + +(define + dream-set-session-field + (fn + (req key val) + (let ((s (dr/session-of req))) (begin ((get s :io) {:val val :key key :op "session/set" :sid (get s :sid)}) req)))) + +(define + dream-session-all + (fn (req) (let ((s (dr/session-of req))) ((get s :io) {:op "session/load" :sid (get s :sid)})))) + +(define + dream-invalidate-session + (fn + (req) + (let ((s (dr/session-of req))) (begin ((get s :io) {:op "session/clear" :sid (get s :sid)}) req)))) diff --git a/lib/dream/static.sx b/lib/dream/static.sx new file mode 100644 index 00000000..372019a3 --- /dev/null +++ b/lib/dream/static.sx @@ -0,0 +1,182 @@ +;; lib/dream/static.sx — Dream-on-SX static file serving. +;; dream-static mounts at a ** route and serves files under a root: content-type by +;; extension, ETags + If-None-Match (304), and Range requests (206). The filesystem +;; is injectable: production reads via (perform op); tests pass an in-memory map. +;; Depends on types.sx. + +;; ── filesystem backends ──────────────────────────────────────────── +;; An fs is (fn (op) result); op {:op "file/read" :path p} -> content | nil. +(define dream-static-perform-fs (fn (op) (perform op))) + +;; in-memory fs over a {path -> content} dict (tests + demos) +(define + dream-memory-fs + (fn + (files) + (fn + (op) + (if (= (get op :op) "file/read") (get files (get op :path)) nil)))) + +;; ── content-type by extension ────────────────────────────────────── +(define dr/mime-types {:js "application/javascript" :jpeg "image/jpeg" :css "text/css; charset=utf-8" :ico "image/x-icon" :mjs "application/javascript" :html "text/html; charset=utf-8" :pdf "application/pdf" :jpg "image/jpeg" :json "application/json" :htm "text/html; charset=utf-8" :wasm "application/wasm" :webp "image/webp" :gif "image/gif" :png "image/png" :svg "image/svg+xml" :md "text/markdown; charset=utf-8" :xml "application/xml" :sx "text/plain; charset=utf-8" :txt "text/plain; charset=utf-8"}) + +(define + dr/ext-of + (fn + (path) + (let + ((segs (split path "."))) + (if + (> (len segs) 1) + (lower (nth segs (- (len segs) 1))) + "")))) + +(define + dream-content-type-for + (fn + (path) + (or (get dr/mime-types (dr/ext-of path)) "application/octet-stream"))) + +;; ── ETag (weak content hash) ─────────────────────────────────────── +(define + dr/static-hash + (fn (s) (dr/static-hash-loop s 0 (string-length s) 7))) +(define + dr/static-hash-loop + (fn + (s i n h) + (if + (>= i n) + h + (dr/static-hash-loop + s + (+ i 1) + n + (mod (+ (* h 131) (char-code (char-at s i))) 2147483647))))) +(define + dr/etag-of + (fn + (content) + (str "\"" (dr/static-hash content) "-" (string-length content) "\""))) +(define + dr/etag-match? + (fn (inm etag) (and (not (nil? inm)) (or (= inm "*") (= inm etag))))) + +;; ── path safety ──────────────────────────────────────────────────── +(define + dr/static-relpath + (fn + (req) + (or (dream-param req "**") (substr (dream-path req) 1)))) +(define + dr/unsafe-path? + (fn (rel) (or (contains? rel "..") (starts-with? rel "/")))) +(define + dr/path-join + (fn + (root rel) + (if (ends-with? root "/") (str root rel) (str root "/" rel)))) + +;; ── range requests ───────────────────────────────────────────────── +(define + dr/parse-range + (fn + (header total) + (let + ((eq (index-of header "="))) + (if + (< eq 0) + nil + (let + ((spec (substr header (+ eq 1)))) + (let + ((dash (index-of spec "-"))) + (if + (< dash 0) + nil + (let + ((s (substr spec 0 dash)) + (e (substr spec (+ dash 1)))) + (let + ((start (if (= s "") 0 (parse-int s))) + (end (if (= e "") (- total 1) (parse-int e)))) + (if + (or + (< start 0) + (>= start total) + (> end (- total 1)) + (> start end)) + nil + {:start start :end end})))))))))) + +(define + dr/serve-range + (fn + (req content etag ctype) + (let + ((total (string-length content))) + (let + ((r (dr/parse-range (dream-header req "range") total))) + (if + (nil? r) + (dream-add-header + (dream-response 416 {:content-type ctype} "") + "content-range" + (str "bytes */" total)) + (let + ((start (get r :start)) (end (get r :end))) + (dream-add-header + (dream-add-header + (dream-response + 206 + {:content-type ctype} + (substr content start (+ 1 (- end start)))) + "content-range" + (str "bytes " start "-" end "/" total)) + "etag" + etag))))))) + +;; ── serving ──────────────────────────────────────────────────────── +(define + dr/serve-file + (fn + (req content) + (let + ((rel (dr/static-relpath req))) + (let + ((etag (dr/etag-of content)) (ctype (dream-content-type-for rel))) + (cond + ((dr/etag-match? (dream-header req "if-none-match") etag) + (dream-add-header (dream-empty 304) "etag" etag)) + ((dream-header req "range") + (dr/serve-range req content etag ctype)) + (else + (dream-add-header + (dream-add-header + (dream-response 200 {:content-type ctype} content) + "etag" + etag) + "accept-ranges" + "bytes"))))))) + +(define + dream-static-with + (fn + (root fs) + (fn + (req) + (let + ((rel (dr/static-relpath req))) + (if + (dr/unsafe-path? rel) + (dream-html-status 403 "Forbidden") + (let + ((content (fs {:path (dr/path-join root rel) :op "file/read"}))) + (if + (nil? content) + (dream-not-found) + (dr/serve-file req content)))))))) + +(define + dream-static + (fn (root) (dream-static-with root dream-static-perform-fs))) diff --git a/lib/dream/tests/api.sx b/lib/dream/tests/api.sx new file mode 100644 index 00000000..add9b71f --- /dev/null +++ b/lib/dream/tests/api.sx @@ -0,0 +1,77 @@ +;; lib/dream/tests/api.sx — facade: app builders + default stack. + +(define dream-ap-pass 0) +(define dream-ap-fail 0) +(define dream-ap-fails (list)) + +(define + dream-ap-test + (fn + (name actual expected) + (if + (= actual expected) + (set! dream-ap-pass (+ dream-ap-pass 1)) + (begin + (set! dream-ap-fail (+ dream-ap-fail 1)) + (append! dream-ap-fails {:name name :actual actual :expected expected}))))) + +(dream-ap-test "version is a string" (string? dream-version) true) + +;; ── dream-make-app: routes -> handler with default stack ─────────── +(define + dream-ap-routes + (list + (dream-get "/" (fn (req) (dream-html "

hi

"))) + (dream-get "/boom" (fn (req) (error "kaboom"))) + (dream-get + "/raw" + (fn (req) (dream-response 200 {} "plain words"))))) +(define dream-ap-app (dream-make-app dream-ap-routes)) + +(dream-ap-test + "app serves" + (dream-resp-body (dream-ap-app (dream-request "GET" "/" {} ""))) + "

hi

") +(dream-ap-test + "app catches errors -> 500" + (dream-status (dream-ap-app (dream-request "GET" "/boom" {} ""))) + 500) +(dream-ap-test + "app 404 for unknown" + (dream-status (dream-ap-app (dream-request "GET" "/nope" {} ""))) + 404) +(dream-ap-test + "app sniffs content-type" + (dream-resp-header + (dream-ap-app (dream-request "GET" "/raw" {} "")) + "content-type") + "text/plain; charset=utf-8") + +;; ── dream-make-app-with: extra outer middleware ──────────────────── +(define + dream-ap-tag + (fn (next) (fn (req) (dream-add-header (next req) "X-App" "1")))) +(define + dream-ap-app2 + (dream-make-app-with (list dream-ap-tag) dream-ap-routes)) +(dream-ap-test + "extra middleware header" + (dream-resp-header + (dream-ap-app2 (dream-request "GET" "/" {} "")) + "x-app") + "1") + +;; ── dream-serve wires through dream-run ──────────────────────────── +(define dream-ap-captured nil) +(define dream-ap-listen (fn (op) (begin (set! dream-ap-captured op) :ok))) +(define + dream-ap-served + (dream-run-with dream-ap-listen (dream-make-app dream-ap-routes) {:port 7000})) +(dream-ap-test "serve listens" dream-ap-served :ok) +(dream-ap-test "serve port" (get dream-ap-captured :port) 7000) +(dream-ap-test + "served app runs" + (get ((get dream-ap-captured :app) {:method "GET" :target "/"}) :body) + "

hi

") + +(define dream-ap-tests-run! (fn () {:total (+ dream-ap-pass dream-ap-fail) :passed dream-ap-pass :failed dream-ap-fail :fails dream-ap-fails})) diff --git a/lib/dream/tests/auth.sx b/lib/dream/tests/auth.sx new file mode 100644 index 00000000..f5cb9806 --- /dev/null +++ b/lib/dream/tests/auth.sx @@ -0,0 +1,109 @@ +;; lib/dream/tests/auth.sx — base64, basic auth, bearer tokens. + +(define dream-au-pass 0) +(define dream-au-fail 0) +(define dream-au-fails (list)) + +(define + dream-au-test + (fn + (name actual expected) + (if + (= actual expected) + (set! dream-au-pass (+ dream-au-pass 1)) + (begin + (set! dream-au-fail (+ dream-au-fail 1)) + (append! dream-au-fails {:name name :actual actual :expected expected}))))) + +;; ── base64 ───────────────────────────────────────────────────────── +(dream-au-test "encode Man" (dream-base64-encode "Man") "TWFu") +(dream-au-test "encode Ma" (dream-base64-encode "Ma") "TWE=") +(dream-au-test "encode M" (dream-base64-encode "M") "TQ==") +(dream-au-test + "encode user:pass" + (dream-base64-encode "user:pass") + "dXNlcjpwYXNz") +(dream-au-test "decode Man" (dream-base64-decode "TWFu") "Man") +(dream-au-test "decode Ma" (dream-base64-decode "TWE=") "Ma") +(dream-au-test "decode M" (dream-base64-decode "TQ==") "M") +(dream-au-test + "decode user:pass" + (dream-base64-decode "dXNlcjpwYXNz") + "user:pass") +(dream-au-test + "roundtrip phrase" + (dream-base64-decode (dream-base64-encode "Hello, World!")) + "Hello, World!") +(dream-au-test + "roundtrip empty" + (dream-base64-decode (dream-base64-encode "")) + "") + +;; ── header parsing ───────────────────────────────────────────────── +(dream-au-test + "bearer token" + (dream-bearer-token (dream-request "GET" "/" {:Authorization "Bearer abc.123"} "")) + "abc.123") +(dream-au-test + "no bearer" + (dream-bearer-token (dream-request "GET" "/" {} "")) + nil) +(dream-au-test + "basic creds" + (dream-basic-credentials (dream-request "GET" "/" {:Authorization "Basic dXNlcjpwYXNz"} "")) + {:pass "pass" :user "user"}) +(dream-au-test + "no basic" + (dream-basic-credentials (dream-request "GET" "/" {} "")) + nil) + +;; ── basic auth middleware ────────────────────────────────────────── +(define dream-au-check (fn (u p) (and (= u "admin") (= p "secret")))) +(define + dream-au-app + ((dream-basic-auth "Admin Area" dream-au-check) + (fn (req) (dream-text (str "hi " (dream-user req)))))) + +(define dream-au-ok (dream-au-app (dream-request "GET" "/" {:Authorization (str "Basic " (dream-base64-encode "admin:secret"))} ""))) +(dream-au-test "basic ok reaches" (dream-resp-body dream-au-ok) "hi admin") +(dream-au-test "basic ok status" (dream-status dream-au-ok) 200) + +(define dream-au-bad (dream-au-app (dream-request "GET" "/" {:Authorization (str "Basic " (dream-base64-encode "admin:wrong"))} ""))) +(dream-au-test "basic wrong 401" (dream-status dream-au-bad) 401) +(dream-au-test + "basic wrong www-authenticate" + (contains? (dream-resp-header dream-au-bad "www-authenticate") "Admin Area") + true) +(dream-au-test + "basic missing 401" + (dream-status (dream-au-app (dream-request "GET" "/" {} ""))) + 401) + +;; ── bearer middleware ────────────────────────────────────────────── +(define dream-au-tokens {:t-ada "ada" :t-bob "bob"}) +(define dream-au-lookup (fn (tok) (get dream-au-tokens tok))) +(define + dream-au-bapp + ((dream-require-bearer dream-au-lookup) + (fn (req) (dream-text (dream-principal req))))) + +(dream-au-test + "bearer valid principal" + (dream-resp-body (dream-au-bapp (dream-request "GET" "/" {:Authorization "Bearer t-ada"} ""))) + "ada") +(dream-au-test + "bearer invalid 401" + (dream-status (dream-au-bapp (dream-request "GET" "/" {:Authorization "Bearer nope"} ""))) + 401) +(dream-au-test + "bearer missing 401" + (dream-status (dream-au-bapp (dream-request "GET" "/" {} ""))) + 401) +(dream-au-test + "bearer 401 header" + (dream-resp-header + (dream-au-bapp (dream-request "GET" "/" {} "")) + "www-authenticate") + "Bearer") + +(define dream-au-tests-run! (fn () {:total (+ dream-au-pass dream-au-fail) :passed dream-au-pass :failed dream-au-fail :fails dream-au-fails})) diff --git a/lib/dream/tests/cors.sx b/lib/dream/tests/cors.sx new file mode 100644 index 00000000..51ee9dc7 --- /dev/null +++ b/lib/dream/tests/cors.sx @@ -0,0 +1,93 @@ +;; lib/dream/tests/cors.sx — CORS decoration + preflight. + +(define dream-co-pass 0) +(define dream-co-fail 0) +(define dream-co-fails (list)) + +(define + dream-co-test + (fn + (name actual expected) + (if + (= actual expected) + (set! dream-co-pass (+ dream-co-pass 1)) + (begin + (set! dream-co-fail (+ dream-co-fail 1)) + (append! dream-co-fails {:name name :actual actual :expected expected}))))) + +(define dream-co-h (fn (req) (dream-text "payload"))) +(define dream-co-app (dream-cors dream-co-h)) + +;; ── decoration of normal responses ───────────────────────────────── +(define dream-co-get (dream-co-app (dream-request "GET" "/" {} ""))) +(dream-co-test + "allow-origin star" + (dream-resp-header dream-co-get "access-control-allow-origin") + "*") +(dream-co-test "body preserved" (dream-resp-body dream-co-get) "payload") +(dream-co-test "status preserved" (dream-status dream-co-get) 200) +(dream-co-test + "no credentials by default" + (dream-resp-header dream-co-get "access-control-allow-credentials") + nil) + +;; ── preflight OPTIONS ────────────────────────────────────────────── +(define + dream-co-pre + (dream-co-app (dream-request "OPTIONS" "/" {} ""))) +(dream-co-test "preflight 204" (dream-status dream-co-pre) 204) +(dream-co-test + "preflight origin" + (dream-resp-header dream-co-pre "access-control-allow-origin") + "*") +(dream-co-test + "preflight methods" + (contains? + (dream-resp-header dream-co-pre "access-control-allow-methods") + "POST") + true) +(dream-co-test + "preflight headers" + (dream-resp-header dream-co-pre "access-control-allow-headers") + "Content-Type") +(dream-co-test + "preflight max-age" + (dream-resp-header dream-co-pre "access-control-max-age") + "86400") + +;; ── custom origin ────────────────────────────────────────────────── +(define + dream-co-custom + ((dream-cors-origin "https://app.example.com") dream-co-h)) +(dream-co-test + "custom origin" + (dream-resp-header + (dream-co-custom (dream-request "GET" "/" {} "")) + "access-control-allow-origin") + "https://app.example.com") + +;; ── credentials enabled ──────────────────────────────────────────── +(define + dream-co-cred + ((dream-cors-with (assoc dream-cors-defaults :credentials true)) + dream-co-h)) +(dream-co-test + "credentials header" + (dream-resp-header + (dream-co-cred (dream-request "GET" "/" {} "")) + "access-control-allow-credentials") + "true") + +;; ── composes around a router ─────────────────────────────────────── +(define + dream-co-router + (dream-cors + (dream-router (list (dream-get "/api" (fn (req) (dream-json "{}"))))))) +(dream-co-test + "router cors origin" + (dream-resp-header + (dream-co-router (dream-request "GET" "/api" {} "")) + "access-control-allow-origin") + "*") + +(define dream-co-tests-run! (fn () {:total (+ dream-co-pass dream-co-fail) :passed dream-co-pass :failed dream-co-fail :fails dream-co-fails})) diff --git a/lib/dream/tests/demos.sx b/lib/dream/tests/demos.sx new file mode 100644 index 00000000..4e5c1d23 --- /dev/null +++ b/lib/dream/tests/demos.sx @@ -0,0 +1,198 @@ +;; lib/dream/tests/demos.sx — end-to-end demo apps exercising the full stack. + +(define dream-dm-pass 0) +(define dream-dm-fail 0) +(define dream-dm-fails (list)) + +(define + dream-dm-test + (fn + (name actual expected) + (if + (= actual expected) + (set! dream-dm-pass (+ dream-dm-pass 1)) + (begin + (set! dream-dm-fail (+ dream-dm-fail 1)) + (append! dream-dm-fails {:name name :actual actual :expected expected}))))) + +(define + dream-dm-req + (fn (method target headers) (dream-request method target headers ""))) + +;; ── hello ────────────────────────────────────────────────────────── +(dream-dm-test + "hello root" + (dream-resp-body (dream-hello-app (dream-dm-req "GET" "/" {}))) + "

Hello, World!

") +(dream-dm-test + "hello name" + (dream-resp-body + (dream-hello-app (dream-dm-req "GET" "/hello/Ada" {}))) + "

Hello, Ada!

") +(dream-dm-test + "hello content-type" + (dream-resp-header + (dream-hello-app (dream-dm-req "GET" "/" {})) + "content-type") + "text/html; charset=utf-8") + +;; ── counter (sessions) ───────────────────────────────────────────── +(define dream-dm-cbackend (dream-memory-sessions)) +(define dream-dm-capp (dream-counter-app-with dream-dm-cbackend)) + +(define dream-dm-c1 (dream-dm-capp (dream-dm-req "GET" "/" {}))) +(dream-dm-test + "counter first visit" + (dream-resp-body dream-dm-c1) + "

You have visited this page 1 time(s).

") +(dream-dm-test + "counter sets cookie" + (len (dream-resp-cookies dream-dm-c1)) + 1) +(dream-dm-test + "counter second visit" + (dream-resp-body (dream-dm-capp (dream-dm-req "GET" "/" {:Cookie "dream.session=s1"}))) + "

You have visited this page 2 time(s).

") +(dream-dm-test + "counter third visit" + (dream-resp-body (dream-dm-capp (dream-dm-req "GET" "/" {:Cookie "dream.session=s1"}))) + "

You have visited this page 3 time(s).

") +(define + dream-dm-reset + (dream-dm-capp (dream-dm-req "POST" "/reset" {:Cookie "dream.session=s1"}))) +(dream-dm-test + "counter reset redirects" + (dream-status dream-dm-reset) + 303) +(dream-dm-test + "counter after reset" + (dream-resp-body (dream-dm-capp (dream-dm-req "GET" "/" {:Cookie "dream.session=s1"}))) + "

You have visited this page 1 time(s).

") +(dream-dm-test + "counter distinct session" + (dream-resp-body (dream-dm-capp (dream-dm-req "GET" "/" {}))) + "

You have visited this page 1 time(s).

") + +;; ── chat (websocket rooms) ───────────────────────────────────────── +(define dream-dm-rooms (dream-chat-rooms)) +(define dream-dm-wsB (dream-mock-ws (list))) +(define dream-dm-wsC (dream-mock-ws (list))) +((get dream-dm-rooms :join) "general" dream-dm-wsB) +((get dream-dm-rooms :join) "general" dream-dm-wsC) +(dream-dm-test + "room has two members" + (len ((get dream-dm-rooms :members) "general")) + 2) + +;; client A joins, sends two messages, then disconnects +(define dream-dm-wsA (dream-mock-ws (list "hi" "again"))) +((dream-chat-session dream-dm-rooms "general") dream-dm-wsA) +(dream-dm-test + "B got broadcasts" + (dream-ws-sent dream-dm-wsB) + (list "hi" "again")) +(dream-dm-test + "C got broadcasts" + (dream-ws-sent dream-dm-wsC) + (list "hi" "again")) +(dream-dm-test + "A echoed own messages" + (dream-ws-sent dream-dm-wsA) + (list "hi" "again")) +(dream-dm-test + "A left on disconnect" + (len ((get dream-dm-rooms :members) "general")) + 2) +(dream-dm-test "A closed" (dream-ws-closed? dream-dm-wsA) true) + +;; route produces an upgrade response +(define dream-dm-chat-app (dream-chat-app-with (dream-chat-rooms))) +(dream-dm-test + "chat route upgrades" + (dream-websocket? + (dream-dm-chat-app (dream-dm-req "GET" "/chat/lobby" {}))) + true) +(dream-dm-test + "chat index html" + (dream-resp-body (dream-dm-chat-app (dream-dm-req "GET" "/" {}))) + "

Rooms

") + +;; ── todo (forms + CSRF) ──────────────────────────────────────────── +(define dream-dm-todo-store (dream-todo-store)) +(define dream-dm-todo-backend (dream-memory-sessions)) +(define + dream-dm-todo-app + (dream-todo-app-with dream-dm-todo-store dream-dm-todo-backend "topsecret")) +(define + dream-dm-todo-tok + (dr/csrf-make-token dream-csrf-sign-default "topsecret" "s1")) + +;; establish session s1 +(dream-dm-todo-app (dream-request "GET" "/" {} "")) +(define + dream-dm-add1 + (dream-dm-todo-app + (dream-request + "POST" + "/add" + {:Cookie "dream.session=s1"} + (str "text=Buy+milk&dream.csrf=" dream-dm-todo-tok)))) +(dream-dm-test "todo add redirects" (dream-status dream-dm-add1) 303) +(dream-dm-test + "todo store has item" + (len ((get dream-dm-todo-store :all))) + 1) + +(define + dream-dm-todo-page + (dream-resp-body + (dream-dm-todo-app (dream-request "GET" "/" {:Cookie "dream.session=s1"} "")))) +(dream-dm-test + "todo lists item" + (contains? dream-dm-todo-page "Buy milk") + true) +(dream-dm-test + "todo has csrf tag" + (contains? dream-dm-todo-page "dream.csrf") + true) +(dream-dm-test + "todo item not done" + (contains? dream-dm-todo-page "[ ] Buy milk") + true) + +(dream-dm-todo-app + (dream-request + "POST" + "/toggle/1" + {:Cookie "dream.session=s1"} + (str "dream.csrf=" dream-dm-todo-tok))) +(dream-dm-test + "todo toggled done" + (contains? + (dream-resp-body + (dream-dm-todo-app (dream-request "GET" "/" {:Cookie "dream.session=s1"} ""))) + "[x] Buy milk") + true) + +(dream-dm-test + "todo add without token 403" + (dream-status + (dream-dm-todo-app (dream-request "POST" "/add" {:Cookie "dream.session=s1"} "text=Sneaky"))) + 403) +(dream-dm-test + "todo unchanged after reject" + (len ((get dream-dm-todo-store :all))) + 1) + +(dream-dm-todo-app + (dream-request + "POST" + "/delete/1" + {:Cookie "dream.session=s1"} + (str "dream.csrf=" dream-dm-todo-tok))) +(dream-dm-test + "todo deleted" + (len ((get dream-dm-todo-store :all))) + 0) + +(define dream-dm-tests-run! (fn () {:total (+ dream-dm-pass dream-dm-fail) :passed dream-dm-pass :failed dream-dm-fail :fails dream-dm-fails})) diff --git a/lib/dream/tests/error.sx b/lib/dream/tests/error.sx new file mode 100644 index 00000000..27ad1e7c --- /dev/null +++ b/lib/dream/tests/error.sx @@ -0,0 +1,90 @@ +;; lib/dream/tests/error.sx — status phrases + dream-catch. + +(define dream-er-pass 0) +(define dream-er-fail 0) +(define dream-er-fails (list)) + +(define + dream-er-test + (fn + (name actual expected) + (if + (= actual expected) + (set! dream-er-pass (+ dream-er-pass 1)) + (begin + (set! dream-er-fail (+ dream-er-fail 1)) + (append! dream-er-fails {:name name :actual actual :expected expected}))))) + +;; ── status phrases ───────────────────────────────────────────────── +(dream-er-test "200 OK" (dream-status-text 200) "OK") +(dream-er-test "404 Not Found" (dream-status-text 404) "Not Found") +(dream-er-test + "405 phrase" + (dream-status-text 405) + "Method Not Allowed") +(dream-er-test + "500 phrase" + (dream-status-text 500) + "Internal Server Error") +(dream-er-test "unknown phrase" (dream-status-text 599) "Unknown") +(dream-er-test "status line" (dream-status-line 404) "404 Not Found") +(dream-er-test + "status page status" + (dream-status (dream-status-page 403)) + 403) +(dream-er-test + "status page body" + (dream-resp-body (dream-status-page 403)) + "

403 Forbidden

") + +;; ── dream-catch ──────────────────────────────────────────────────── +(define dream-er-boom (fn (req) (error "kaboom"))) +(define dream-er-ok (fn (req) (dream-text "fine"))) + +(dream-er-test + "catch normal passes through" + (dream-resp-body + ((dream-catch dream-er-ok) (dream-request "GET" "/" {} ""))) + "fine") +(dream-er-test + "catch error -> 500" + (dream-status + ((dream-catch dream-er-boom) (dream-request "GET" "/" {} ""))) + 500) +(dream-er-test + "catch 500 body" + (dream-resp-body + ((dream-catch dream-er-boom) (dream-request "GET" "/" {} ""))) + "

500 Internal Server Error

") + +;; custom error page receives the error +(define + dream-er-custom + (dream-catch-with (fn (req e) (dream-text (str "ERR:" e))))) +(dream-er-test + "custom error page" + (dream-resp-body + ((dream-er-custom dream-er-boom) (dream-request "GET" "/" {} ""))) + "ERR:kaboom") +(dream-er-test + "custom passes normal through" + (dream-resp-body + ((dream-er-custom dream-er-ok) (dream-request "GET" "/" {} ""))) + "fine") + +;; catch composes around a router +(define + dream-er-app + (dream-catch + (dream-router + (list (dream-get "/boom" dream-er-boom) (dream-get "/ok" dream-er-ok))))) +(dream-er-test + "router error caught" + (dream-status (dream-er-app (dream-request "GET" "/boom" {} ""))) + 500) +(dream-er-test + "router ok intact" + (dream-resp-body (dream-er-app (dream-request "GET" "/ok" {} ""))) + "fine") + +(define dream-er-tests-run! (fn () {:total (+ dream-er-pass dream-er-fail) :passed dream-er-pass :failed dream-er-fail :fails dream-er-fails})) diff --git a/lib/dream/tests/flash.sx b/lib/dream/tests/flash.sx new file mode 100644 index 00000000..815f983b --- /dev/null +++ b/lib/dream/tests/flash.sx @@ -0,0 +1,129 @@ +;; lib/dream/tests/flash.sx — codec + read-after-write across requests. + +(define dream-fl-pass 0) +(define dream-fl-fail 0) +(define dream-fl-fails (list)) + +(define + dream-fl-test + (fn + (name actual expected) + (if + (= actual expected) + (set! dream-fl-pass (+ dream-fl-pass 1)) + (begin + (set! dream-fl-fail (+ dream-fl-fail 1)) + (append! dream-fl-fails {:name name :actual actual :expected expected}))))) + +;; ── codec ────────────────────────────────────────────────────────── +(dream-fl-test "encode one" (dr/flash-encode (list {:message "saved" :category "info"})) "info|saved") +(dream-fl-test + "encode two" + (dr/flash-encode (list {:message "a" :category "info"} {:message "b" :category "error"})) + "info|a~error|b") +(dream-fl-test "decode one" (dr/flash-decode "info|saved") (list {:message "saved" :category "info"})) +(dream-fl-test "decode empty" (dr/flash-decode "") (list)) +(dream-fl-test + "roundtrip special chars" + (dr/flash-decode (dr/flash-encode (list {:message "a~b%c" :category "x|y"}))) + (list {:message "a~b%c" :category "x|y"})) +(dream-fl-test "escape pipe" (dr/flash-encode (list {:message "a|b" :category "c"})) "c|a%7Cb") + +;; extract a cookie value from a Set-Cookie string +(define + dream-fl-cookie-val + (fn + (setc) + (let + ((after (substr setc (+ (index-of setc "=") 1)))) + (substr after 0 (index-of after ";"))))) + +;; ── read-after-write across requests ─────────────────────────────── +(define + dream-fl-set-h + (fn + (req) + (begin (dream-add-flash-message req "info" "Saved!") (dream-text "done")))) +(define dream-fl-set-app (dream-flash dream-fl-set-h)) + +;; request 1: add a flash, no incoming -> sets the flash cookie +(define + dream-fl-r1 + (dream-fl-set-app (dream-request "POST" "/save" {} ""))) +(dream-fl-test "writer body" (dream-resp-body dream-fl-r1) "done") +(dream-fl-test + "writer sets flash cookie" + (len (dream-resp-cookies dream-fl-r1)) + 1) +(dream-fl-test + "writer has no incoming" + (dream-flash-messages + (assoc (dream-request "GET" "/" {} "") :dream-flash {:box (dr/flash-box) :incoming (list)})) + (list)) + +;; request 2: carries the flash cookie -> handler reads it, cookie cleared +(define + dream-fl-cval + (dream-fl-cookie-val (first (dream-resp-cookies dream-fl-r1)))) +(define + dream-fl-read-h + (fn + (req) + (let + ((msgs (dream-flash-messages req))) + (dream-text + (if (empty? msgs) "none" (dream-flash-message (first msgs))))))) +(define dream-fl-read-app (dream-flash dream-fl-read-h)) +(define + dream-fl-r2 + (dream-fl-read-app (dream-request "GET" "/" {:Cookie (str "dream.flash=" dream-fl-cval)} ""))) +(dream-fl-test "reader sees message" (dream-resp-body dream-fl-r2) "Saved!") +(dream-fl-test + "reader clears cookie (Max-Age=0)" + (contains? (first (dream-resp-cookies dream-fl-r2)) "Max-Age=0") + true) + +;; request 3: no flash cookie -> nothing to read, no cookie set +(define + dream-fl-r3 + (dream-fl-read-app (dream-request "GET" "/" {} ""))) +(dream-fl-test "no flash -> none" (dream-resp-body dream-fl-r3) "none") +(dream-fl-test + "no flash -> no cookie" + (len (dream-resp-cookies dream-fl-r3)) + 0) + +;; ── multiple categories ──────────────────────────────────────────── +(define + dream-fl-multi-h + (fn + (req) + (begin + (dream-add-flash-message req "info" "i1") + (dream-add-flash-message req "error" "e1") + (dream-add-flash-message req "info" "i2") + (dream-text "ok")))) +(define + dream-fl-multi-r1 + ((dream-flash dream-fl-multi-h) (dream-request "GET" "/" {} ""))) +(define + dream-fl-multi-val + (dream-fl-cookie-val (first (dream-resp-cookies dream-fl-multi-r1)))) +(define + dream-fl-count-h + (fn + (req) + (dream-text + (str + (len (dream-flash-messages req)) + "/" + (len (dream-flash-of req "info")))))) +(define + dream-fl-multi-r2 + ((dream-flash dream-fl-count-h) (dream-request "GET" "/" {:Cookie (str "dream.flash=" dream-fl-multi-val)} ""))) +(dream-fl-test + "multi: all + filtered counts" + (dream-resp-body dream-fl-multi-r2) + "3/2") + +(define dream-fl-tests-run! (fn () {:total (+ dream-fl-pass dream-fl-fail) :passed dream-fl-pass :failed dream-fl-fail :fails dream-fl-fails})) diff --git a/lib/dream/tests/form.sx b/lib/dream/tests/form.sx new file mode 100644 index 00000000..8b1e9eb4 --- /dev/null +++ b/lib/dream/tests/form.sx @@ -0,0 +1,226 @@ +;; lib/dream/tests/form.sx — urlencoded parsing, Ok/Err, CSRF accept/reject, multipart. + +(define dream-fo-pass 0) +(define dream-fo-fail 0) +(define dream-fo-fails (list)) + +(define + dream-fo-test + (fn + (name actual expected) + (if + (= actual expected) + (set! dream-fo-pass (+ dream-fo-pass 1)) + (begin + (set! dream-fo-fail (+ dream-fo-fail 1)) + (append! dream-fo-fails {:name name :actual actual :expected expected}))))) + +;; ── Result ───────────────────────────────────────────────────────── +(dream-fo-test "ok? on ok" (dream-ok? (dream-ok 5)) true) +(dream-fo-test "err? on ok" (dream-err? (dream-ok 5)) false) +(dream-fo-test "ok value" (dream-ok-value (dream-ok {:a 1})) {:a 1}) +(dream-fo-test "err reason" (dream-err-reason (dream-err :bad)) "bad") + +;; ── urlencoded parsing ───────────────────────────────────────────── +(define + dream-fo-req + (fn (body) (dream-request "POST" "/f" {:Content-Type "application/x-www-form-urlencoded"} body))) + +(dream-fo-test + "parse two fields" + (dream-form-fields (dream-fo-req "a=1&b=2")) + {:a "1" :b "2"}) +(dream-fo-test + "url-decoded value" + (dream-form-field (dream-fo-req "name=Ada+Lovelace") "name") + "Ada Lovelace") +(dream-fo-test + "percent decode" + (dream-form-field (dream-fo-req "x=a%20b%21") "x") + "a b!") +(dream-fo-test "empty body" (dream-form-fields (dream-fo-req "")) {}) +(dream-fo-test + "valueless key" + (dream-form-field (dream-fo-req "flag") "flag") + "") +(dream-fo-test + "decoded key" + (dream-form-field (dream-fo-req "first%20name=x") "first name") + "x") + +;; ── CSRF sign + verify ───────────────────────────────────────────── +(dream-fo-test + "sign deterministic" + (= + (dream-csrf-sign-default "secret" "s1") + (dream-csrf-sign-default "secret" "s1")) + true) +(dream-fo-test + "sign secret-sensitive" + (= + (dream-csrf-sign-default "secret" "s1") + (dream-csrf-sign-default "other" "s1")) + false) +(dream-fo-test + "sign session-sensitive" + (= + (dream-csrf-sign-default "secret" "s1") + (dream-csrf-sign-default "secret" "s2")) + false) +(dream-fo-test + "token valid for own session" + (dr/csrf-valid? + dream-csrf-sign-default + "k" + "s1" + (dr/csrf-make-token dream-csrf-sign-default "k" "s1")) + true) +(dream-fo-test + "token invalid for other session" + (dr/csrf-valid? + dream-csrf-sign-default + "k" + "s2" + (dr/csrf-make-token dream-csrf-sign-default "k" "s1")) + false) +(dream-fo-test + "tampered token invalid" + (dr/csrf-valid? dream-csrf-sign-default "k" "s1" "s1.deadbeef") + false) +(dream-fo-test + "empty token invalid" + (dr/csrf-valid? dream-csrf-sign-default "k" "s1" "") + false) +(dream-fo-test + "nil token invalid" + (dr/csrf-valid? dream-csrf-sign-default "k" "s1" nil) + false) + +;; ── full stack: session -> csrf -> handler ───────────────────────── +(define dream-fo-backend (dream-memory-sessions)) +(define dream-fo-sid (dream-fo-backend {:op "session/create"})) ;; s1 + +(define + dream-fo-stack + (fn + (handler) + ((dream-sessions dream-fo-backend) ((dream-csrf "topsecret") handler)))) + +(define + dream-fo-tag-out + (dream-resp-body + ((dream-fo-stack (fn (req) (dream-text (dream-csrf-tag req)))) + (dream-request "GET" "/form" {:Cookie "dream.session=s1"} "")))) +(dream-fo-test + "csrf-tag is hidden input" + (contains? dream-fo-tag-out "type=\"hidden\"") + true) +(dream-fo-test + "csrf-tag names field" + (contains? dream-fo-tag-out "name=\"dream.csrf\"") + true) + +(define + dream-fo-good-token + (dr/csrf-make-token dream-csrf-sign-default "topsecret" "s1")) +(define + dream-fo-submit + (fn + (token) + ((dream-fo-stack (fn (req) (let ((r (dream-form req))) (if (dream-ok? r) (dream-text (str "ok:" (get (dream-ok-value r) "msg"))) (dream-text (str "err:" (dream-err-reason r))))))) + (dream-request + "POST" + "/form" + {:Cookie "dream.session=s1"} + (str "msg=hello&dream.csrf=" token))))) + +(dream-fo-test + "valid csrf -> Ok fields" + (dream-resp-body (dream-fo-submit dream-fo-good-token)) + "ok:hello") +(dream-fo-test + "bad csrf -> Err" + (dream-resp-body (dream-fo-submit "s1.wrong")) + "err:csrf-token-invalid") +(dream-fo-test + "missing csrf -> Err" + (dream-resp-body (dream-fo-submit "")) + "err:csrf-token-invalid") + +;; ── csrf-protect middleware auto-rejects ─────────────────────────── +(define + dream-fo-protected + (fn + (handler) + ((dream-sessions dream-fo-backend) + ((dream-csrf-protect "topsecret") handler)))) +(define dream-fo-ph (dream-fo-protected (fn (req) (dream-text "reached")))) + +(dream-fo-test + "GET passes without token" + (dream-resp-body (dream-fo-ph (dream-request "GET" "/x" {:Cookie "dream.session=s1"} ""))) + "reached") +(dream-fo-test + "POST without token 403" + (dream-status (dream-fo-ph (dream-request "POST" "/x" {:Cookie "dream.session=s1"} ""))) + 403) +(dream-fo-test + "POST with valid token reaches" + (dream-resp-body + (dream-fo-ph + (dream-request + "POST" + "/x" + {:Cookie "dream.session=s1"} + (str "dream.csrf=" dream-fo-good-token)))) + "reached") + +;; ── multipart/form-data ──────────────────────────────────────────── +(define + dream-fo-mp-body + (str + "--B1\r\n" + "Content-Disposition: form-data; name=\"title\"\r\n\r\n" + "Hello\r\n" + "--B1\r\n" + "Content-Disposition: form-data; name=\"file\"; filename=\"a.txt\"\r\nContent-Type: text/plain\r\n\r\n" + "line1\r\nline2\r\n" + "--B1--\r\n")) +(define + dream-fo-mp-req + (dream-request "POST" "/upload" {:Content-Type "multipart/form-data; boundary=B1"} dream-fo-mp-body)) +(define dream-fo-mp (dream-multipart dream-fo-mp-req)) +(dream-fo-test "multipart is Ok" (dream-ok? dream-fo-mp) true) +(define dream-fo-parts (dream-ok-value dream-fo-mp)) +(dream-fo-test "two parts" (len dream-fo-parts) 2) +(dream-fo-test + "field value" + (dream-multipart-field dream-fo-parts "title") + "Hello") +(dream-fo-test + "file part filename" + (get (dream-multipart-file dream-fo-parts "file") :filename) + "a.txt") +(dream-fo-test + "file content-type" + (get (dream-multipart-file dream-fo-parts "file") :content-type) + "text/plain") +(dream-fo-test + "file content keeps inner CRLF" + (get (dream-multipart-file dream-fo-parts "file") :content) + "line1\r\nline2") +(dream-fo-test + "field is not a file" + (get (dream-multipart-file dream-fo-parts "title") :filename) + nil) +(dream-fo-test + "non-multipart is Err" + (dream-err? (dream-multipart (dream-request "POST" "/x" {:Content-Type "text/plain"} "hi"))) + true) +(dream-fo-test + "quoted boundary parsed" + (dream-ok? + (dream-multipart (dream-request "POST" "/u" {:Content-Type "multipart/form-data; boundary=\"B1\""} dream-fo-mp-body))) + true) + +(define dream-fo-tests-run! (fn () {:total (+ dream-fo-pass dream-fo-fail) :passed dream-fo-pass :failed dream-fo-fail :fails dream-fo-fails})) diff --git a/lib/dream/tests/headers.sx b/lib/dream/tests/headers.sx new file mode 100644 index 00000000..cd81ad3f --- /dev/null +++ b/lib/dream/tests/headers.sx @@ -0,0 +1,94 @@ +;; lib/dream/tests/headers.sx — security headers + cache-control. + +(define dream-hd-pass 0) +(define dream-hd-fail 0) +(define dream-hd-fails (list)) + +(define + dream-hd-test + (fn + (name actual expected) + (if + (= actual expected) + (set! dream-hd-pass (+ dream-hd-pass 1)) + (begin + (set! dream-hd-fail (+ dream-hd-fail 1)) + (append! dream-hd-fails {:name name :actual actual :expected expected}))))) + +(define dream-hd-h (fn (req) (dream-text "body"))) +(define dream-hd-req (dream-request "GET" "/" {} "")) + +;; ── security headers ─────────────────────────────────────────────── +(define dream-hd-sec ((dream-security-headers dream-hd-h) dream-hd-req)) +(dream-hd-test + "nosniff" + (dream-resp-header dream-hd-sec "x-content-type-options") + "nosniff") +(dream-hd-test + "frame deny" + (dream-resp-header dream-hd-sec "x-frame-options") + "DENY") +(dream-hd-test + "referrer policy" + (dream-resp-header dream-hd-sec "referrer-policy") + "no-referrer") +(dream-hd-test + "no hsts by default" + (dream-resp-header dream-hd-sec "strict-transport-security") + nil) +(dream-hd-test "body preserved" (dream-resp-body dream-hd-sec) "body") + +(define + dream-hd-hsts + ((dream-security-headers-with (assoc dream-security-defaults :hsts true)) + dream-hd-h)) +(dream-hd-test + "hsts when enabled" + (contains? + (dream-resp-header + (dream-hd-hsts dream-hd-req) + "strict-transport-security") + "max-age=31536000") + true) + +;; ── cache-control ────────────────────────────────────────────────── +(dream-hd-test + "cache public" + (dream-resp-header + (dream-cache (dream-text "x") 60) + "cache-control") + "public, max-age=60") +(dream-hd-test + "private cache" + (dream-resp-header + (dream-private-cache (dream-text "x") 30) + "cache-control") + "private, max-age=30") +(dream-hd-test + "no-store" + (dream-resp-header (dream-no-store (dream-text "x")) "cache-control") + "no-store") +(dream-hd-test + "no-cache" + (dream-resp-header (dream-no-cache (dream-text "x")) "cache-control") + "no-cache, no-store, must-revalidate") + +;; ── cache middleware ─────────────────────────────────────────────── +(define dream-hd-capp ((dream-cache-for 300) dream-hd-h)) +(dream-hd-test + "cache-for stamps" + (dream-resp-header (dream-hd-capp dream-hd-req) "cache-control") + "public, max-age=300") + +;; ── composes around a router ─────────────────────────────────────── +(define + dream-hd-app + (dream-security-headers + (dream-router + (list (dream-get "/" (fn (req) (dream-html "

hi

"))))))) +(dream-hd-test + "router security header" + (dream-resp-header (dream-hd-app dream-hd-req) "x-frame-options") + "DENY") + +(define dream-hd-tests-run! (fn () {:total (+ dream-hd-pass dream-hd-fail) :passed dream-hd-pass :failed dream-hd-fail :fails dream-hd-fails})) diff --git a/lib/dream/tests/html.sx b/lib/dream/tests/html.sx new file mode 100644 index 00000000..bf76b7bb --- /dev/null +++ b/lib/dream/tests/html.sx @@ -0,0 +1,59 @@ +;; lib/dream/tests/html.sx — HTML escaping (+ demo XSS regression). + +(define dream-ht-pass 0) +(define dream-ht-fail 0) +(define dream-ht-fails (list)) + +(define + dream-ht-test + (fn + (name actual expected) + (if + (= actual expected) + (set! dream-ht-pass (+ dream-ht-pass 1)) + (begin + (set! dream-ht-fail (+ dream-ht-fail 1)) + (append! dream-ht-fails {:name name :actual actual :expected expected}))))) + +(dream-ht-test "escape ampersand" (dream-escape "a & b") "a & b") +(dream-ht-test "escape lt gt" (dream-escape "") "<b>") +(dream-ht-test "escape quote" (dream-escape "say \"hi\"") "say "hi"") +(dream-ht-test "escape apostrophe" (dream-escape "it's") "it's") +(dream-ht-test + "escape script tag" + (dream-escape "") + "<script>alert(1)</script>") +(dream-ht-test + "ampersand first (no double-escape)" + (dream-escape "<") + "&lt;") +(dream-ht-test + "safe string unchanged" + (dream-escape "hello world") + "hello world") +(dream-ht-test + "attr escapes value" + (dream-attr "title" "a\"b") + "title=\"a"b\"") +(dream-ht-test + "escape-join" + (dream-escape-join " " (list "" "")) + "<a> <b>") + +;; ── todo demo escapes user input (XSS regression) ────────────────── +(define dream-ht-store (dream-todo-store)) +((get dream-ht-store :add) "") +(define + dream-ht-ctx + (assoc (dream-request "GET" "/" {} "") :dream-csrf {:sign dream-csrf-sign-default :sid "s1" :secret "k"})) +(define dream-ht-rendered (dr/todo-render dream-ht-store dream-ht-ctx)) +(dream-ht-test + "todo escapes script" + (contains? dream-ht-rendered "<script>") + true) +(dream-ht-test + "todo has no raw script" + (contains? dream-ht-rendered "