DeterministicESPAsyncWebServer v6.27.1
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
file_serving.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file file_serving.cpp
6 * @brief Filesystem-backed static file serving for DetWebServer (GET/HEAD of an fs::FS path).
7 *
8 * Split out of dwserver.cpp (single-purpose server files). Covers the conditional-GET validators
9 * (ETag / Last-Modified / If-None-Match / If-Modified-Since), byte-range requests (RFC 7233),
10 * pre-compressed .gz variants, and the cross-loop file-send pump that pages a large body out
11 * without truncating or blocking the worker. The shared RFC 1123 date helper (http_rfc1123)
12 * lives here because WEBDAV requires FILE_SERVING, so this TU is its single always-present home.
13 * Behaviour is identical to the pre-split code - a pure move.
14 */
15
16#include "dwserver.h"
17#include "network_drivers/transport/tcp.h" // conn_pool, det_conn_*, TcpConn/ConnState
19#include "server/http_range.h" // http_parse_byte_range (shared with the edge cache)
20#include "shared_primitives/mime.h" // mime_type, DET_MIME_*
21#include <stdio.h> // snprintf, sscanf
22#include <string.h> // strncasecmp, strchr, strstr, strncmp, strnlen
23#include <time.h> // gmtime_r, strftime (RFC 1123 / conditional-GET dates)
24
25// ---------------------------------------------------------------------------
26// File serving
27// ---------------------------------------------------------------------------
28
29#if DETWS_ENABLE_FILE_SERVING
30// HTTP-date helpers (shared by file serving's Last-Modified / If-Modified-Since and
31// WebDAV's getlastmodified / creationdate). WEBDAV requires FILE_SERVING, so this is
32// the single home for both. Format a time_t as an RFC 1123 GMT date; leaves @p out
33// empty when the timestamp is zero/unavailable.
34void http_rfc1123(time_t t, char *out, size_t cap)
35{
36 out[0] = '\0';
37 if (t <= 0)
38 return;
39 struct tm tmv;
40 if (!gmtime_r(&t, &tmv)) // reentrant: never the shared static buffer (worker-safe)
41 return;
42 strftime(out, cap, "%a, %d %b %Y %H:%M:%S GMT", &tmv);
43}
44
45// True if a resource last modified at @p mtime is NOT newer than the client's
46// If-Modified-Since date @p ims (RFC 1123 form), i.e. a conditional GET should
47// answer 304. Parses the date by hand (sscanf, no stdlib) and compares the two
48// broken-down times field by field, so no timegm()/epoch round-trip is needed.
49// Returns false (serve 200) when there is no usable date - mtime is 0 (no clock),
50// @p ims is absent, or it does not parse.
51static bool http_not_modified_since(time_t mtime, const char *ims)
52{
53 if (mtime <= 0 || !ims)
54 return false;
55 char mon[4] = {0};
56 int day = 0;
57 int year = 0;
58 int hh = 0;
59 int mm = 0;
60 int ss = 0;
61 // "Sun, 06 Nov 1994 08:49:37 GMT" - skip the weekday, read the rest.
62 if (sscanf(ims, "%*3s, %d %3s %d %d:%d:%d", &day, mon, &year, &hh, &mm, &ss) != 6)
63 return false;
64 static const char MONTHS[] = "JanFebMarAprMayJunJulAugSepOctNovDec";
65 const char *mp = strstr(MONTHS, mon);
66 // Must align to a 3-char month boundary: a malformed token like "ebM" appears in
67 // the table at a non-multiple-of-3 offset and would otherwise mis-parse as a month.
68 if (!mp || ((mp - MONTHS) % 3) != 0)
69 return false;
70 int imon = (int)(mp - MONTHS) / 3; // 0-based, matches struct tm tm_mon
71
72 struct tm tf;
73 if (!gmtime_r(&mtime, &tf)) // reentrant: never the shared static buffer (worker-safe)
74 return false;
75 // Compare file (tf) vs If-Modified-Since fields, most significant first.
76 int fy = tf.tm_year + 1900;
77 if (fy != year)
78 return fy < year;
79 if (tf.tm_mon != imon)
80 return tf.tm_mon < imon;
81 if (tf.tm_mday != day)
82 return tf.tm_mday < day;
83 if (tf.tm_hour != hh)
84 return tf.tm_hour < hh;
85 if (tf.tm_min != mm)
86 return tf.tm_min < mm;
87 return tf.tm_sec <= ss;
88}
89
90// RFC 9110 13.1.2: If-None-Match comparison. Supports "*" (matches any current
91// representation), a comma-separated list of entity-tags, and weak comparison
92// (an inbound W/"x" matches our strong "x"). @p etag is our tag, quotes included.
93static bool inm_matches(const char *inm, const char *etag)
94{
95 while (*inm == ' ' || *inm == '\t')
96 inm++; // GCOVR_EXCL_LINE http_parser strips leading OWS from header values, so inm never starts with WS
97 if (inm[0] == '*')
98 return true; // "*" matches the existing representation
99 size_t etlen = strnlen(etag, 40);
100 const char *p = inm;
101 while (*p)
102 {
103 while (*p == ' ' || *p == '\t' || *p == ',')
104 p++;
105 if (!*p)
106 break;
107 const char *tag = p;
108 if (tag[0] == 'W' && tag[1] == '/') // weak validator: ignore the W/ prefix
109 tag += 2;
110 if (tag[0] == '"')
111 {
112 const char *end = strchr(tag + 1, '"');
113 if (end && (size_t)(end - tag + 1) == etlen && strncmp(tag, etag, etlen) == 0)
114 return true;
115 }
116 const char *comma = strchr(p, ',');
117 if (!comma)
118 break;
119 p = comma + 1;
120 }
121 return false;
122}
123
124void DetWebServer::serve_file_internal(uint8_t slot_id, bool head, fs::FS &file_sys, const char *fs_path,
125 const char *content_type, const char *content_encoding)
126{
127 fs::File f = file_sys.open(fs_path, "r");
128 if (!f)
129 {
130 send(slot_id, 404, DET_MIME_TEXT_PLAIN, "Not Found");
131 return;
132 }
133
134 if (!det_conn_active(slot_id))
135 {
136 f.close();
137 http_reset(slot_id);
138 return;
139 }
140
141 size_t file_size = f.size();
142
143 bool keep;
144 const char *cl = resp_conn_hdr(slot_id, &keep);
145
146 // Optional Content-Encoding line (e.g. gzip for pre-compressed assets).
147 char enc_line[40];
148 enc_line[0] = '\0';
149 if (content_encoding)
150 snprintf(enc_line, sizeof(enc_line), "Content-Encoding: %s\r\n", content_encoding);
151
152#if DETWS_ENABLE_ETAG
153 // Conditional GET. Strong validator (ETag) from size + mtime; plus a
154 // Last-Modified date validator. A conditional request answers 304 when either
155 // the client's If-None-Match matches the ETag, or - per RFC 9110, only when no
156 // If-None-Match is present - its If-Modified-Since is not older than the file.
157 time_t mtime = f.getLastWrite();
158 char etag[40];
159 snprintf(etag, sizeof(etag), "\"%x-%lx\"", (unsigned)file_size, (unsigned long)mtime);
160
161 char lm_date[40];
162 char lastmod_line[17 + sizeof(lm_date)]; // "Last-Modified: " + date + "\r\n" + NUL
163 lastmod_line[0] = '\0';
164 http_rfc1123(mtime, lm_date, sizeof(lm_date));
165 if (lm_date[0])
166 snprintf(lastmod_line, sizeof(lastmod_line), "Last-Modified: %s\r\n", lm_date);
167
168 const char *inm = http_get_header(&http_pool[slot_id], "If-None-Match");
169 bool not_modified = inm ? inm_matches(inm, etag)
170 : http_not_modified_since(mtime, http_get_header(&http_pool[slot_id], "If-Modified-Since"));
171 if (not_modified)
172 {
173 f.close();
174 char h304[RESP_HDR_BUF_SIZE];
175 int n304 = snprintf(h304, sizeof(h304), "HTTP/1.1 304 Not Modified\r\nETag: %s\r\n%s%s%s%s\r\n", etag,
176 lastmod_line, _cache_control_buf, _cors_enabled ? _cors_header_buf : "", cl);
177 det_conn_send_flush(slot_id, h304, (u16_t)n304); // 304s are frequent (cache revalidation): one marshal
178 resp_end(slot_id, 304, 0, keep, /*pre_flushed=*/true);
179 return;
180 }
181 char etag_line[48];
182 snprintf(etag_line, sizeof(etag_line), "ETag: %s\r\n", etag);
183#else
184 const char *etag_line = "";
185 const char *lastmod_line = "";
186#endif
187
188 // Default: full 200 response covering the whole file.
189 int status = 200;
190 size_t body_len = file_size;
191 size_t body_off = 0; // file offset the body starts at (nonzero for a Range)
192 const char *accept_ranges = "";
193 char range_line[64];
194 range_line[0] = '\0';
195
196#if DETWS_ENABLE_RANGE
197 accept_ranges = "Accept-Ranges: bytes\r\n"; // advertise range support on every file response
198 size_t r_start = 0;
199 size_t r_end = 0;
200 int rr = http_parse_byte_range(http_get_header(&http_pool[slot_id], "Range"), file_size, &r_start, &r_end);
201 if (rr < 0)
202 {
203 // Unsatisfiable range -> 416 with Content-Range: bytes */<size>.
204 f.close();
205 char h416[RESP_HDR_BUF_SIZE];
206 int n416 = snprintf(h416, sizeof(h416),
207 "HTTP/1.1 416 Range Not Satisfiable\r\n"
208 "Content-Range: bytes */%u\r\n"
209 "Content-Length: 0\r\n"
210 "%s%s\r\n",
211 (unsigned)file_size, _cors_enabled ? _cors_header_buf : "", cl);
212 det_conn_send_flush(slot_id, h416, (u16_t)n416);
213 resp_end(slot_id, 416, 0, keep, /*pre_flushed=*/true);
214 return;
215 }
216 if (rr > 0)
217 {
218 status = 206;
219 body_len = r_end - r_start + 1;
220 snprintf(range_line, sizeof(range_line), "Content-Range: bytes %u-%u/%u\r\n", (unsigned)r_start,
221 (unsigned)r_end, (unsigned)file_size);
222 f.seek((uint32_t)r_start);
223 body_off = r_start;
224 }
225#endif
226
227 char header[RESP_HDR_BUF_SIZE];
228 int hlen =
229 snprintf(header, sizeof(header),
230 "HTTP/1.1 %d %s\r\n"
231 "Content-Type: %s\r\n"
232 "Content-Length: %u\r\n"
233 "%s%s%s%s%s%s%s"
234 "%s\r\n",
235 status, status_text(status), content_type, (unsigned)body_len, accept_ranges, range_line, enc_line,
236 etag_line, lastmod_line, _cache_control_buf, _cors_enabled ? _cors_header_buf : "", cl);
237
238 det_conn_send(slot_id, header, (u16_t)hlen);
239
240 // HEAD or empty body: headers only, finish now.
241 if (head || body_len == 0)
242 {
243 f.close();
244 resp_end(slot_id, status, 0, keep);
245 return;
246 }
247
248 // Hand the body to the cross-loop pump: it pages out at most one send-buffer
249 // window now and resumes on later loops as the window drains, so a file larger
250 // than TCP_SND_BUF is never truncated. The pump owns the file and calls
251 // resp_end() at completion - do not close f or end the response here.
252 FileSend &s = s_send.file[slot_id];
253 s.file = f; // shared handle on ARDUINO; the local f going out of scope keeps it open
254 s.off = body_off;
255 s.remaining = body_len;
256 s.status = status;
257 s.total = (int)body_len;
258 s.keep = keep;
259 s.active = true;
260 file_send_pump(slot_id);
261}
262
263// Page out a pending file response across worker loops: send up to det_conn_sndbuf()
264// bytes now and return; the next loop resumes (woken by the sent callback) until the
265// whole body has been queued, then finish the response. Bounded per loop, never
266// truncates, never blocks the worker.
267void DetWebServer::file_send_pump(uint8_t slot_id)
268{
269 FileSend &s = s_send.file[slot_id];
270 if (!s.active)
271 return;
272
273 if (!det_conn_active(slot_id))
274 {
275 // Connection went away mid-transfer: drop the source and the continuation.
276 s.file.close();
277 s.active = false;
278 return;
279 }
280
281 // A file body still being paged out is active, not idle: keep the CONN_TIMEOUT_MS idle sweep
282 // off it so a transient send stall on a large file cannot reap the slot mid-transfer.
283 det_conn_touch_active(slot_id);
284
285 uint8_t chunk[FILE_CHUNK_SIZE];
286 while (s.remaining > 0)
287 {
288 u16_t avail = det_conn_sndbuf(slot_id);
289 if (avail == 0)
290 {
291 det_conn_flush(slot_id); // push what is queued; resume on a later loop
292 return;
293 }
294 size_t want = s.remaining < sizeof(chunk) ? s.remaining : sizeof(chunk);
295 if (want > avail)
296 want = avail;
297 size_t n = s.file.read(chunk, want);
298 if (n == 0)
299 {
300 s.remaining = 0; // read error / short file: stop (response will be short)
301 break;
302 }
303 if (!det_conn_send(slot_id, chunk, (u16_t)n))
304 {
305 s.file.seek((uint32_t)s.off); // un-read the bytes that did not go out; retry next loop
306 det_conn_flush(slot_id);
307 return;
308 }
309 s.off += n;
310 s.remaining -= n;
311 }
312
313 // Whole body queued: finish the response (flush, keep-alive/close, log, reset).
314 s.file.close();
315 s.active = false;
316 det_conn_flush(slot_id);
317 resp_end(slot_id, s.status, s.total, s.keep);
318}
319
320void DetWebServer::serve_file(uint8_t slot_id, fs::FS &file_sys, const char *fs_path, const char *content_type)
321{
322 serve_file_internal(slot_id, req_is_head(slot_id), file_sys, fs_path, content_type, nullptr);
323}
324
325void DetWebServer::serve_static(const char *url_prefix, fs::FS &file_sys, const char *fs_root)
326{
327 if (_route_count >= MAX_ROUTES)
328 return;
329 Route *r = &_routes[_route_count++];
330
331 // Store the pattern as a wildcard so path_matches() does a prefix match.
332 char pat[MAX_PATH_LEN];
333 size_t n = strnlen(url_prefix, MAX_PATH_LEN);
334 if (n > 0 && url_prefix[n - 1] == '*')
335 snprintf(pat, sizeof(pat), "%s", url_prefix); // already a wildcard
336 else
337 snprintf(pat, sizeof(pat), "%s*", url_prefix); // append the wildcard
338 fill_route_base(r, pat);
339 r->type = RouteType::ROUTE_STATIC;
341 r->static_fs = &file_sys;
342 r->static_root = fs_root;
343}
344
345void DetWebServer::serve_static_request(uint8_t slot_id, HttpReq *req, const Route *r)
346{
347 if (!r->static_fs)
348 {
349 send(slot_id, 404, DET_MIME_TEXT_PLAIN, "Not Found");
350 return;
351 }
352
353 // Request path beyond the mount prefix (route path minus its trailing '*').
354 size_t plen = strnlen(r->path, MAX_PATH_LEN);
355 if (plen > 0 && r->path[plen - 1] == '*')
356 plen--;
357 const char *sub = (strnlen(req->path, MAX_PATH_LEN) >= plen) ? req->path + plen : "";
358
359 // Reject path traversal before touching the filesystem.
360 if (strstr(sub, ".."))
361 {
362 send(slot_id, 404, DET_MIME_TEXT_PLAIN, "Not Found");
363 return;
364 }
365
366 const char *root = r->static_root ? r->static_root : "";
367 size_t rlen = strnlen(root, MAX_PATH_LEN);
368 bool root_slash = (rlen > 0 && root[rlen - 1] == '/');
369 if (root_slash && sub[0] == '/') // avoid a doubled separator
370 sub++;
371 bool sub_slash = (sub[0] == '/');
372 const char *sep = (root_slash || sub_slash) ? "" : "/";
373
374 // Directory or bare-prefix request → index.html.
375 size_t slen = strnlen(sub, MAX_PATH_LEN);
376 bool dir = (slen == 0) || (sub[slen - 1] == '/');
377
378 char fs_path[256];
379 int wn = dir ? snprintf(fs_path, sizeof(fs_path), "%s%s%sindex.html", root, sep, sub)
380 : snprintf(fs_path, sizeof(fs_path), "%s%s%s", root, sep, sub);
381 if (wn <= 0 || wn >= (int)sizeof(fs_path))
382 {
383 send(slot_id, 404, DET_MIME_TEXT_PLAIN, "Not Found");
384 return;
385 }
386
387 const char *ctype = mime_type(fs_path);
388 bool head = req_is_head(slot_id);
389
390 // Pre-compressed variant: serve <path>.gz if the client accepts gzip and it
391 // exists. Content-Type stays that of the original (uncompressed) resource.
392 const char *ae = http_get_header(req, "Accept-Encoding");
393 if (ae && strstr(ae, "gzip"))
394 {
395 char gz[260];
396 int gn = snprintf(gz, sizeof(gz), "%s.gz", fs_path);
397 if (gn > 0 && gn < (int)sizeof(gz) && r->static_fs->exists(gz))
398 {
399 serve_file_internal(slot_id, head, *r->static_fs, gz, ctype, "gzip");
400 return;
401 }
402 }
403
404 serve_file_internal(slot_id, head, *r->static_fs, fs_path, ctype, nullptr);
405}
406#endif // DETWS_ENABLE_FILE_SERVING
#define RESP_HDR_BUF_SIZE
Stack buffer for HTTP response header lines in send() / send_empty() / send_unauth() / serve_file().
#define FILE_CHUNK_SIZE
Bytes read from the filesystem and passed to tcp_write() per loop().
#define MAX_PATH_LEN
Maximum URL path length (including leading /).
#define MAX_ROUTES
Maximum simultaneously registered routes.
void send(uint8_t slot_id, int code, const char *content_type, const char *payload)
Send an HTTP response with a body and close the connection.
static const char * mime_type(const char *path)
Guess a Content-Type from a path's file extension.
Definition response.cpp:317
void fill_route_base(Route *r, const char *path)
Register a route in the route table.
Definition dwserver.cpp:747
const char * status_text(int code)
Convert an HTTP status code to its standard reason phrase.
Definition dwserver.cpp:112
SendCtx s_send
Definition dwserver.cpp:97
bool req_is_head(uint8_t slot_id)
True if the request in slot slot_id used the HEAD method (send headers, no body).
Layer 7 (Application) - public HTTP routing API.
@ HTTP_GET
Safe, idempotent read.
Library-private declarations shared between dwserver.cpp and the src/server/*.cpp request-handler tra...
void http_rfc1123(time_t t, char *out, size_t cap)
Format t as an RFC 1123 GMT date into out (cap bytes); out is emptied for t <= 0.
const char * http_get_header(const HttpReq *req, const char *key)
Look up a header value by name (case-insensitive).
HttpReq http_pool[CONN_POOL_SLOTS]
Pool of parser contexts, one per connection-pool slot (incl. reserved dispatch slots).
Shared single-range Range: bytes=... parser (RFC 7233), used by static file serving and the edge cach...
Shared HTTP Content-Type ("MIME") string constants (one source of truth).
void http_reset(uint8_t slot_id)
Reset the HTTP parser for a connection slot.
Fully-parsed HTTP/1.1 request.
char path[MAX_PATH_LEN]
URL path, null-terminated; no query string.
Internal route entry stored in the routing table.
Definition dwserver.h:244
RouteType type
HTTP, WS, or SSE.
Definition dwserver.h:246
HttpMethod method
HTTP method (RouteType::ROUTE_HTTP only).
Definition dwserver.h:247
char path[MAX_PATH_LEN]
Null-terminated path pattern.
Definition dwserver.h:245
bool det_conn_send(uint8_t slot, const void *data, u16_t len)
Send len bytes on connection slot (copies data; TLS-aware).
Definition tcp.cpp:362
void det_conn_flush(uint8_t slot)
Flush queued bytes / finish the send on slot (TLS-aware).
Definition tcp.cpp:413
u16_t det_conn_sndbuf(uint8_t slot)
Bytes that can currently be queued for sending on slot.
Definition tcp.cpp:398
bool det_conn_send_flush(uint8_t slot, const void *data, u16_t len)
Send len bytes on slot and flush in a single tcpip_thread round-trip.
Definition tcp.cpp:378
void det_conn_touch_active(uint8_t slot_id)
Refresh slot's idle-timeout timestamp while a response body is in flight.
Definition tcp.cpp:718
Layer 4 (Transport) - TCP connection pool, ring buffers, and lwIP integration.