Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/is_directory.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#include <sys/stat.h>

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
}
1 change: 1 addition & 0 deletions test/static-fixtures/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
root index
1 change: 1 addition & 0 deletions test/static-fixtures/sub/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sub index
1 change: 1 addition & 0 deletions test/static-fixtures/sub/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
body {}
113 changes: 112 additions & 1 deletion test/web.carp
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
66 changes: 66 additions & 0 deletions web.carp
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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")
Expand Down
Loading