libs/http/src/server/serve_index.cpp

0.0% Lines (0/137) 0.0% Functions (0/14) 0.0% Branches (0/137)
libs/http/src/server/serve_index.cpp
Line Hits Source Code
1 //
2 // Copyright (c) 2025 Vinnie Falco (vinnie.falco@gmail.com)
3 //
4 // Distributed under the Boost Software License, Version 1.0. (See accompanying
5 // file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
6 //
7 // Official repository: https://github.com/cppalliance/http
8 //
9
10 #include <boost/http/server/serve_index.hpp>
11 #include <boost/http/server/accepts.hpp>
12 #include <boost/http/server/escape_html.hpp>
13 #include <boost/http/server/encode_url.hpp>
14 #include <boost/http/field.hpp>
15 #include <boost/http/status.hpp>
16 #include <chrono>
17 #include <filesystem>
18 #include <string>
19 #include <vector>
20
21 namespace boost {
22 namespace http {
23
24 namespace {
25
26 // Append an HTTP rel-path to a local filesystem path.
27 void
28 path_cat(
29 std::string& result,
30 core::string_view prefix,
31 core::string_view suffix)
32 {
33 result = prefix;
34
35 #ifdef _WIN32
36 char constexpr path_separator = '\\';
37 #else
38 char constexpr path_separator = '/';
39 #endif
40 if(! result.empty() && result.back() == path_separator)
41 result.resize(result.size() - 1);
42
43 #ifdef _WIN32
44 for(auto& c : result)
45 if(c == '/')
46 c = path_separator;
47 #endif
48 for(auto const& c : suffix)
49 {
50 if(c == '/')
51 result.push_back(path_separator);
52 else
53 result.push_back(c);
54 }
55 }
56
57 struct dir_entry
58 {
59 std::string name;
60 bool is_dir = false;
61 std::uint64_t size = 0;
62 std::uint64_t mtime = 0;
63 };
64
65 // Directories first, then case-insensitive alphabetical
66 bool
67 entry_less(
68 dir_entry const& a,
69 dir_entry const& b) noexcept
70 {
71 if(a.is_dir != b.is_dir)
72 return a.is_dir;
73
74 // Case-insensitive compare
75 auto const& an = a.name;
76 auto const& bn = b.name;
77 auto const n = (std::min)(an.size(), bn.size());
78 for(std::size_t i = 0; i < n; ++i)
79 {
80 auto ac = static_cast<unsigned char>(an[i]);
81 auto bc = static_cast<unsigned char>(bn[i]);
82 if(ac >= 'A' && ac <= 'Z') ac += 32;
83 if(bc >= 'A' && bc <= 'Z') bc += 32;
84 if(ac != bc)
85 return ac < bc;
86 }
87 return an.size() < bn.size();
88 }
89
90 std::uint64_t
91 to_epoch(std::filesystem::file_time_type tp)
92 {
93 auto const sctp = std::chrono::clock_cast<
94 std::chrono::system_clock>(tp);
95 auto const dur = sctp.time_since_epoch();
96 return static_cast<std::uint64_t>(
97 std::chrono::duration_cast<
98 std::chrono::seconds>(dur).count());
99 }
100
101 std::string
102 format_size(std::uint64_t bytes)
103 {
104 if(bytes < 1024)
105 return std::to_string(bytes) + " B";
106 if(bytes < 1024 * 1024)
107 return std::to_string(bytes / 1024) + " KB";
108 if(bytes < 1024 * 1024 * 1024)
109 return std::to_string(bytes / (1024 * 1024)) + " MB";
110 return std::to_string(
111 bytes / (1024ULL * 1024 * 1024)) + " GB";
112 }
113
114 std::string
115 format_time(std::uint64_t epoch)
116 {
117 if(epoch == 0)
118 return "-";
119
120 auto const t = static_cast<std::time_t>(epoch);
121 std::tm tm;
122 #ifdef _WIN32
123 gmtime_s(&tm, &t);
124 #else
125 gmtime_r(&t, &tm);
126 #endif
127 char buf[64];
128 std::strftime(buf, sizeof(buf),
129 "%Y-%m-%d %H:%M:%S", &tm);
130 return buf;
131 }
132
133
134 std::string
135 render_html(
136 core::string_view dir,
137 std::vector<dir_entry> const& entries,
138 bool show_parent)
139 {
140 std::string body;
141 body.reserve(4096);
142
143 body.append(
144 "<!DOCTYPE html>\n"
145 "<html>\n<head>\n"
146 "<meta charset=\"utf-8\">\n"
147 "<meta name=\"viewport\" "
148 "content=\"width=device-width\">\n"
149 "<title>Index of ");
150 body.append(escape_html(dir));
151 body.append(
152 "</title>\n"
153 "<style>\n"
154 "body { font-family: -apple-system, "
155 "BlinkMacSystemFont, sans-serif; "
156 "margin: 2em; }\n"
157 "h1 { font-size: 1.4em; }\n"
158 "table { border-collapse: collapse; "
159 "width: 100%; max-width: 900px; }\n"
160 "th, td { text-align: left; "
161 "padding: 0.4em 1em; }\n"
162 "th { border-bottom: 2px solid #ddd; }\n"
163 "td { border-bottom: 1px solid #eee; }\n"
164 "a { text-decoration: none; "
165 "color: #0366d6; }\n"
166 "a:hover { text-decoration: underline; }\n"
167 ".size, .date { color: #586069; }\n"
168 "</style>\n"
169 "</head>\n<body>\n"
170 "<h1>Index of ");
171 body.append(escape_html(dir));
172 body.append("</h1>\n");
173
174 body.append(
175 "<table>\n"
176 "<tr><th>Name</th>"
177 "<th>Size</th>"
178 "<th>Modified</th></tr>\n");
179
180 if(show_parent)
181 {
182 body.append(
183 "<tr><td><a href=\"../\">"
184 "..</a></td>"
185 "<td class=\"size\">-</td>"
186 "<td class=\"date\">-</td></tr>\n");
187 }
188
189 for(auto const& e : entries)
190 {
191 auto display_name = escape_html(e.name);
192 auto href = encode_url(e.name);
193 if(e.is_dir)
194 href += '/';
195
196 body.append("<tr><td><a href=\"");
197 body.append(href);
198 body.append("\">");
199 body.append(display_name);
200 if(e.is_dir)
201 body.append("/");
202 body.append("</a></td>");
203 body.append("<td class=\"size\">");
204 body.append(e.is_dir ? "-" : format_size(e.size));
205 body.append("</td>");
206 body.append("<td class=\"date\">");
207 body.append(format_time(e.mtime));
208 body.append("</td></tr>\n");
209 }
210
211 body.append("</table>\n</body>\n</html>\n");
212 return body;
213 }
214
215 std::string
216 render_json(
217 std::vector<dir_entry> const& entries)
218 {
219 std::string body;
220 body.reserve(1024);
221 body.push_back('[');
222
223 bool first = true;
224 for(auto const& e : entries)
225 {
226 if(! first)
227 body.push_back(',');
228 first = false;
229
230 body.append("{\"name\":\"");
231
232 // Escape JSON string
233 for(auto c : e.name)
234 {
235 switch(c)
236 {
237 case '"': body.append("\\\""); break;
238 case '\\': body.append("\\\\"); break;
239 case '\n': body.append("\\n"); break;
240 case '\r': body.append("\\r"); break;
241 case '\t': body.append("\\t"); break;
242 default: body.push_back(c); break;
243 }
244 }
245
246 body.append("\",\"type\":\"");
247 body.append(e.is_dir ? "directory" : "file");
248 body.append("\",\"size\":");
249 body.append(std::to_string(e.size));
250 body.append(",\"mtime\":");
251 body.append(std::to_string(e.mtime));
252 body.push_back('}');
253 }
254
255 body.push_back(']');
256 return body;
257 }
258
259 std::string
260 render_plain(
261 std::vector<dir_entry> const& entries)
262 {
263 std::string body;
264 body.reserve(1024);
265 for(auto const& e : entries)
266 {
267 body.append(e.name);
268 if(e.is_dir)
269 body.push_back('/');
270 body.push_back('\n');
271 }
272 return body;
273 }
274
275 } // (anon)
276
277 //------------------------------------------------
278
279 struct serve_index::impl
280 {
281 std::string root;
282 serve_index::options opts;
283
284 impl(
285 core::string_view root_,
286 serve_index::options const& opts_)
287 : root(root_)
288 , opts(opts_)
289 {
290 }
291 };
292
293 serve_index::
294 ~serve_index()
295 {
296 delete impl_;
297 }
298
299 serve_index::
300 serve_index(core::string_view root)
301 : serve_index(root, options{})
302 {
303 }
304
305 serve_index::
306 serve_index(
307 core::string_view root,
308 options const& opts)
309 : impl_(new impl(root, opts))
310 {
311 }
312
313 serve_index::
314 serve_index(serve_index&& other) noexcept
315 : impl_(other.impl_)
316 {
317 other.impl_ = nullptr;
318 }
319
320 route_task
321 serve_index::
322 operator()(route_params& rp) const
323 {
324 // Only handle GET and HEAD
325 if(rp.req.method() != method::get &&
326 rp.req.method() != method::head)
327 {
328 if(impl_->opts.fallthrough)
329 co_return route_next;
330
331 rp.res.set_status(status::method_not_allowed);
332 rp.res.set(field::allow, "GET, HEAD, OPTIONS");
333 auto [ec] = co_await rp.send();
334 if(ec)
335 co_return route_error(ec);
336 co_return route_done;
337 }
338
339 auto req_path = rp.url.path();
340
341 // Build filesystem path
342 std::string path;
343 path_cat(path, impl_->root, req_path);
344
345 // Must be a directory
346 std::error_code fec;
347 auto fs_status = std::filesystem::status(path, fec);
348 if(fec || fs_status.type() !=
349 std::filesystem::file_type::directory)
350 co_return route_next;
351
352 // Redirect if missing trailing slash
353 if(req_path.empty() || req_path.back() != '/')
354 {
355 std::string location(req_path);
356 location += '/';
357 rp.res.set_status(status::moved_permanently);
358 rp.res.set(field::location, location);
359 auto [ec] = co_await rp.send("");
360 if(ec)
361 co_return route_error(ec);
362 co_return route_done;
363 }
364
365 // Read directory entries
366 std::vector<dir_entry> entries;
367 {
368 std::filesystem::directory_iterator it(path, fec);
369 if(fec)
370 co_return route_next;
371
372 for(auto const& de :
373 std::filesystem::directory_iterator(path, fec))
374 {
375 auto name = de.path().filename().string();
376
377 // Skip hidden files unless configured
378 if(! impl_->opts.hidden &&
379 ! name.empty() && name[0] == '.')
380 continue;
381
382 dir_entry e;
383 e.name = std::move(name);
384
385 std::error_code sec;
386 e.is_dir = de.is_directory(sec);
387 if(! e.is_dir)
388 e.size = de.file_size(sec);
389 auto lwt = de.last_write_time(sec);
390 if(! sec)
391 e.mtime = to_epoch(lwt);
392
393 entries.push_back(std::move(e));
394 }
395 }
396
397 std::sort(entries.begin(), entries.end(), entry_less);
398
399 // Determine ".." display
400 std::filesystem::path root_canonical(impl_->root);
401 std::filesystem::path dir_canonical(path);
402 {
403 std::error_code ec2;
404 root_canonical =
405 std::filesystem::canonical(root_canonical, ec2);
406 dir_canonical =
407 std::filesystem::canonical(dir_canonical, ec2);
408 }
409 bool show_up = impl_->opts.show_parent &&
410 dir_canonical != root_canonical;
411
412 // Content negotiation
413 accepts ac( rp.req );
414 auto type = ac.type({ "html", "json", "text" });
415
416 std::string body;
417 std::string_view content_type;
418 if( type == "json" )
419 {
420 body = render_json(entries);
421 content_type = "application/json; charset=utf-8";
422 }
423 else if( type == "text" )
424 {
425 body = render_plain(entries);
426 content_type = "text/plain; charset=utf-8";
427 }
428 else
429 {
430 body = render_html(req_path, entries, show_up);
431 content_type = "text/html; charset=utf-8";
432 }
433
434 rp.res.set(field::content_type, content_type);
435 rp.res.set("X-Content-Type-Options", "nosniff");
436
437 auto [ec] = co_await rp.send(body);
438 if(ec)
439 co_return route_error(ec);
440 co_return route_done;
441 }
442
443 } // http
444 } // boost
445