Line data 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 0 : path_cat(
29 : std::string& result,
30 : core::string_view prefix,
31 : core::string_view suffix)
32 : {
33 0 : result = prefix;
34 :
35 : #ifdef _WIN32
36 : char constexpr path_separator = '\\';
37 : #else
38 0 : char constexpr path_separator = '/';
39 : #endif
40 0 : if(! result.empty() && result.back() == path_separator)
41 0 : result.resize(result.size() - 1);
42 :
43 : #ifdef _WIN32
44 : for(auto& c : result)
45 : if(c == '/')
46 : c = path_separator;
47 : #endif
48 0 : for(auto const& c : suffix)
49 : {
50 0 : if(c == '/')
51 0 : result.push_back(path_separator);
52 : else
53 0 : result.push_back(c);
54 : }
55 0 : }
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 0 : entry_less(
68 : dir_entry const& a,
69 : dir_entry const& b) noexcept
70 : {
71 0 : if(a.is_dir != b.is_dir)
72 0 : return a.is_dir;
73 :
74 : // Case-insensitive compare
75 0 : auto const& an = a.name;
76 0 : auto const& bn = b.name;
77 0 : auto const n = (std::min)(an.size(), bn.size());
78 0 : for(std::size_t i = 0; i < n; ++i)
79 : {
80 0 : auto ac = static_cast<unsigned char>(an[i]);
81 0 : auto bc = static_cast<unsigned char>(bn[i]);
82 0 : if(ac >= 'A' && ac <= 'Z') ac += 32;
83 0 : if(bc >= 'A' && bc <= 'Z') bc += 32;
84 0 : if(ac != bc)
85 0 : return ac < bc;
86 : }
87 0 : return an.size() < bn.size();
88 : }
89 :
90 : std::uint64_t
91 0 : to_epoch(std::filesystem::file_time_type tp)
92 : {
93 : auto const sctp = std::chrono::clock_cast<
94 0 : std::chrono::system_clock>(tp);
95 0 : auto const dur = sctp.time_since_epoch();
96 : return static_cast<std::uint64_t>(
97 : std::chrono::duration_cast<
98 0 : std::chrono::seconds>(dur).count());
99 : }
100 :
101 : std::string
102 0 : format_size(std::uint64_t bytes)
103 : {
104 0 : if(bytes < 1024)
105 0 : return std::to_string(bytes) + " B";
106 0 : if(bytes < 1024 * 1024)
107 0 : return std::to_string(bytes / 1024) + " KB";
108 0 : if(bytes < 1024 * 1024 * 1024)
109 0 : return std::to_string(bytes / (1024 * 1024)) + " MB";
110 0 : return std::to_string(
111 0 : bytes / (1024ULL * 1024 * 1024)) + " GB";
112 : }
113 :
114 : std::string
115 0 : format_time(std::uint64_t epoch)
116 : {
117 0 : if(epoch == 0)
118 0 : return "-";
119 :
120 0 : auto const t = static_cast<std::time_t>(epoch);
121 : std::tm tm;
122 : #ifdef _WIN32
123 : gmtime_s(&tm, &t);
124 : #else
125 0 : gmtime_r(&t, &tm);
126 : #endif
127 : char buf[64];
128 0 : std::strftime(buf, sizeof(buf),
129 : "%Y-%m-%d %H:%M:%S", &tm);
130 0 : return buf;
131 : }
132 :
133 :
134 : std::string
135 0 : render_html(
136 : core::string_view dir,
137 : std::vector<dir_entry> const& entries,
138 : bool show_parent)
139 : {
140 0 : std::string body;
141 0 : body.reserve(4096);
142 :
143 0 : 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 0 : body.append(escape_html(dir));
151 0 : 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 0 : body.append(escape_html(dir));
172 0 : body.append("</h1>\n");
173 :
174 0 : body.append(
175 : "<table>\n"
176 : "<tr><th>Name</th>"
177 : "<th>Size</th>"
178 : "<th>Modified</th></tr>\n");
179 :
180 0 : if(show_parent)
181 : {
182 0 : 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 0 : for(auto const& e : entries)
190 : {
191 0 : auto display_name = escape_html(e.name);
192 0 : auto href = encode_url(e.name);
193 0 : if(e.is_dir)
194 0 : href += '/';
195 :
196 0 : body.append("<tr><td><a href=\"");
197 0 : body.append(href);
198 0 : body.append("\">");
199 0 : body.append(display_name);
200 0 : if(e.is_dir)
201 0 : body.append("/");
202 0 : body.append("</a></td>");
203 0 : body.append("<td class=\"size\">");
204 0 : body.append(e.is_dir ? "-" : format_size(e.size));
205 0 : body.append("</td>");
206 0 : body.append("<td class=\"date\">");
207 0 : body.append(format_time(e.mtime));
208 0 : body.append("</td></tr>\n");
209 0 : }
210 :
211 0 : body.append("</table>\n</body>\n</html>\n");
212 0 : return body;
213 0 : }
214 :
215 : std::string
216 0 : render_json(
217 : std::vector<dir_entry> const& entries)
218 : {
219 0 : std::string body;
220 0 : body.reserve(1024);
221 0 : body.push_back('[');
222 :
223 0 : bool first = true;
224 0 : for(auto const& e : entries)
225 : {
226 0 : if(! first)
227 0 : body.push_back(',');
228 0 : first = false;
229 :
230 0 : body.append("{\"name\":\"");
231 :
232 : // Escape JSON string
233 0 : for(auto c : e.name)
234 : {
235 0 : switch(c)
236 : {
237 0 : case '"': body.append("\\\""); break;
238 0 : case '\\': body.append("\\\\"); break;
239 0 : case '\n': body.append("\\n"); break;
240 0 : case '\r': body.append("\\r"); break;
241 0 : case '\t': body.append("\\t"); break;
242 0 : default: body.push_back(c); break;
243 : }
244 : }
245 :
246 0 : body.append("\",\"type\":\"");
247 0 : body.append(e.is_dir ? "directory" : "file");
248 0 : body.append("\",\"size\":");
249 0 : body.append(std::to_string(e.size));
250 0 : body.append(",\"mtime\":");
251 0 : body.append(std::to_string(e.mtime));
252 0 : body.push_back('}');
253 : }
254 :
255 0 : body.push_back(']');
256 0 : return body;
257 0 : }
258 :
259 : std::string
260 0 : render_plain(
261 : std::vector<dir_entry> const& entries)
262 : {
263 0 : std::string body;
264 0 : body.reserve(1024);
265 0 : for(auto const& e : entries)
266 : {
267 0 : body.append(e.name);
268 0 : if(e.is_dir)
269 0 : body.push_back('/');
270 0 : body.push_back('\n');
271 : }
272 0 : return body;
273 0 : }
274 :
275 : } // (anon)
276 :
277 : //------------------------------------------------
278 :
279 : struct serve_index::impl
280 : {
281 : std::string root;
282 : serve_index::options opts;
283 :
284 0 : impl(
285 : core::string_view root_,
286 : serve_index::options const& opts_)
287 0 : : root(root_)
288 0 : , opts(opts_)
289 : {
290 0 : }
291 : };
292 :
293 0 : serve_index::
294 : ~serve_index()
295 : {
296 0 : delete impl_;
297 0 : }
298 :
299 0 : serve_index::
300 0 : serve_index(core::string_view root)
301 0 : : serve_index(root, options{})
302 : {
303 0 : }
304 :
305 0 : serve_index::
306 : serve_index(
307 : core::string_view root,
308 0 : options const& opts)
309 0 : : impl_(new impl(root, opts))
310 : {
311 0 : }
312 :
313 0 : serve_index::
314 0 : serve_index(serve_index&& other) noexcept
315 0 : : impl_(other.impl_)
316 : {
317 0 : other.impl_ = nullptr;
318 0 : }
319 :
320 : route_task
321 0 : 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 0 : }
442 :
443 : } // http
444 : } // boost
|