diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e80f24..29553d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ `CORS.set-expose-headers!` control additional headers. A `(cors ...)` form in `defserver` registers both hooks automatically. +- **`StaticFile` module for serving static files.** `StaticFile.handler` + and `StaticFile.mount` serve files from a directory with directory index + support (index.html by default, configurable via `handler-with`), path + traversal prevention, and zero-copy transfer via `Response.sendfile`. + ## 0.6.0 (2026-06-24) ### Added diff --git a/src/is_directory.h b/src/is_directory.h new file mode 100644 index 0000000..8642c8f --- /dev/null +++ b/src/is_directory.h @@ -0,0 +1,13 @@ +#include + +bool web_is_directory(String *path) { +#ifdef _WIN32 + struct _stat64 st; + if (_stat64(*path, &st) == -1) return false; + return (st.st_mode & _S_IFDIR) != 0; +#else + struct stat st; + if (stat(*path, &st) == -1) return false; + return S_ISDIR(st.st_mode); +#endif +} diff --git a/test/static-fixtures/index.html b/test/static-fixtures/index.html new file mode 100644 index 0000000..5b417d6 --- /dev/null +++ b/test/static-fixtures/index.html @@ -0,0 +1 @@ +root index diff --git a/test/static-fixtures/sub/index.html b/test/static-fixtures/sub/index.html new file mode 100644 index 0000000..e7bfae7 --- /dev/null +++ b/test/static-fixtures/sub/index.html @@ -0,0 +1 @@ +sub index diff --git a/test/static-fixtures/sub/style.css b/test/static-fixtures/sub/style.css new file mode 100644 index 0000000..208d16d --- /dev/null +++ b/test/static-fixtures/sub/style.css @@ -0,0 +1 @@ +body {} diff --git a/test/web.carp b/test/web.carp index a462a98..ec29e2d 100644 --- a/test/web.carp +++ b/test/web.carp @@ -784,4 +784,115 @@ &ah &(String.to-bytes "INVALID\r\n\r\n"))] @(Response.code (Pair.a &pair))) - "build-response returns 400 for garbage input")) + "build-response returns 400 for garbage input") + + ; -- StaticFile.exists? -- + (assert-true test + (StaticFile.exists? "test/static-fixtures/index.html") + "exists? finds an existing file") + + (assert-false test + (StaticFile.exists? "test/static-fixtures/nonexistent.xyz") + "exists? returns false for missing file") + + (assert-false test + (StaticFile.exists? "test/static-fixtures/sub") + "exists? returns false for a directory") + + ; -- StaticFile.find-index -- + (assert-true test + (Maybe.just? + &(StaticFile.find-index "test/static-fixtures" &[@"index.html"])) + "find-index finds index.html in root") + + (assert-true test + (Maybe.just? + &(StaticFile.find-index "test/static-fixtures/sub" &[@"index.html"])) + "find-index finds index.html in subdirectory") + + (assert-true test + (Maybe.nothing? + &(StaticFile.find-index "test/static-fixtures" &[@"default.htm"])) + "find-index returns Nothing when no index matches") + + (assert-true test + (Maybe.just? + &(StaticFile.find-index "test/static-fixtures" + &[@"missing.htm" @"index.html"])) + "find-index tries files in order and finds second") + + ; -- StaticFile.serve: path traversal -- + (assert-equal test + 404 + @(Response.code + &(StaticFile.serve "test/static-fixtures" "../web.carp" &[@"index.html"])) + "serve rejects .. traversal") + + (assert-equal test + 404 + @(Response.code + &(StaticFile.serve "test/static-fixtures" + "sub/../../web.carp" + &[@"index.html"])) + "serve rejects nested .. traversal") + + ; -- StaticFile.serve: file serving -- + (assert-equal test + 200 + @(Response.code + &(StaticFile.serve "test/static-fixtures" "sub/style.css" &[@"index.html"])) + "serve returns 200 for existing file") + + (assert-true test + (let [resp (StaticFile.serve "test/static-fixtures" + "sub/style.css" + &[@"index.html"]) + ct (Map.get-with-default (Response.headers &resp) + "Content-Type" + &[@""])] + (= (Array.unsafe-first &ct) "text/css; charset=utf-8")) + "serve sets correct Content-Type for .css") + + (assert-true test + (Map.contains? + (Response.headers + &(StaticFile.serve "test/static-fixtures" + "sub/style.css" + &[@"index.html"])) + "X-Sendfile") + "serve uses X-Sendfile header") + + ; -- StaticFile.serve: index files -- + (assert-equal test + 200 + @(Response.code + &(StaticFile.serve "test/static-fixtures" "" &[@"index.html"])) + "serve finds index.html for empty path") + + (assert-equal test + 200 + @(Response.code + &(StaticFile.serve "test/static-fixtures" "sub/" &[@"index.html"])) + "serve finds index.html for trailing-slash path") + + (assert-equal test + 200 + @(Response.code + &(StaticFile.serve "test/static-fixtures" "sub" &[@"index.html"])) + "serve tries index files when path is a directory") + + ; -- StaticFile.serve: 404 -- + (assert-equal test + 404 + @(Response.code + &(StaticFile.serve "test/static-fixtures" + "nonexistent.txt" + &[@"index.html"])) + "serve returns 404 for missing file") + + ; -- StaticFile.mount -- + (assert-equal test + 1 + (let [app (-> (App.create) (StaticFile.mount @"/assets" @"./public"))] + (Array.length (App.routes &app))) + "mount adds one route to app")) diff --git a/web.carp b/web.carp index 4a22c68..b0bf184 100644 --- a/web.carp +++ b/web.carp @@ -245,6 +245,11 @@ returns whatever `f` returns. If the key is missing, returns `default`.") (hidden web-fstat-mtime) (register web-fstat-mtime (Fn [Int] Long) "web_fstat_mtime") +(relative-include "src/is_directory.h") +(private web-is-directory?) +(hidden web-is-directory?) +(register web-is-directory? (Fn [&String] Bool) "web_is_directory") + (defmodule Response (doc text "creates a 200 OK response with a plain text body.") (defn text [body] @@ -2272,6 +2277,67 @@ Or via [`defserver`](#defserver): (ignore (System.wait (Pointer.address &status))))) (IO.println &(fmt "All %d workers exited" spawned)))))))) +(doc StaticFile "provides static file serving with directory index support, +path traversal prevention, and efficient transfer via sendfile(2).") +(defmodule StaticFile + (doc exists? "checks whether path exists and is a readable regular file.") + (defn exists? [path] + (if (web-is-directory? path) + false + (match (File.open-with path "r") + (Result.Error _) false + (Result.Success f) (do (File.close f) true)))) + + (doc find-index "returns the full path of the first existing index file in +dir, or Nothing if none match.") + (defn find-index [dir index-files] + (let-do [result (the (Maybe String) (Maybe.Nothing))] + (for [i 0 (Array.length index-files)] + (let [candidate (fmt "%s/%s" dir (Array.unsafe-nth index-files i))] + (when-do (exists? &candidate) + (set! result (Maybe.Just candidate)) + (break)))) + result)) + + (doc serve "serves a static file from root for the relative path rel. +rejects .. segments and tries index-files for directory paths. +returns a 404 if the path is unsafe or no file matches.") + (defn serve [root rel index-files] + (if (not (web-safe-path? rel)) + (Response.not-found) + (let [path (if (String.empty? rel) @root (fmt "%s/%s" root rel))] + (if (or (String.empty? rel) (String.ends-with? rel "/")) + (let [dir (if (String.ends-with? &path "/") + (String.prefix &path (- (String.length &path) 1)) + @&path)] + (match (find-index &dir index-files) + (Maybe.Just idx-path) (Response.sendfile &idx-path) + (Maybe.Nothing) (Response.not-found))) + (if (exists? &path) + (Response.sendfile &path) + (match (find-index &path index-files) + (Maybe.Just idx-path) (Response.sendfile &idx-path) + (Maybe.Nothing) (Response.not-found))))))) + + (doc handler "creates a request handler that serves static files from root, +using index.html for directory paths. register with a * glob route.") + (defn handler [root] + (let [idx [@"index.html"]] + (fn [req params] + (let [rel (Map.get-with-default params "*" &@"")] + (StaticFile.serve &root &rel &idx))))) + + (doc handler-with "like handler but with custom index file names.") + (defn handler-with [root index-files] + (fn [req params] + (let [rel (Map.get-with-default params "*" &@"")] + (StaticFile.serve &root &rel &index-files)))) + + (doc mount "registers a GET route at prefix/* that serves static files +from root.") + (defn mount [app prefix root] + (App.GET app (String.append &prefix "/*") (handler root)))) + (defmodule CORS (def origin @"*") (def methods @"GET, POST, PUT, DELETE, PATCH, OPTIONS")