LCOV - code coverage report
Current view: top level - http/server/file_server - static_files.cpp (source / functions) Coverage Total Hit
Test: coverage_filtered.info Lines: 97.3 % 113 110
Test Date: 2026-11-10 10:38:07 Functions: 100.0 % 10 10

            Line data    Source code
       1              : #include "static_files.hpp"
       2              : #include "file_info.hpp"
       3              : #include "file_response.hpp"
       4              : #include "../mime_types.hpp"
       5              : #include "../request.hpp"
       6              : #include "../response.hpp"
       7              : #include "../response_compression.hpp"
       8              : #include "../../util/url.hpp"
       9              : #include <algorithm>
      10              : #include <optional>
      11              : #include <string_view>
      12              : #include <system_error>
      13              : 
      14              : namespace thinger::http::file_server {
      15              : 
      16              : namespace {
      17              : 
      18              :     namespace fs = std::filesystem;
      19              : 
      20              :     // Whether `file` is `directory` or inside it (both canonical), by whole path components:
      21              :     // a sibling sharing its name as a prefix ("/srv/www-secret" next to "/srv/www") is outside
      22          638 :     bool is_within(const fs::path& file, const fs::path& directory) {
      23          638 :         auto relative = file.lexically_relative(directory);
      24         1276 :         return !relative.empty() && *relative.begin() != "..";
      25          638 :     }
      26              : 
      27              :     // A precompressed sibling of a static file: its suffix, and its content coding
      28              :     struct precompressed_coding {
      29              :         const char* suffix;
      30              :         const char* coding;
      31              :     };
      32              :     constexpr precompressed_coding brotli_sibling{".br", "br"};
      33              :     constexpr precompressed_coding gzip_sibling{".gz", "gzip"};
      34              : 
      35              :     // Precompressed siblings a request accepts, in the order to look for them (how many):
      36              :     // br and gzip by the quality its Accept-Encoding gives them, br first at equal quality
      37              :     // (it is smaller); none without Accept-Encoding, nor one with a lower quality than
      38              :     // identity (as response compression)
      39          530 :     size_t accepted_siblings(const http_request& request, precompressed_coding (&siblings)[2]) {
      40          530 :         auto accepted = compression::accepted_by(request);
      41          530 :         bool br = accepted.br > 0 && accepted.br >= accepted.identity;
      42          530 :         bool gzip = accepted.gzip > 0 && accepted.gzip >= accepted.identity;
      43          530 :         bool brotli_first = accepted.br >= accepted.gzip;
      44          530 :         size_t count = 0;
      45          530 :         if (br && brotli_first) siblings[count++] = brotli_sibling;
      46          530 :         if (gzip) siblings[count++] = gzip_sibling;
      47          530 :         if (br && !brotli_first) siblings[count++] = brotli_sibling;
      48          530 :         return count;
      49              :     }
      50              : 
      51              :     // Metadata of a precompressed sibling of `original`, if it can be sent: a regular file
      52              :     // (a symlink only while it resolves inside `root`, as the files) not older than it
      53          162 :     std::optional<file_info> sibling_info(const fs::path& sibling, const fs::path& root, const file_info& original,
      54              :                                           const char* coding) {
      55          162 :         auto info = file_info::of(sibling, false);
      56          162 :         if (info && info->symlink) {
      57            6 :             std::error_code error;
      58            6 :             auto target = fs::canonical(sibling, error);
      59            6 :             info = !error && is_within(target, root) ? file_info::of(target) : std::nullopt;
      60            6 :         }
      61              :         // A sibling older than the file is stale: compressed from a previous version
      62          162 :         if (!info || !info->regular || info->mtime_ns < original.mtime_ns) return std::nullopt;
      63              :         // Its own entity tag, telling the coding (as Apache does): another representation
      64          148 :         info->etag.insert(info->etag.size() - 1, std::string("-") + coding);
      65           74 :         return info;
      66          162 :     }
      67              : 
      68              :     // Send a static `file` (canonical, inside the canonical directory `root`), or its
      69              :     // precompressed sibling if `precompressed` and the request accepts it
      70          536 :     void send_static_file(request& req, response& res, const fs::path& file, const fs::path& root,
      71              :                           bool precompressed) {
      72              :         precompressed_coding siblings[2];
      73          536 :         size_t count = precompressed ? accepted_siblings(*req.get_http_request(), siblings) : 0;
      74              :         // Once answered, send_file() reports it without looking for the file
      75          536 :         if (count == 0 || res.has_responded()) {
      76          402 :             res.send_file(file);
      77          476 :             return;
      78              :         }
      79              : 
      80              :         // One stat for the file (send_file does not repeat it), and one per sibling looked for
      81          134 :         auto original = file_info::of(file);
      82          134 :         if (!original || !original->regular) {
      83            0 :             res.send_file(file);
      84            0 :             return;
      85              :         }
      86          134 :         const auto& content_type = mime_types::extension_to_type(file.extension().string());
      87          222 :         for (size_t i = 0; i < count; ++i) {
      88          162 :             auto sibling = file;
      89          162 :             sibling += siblings[i].suffix;
      90          162 :             if (auto info = sibling_info(sibling, root, *original, siblings[i].coding)) {
      91           74 :                 send(res, {sibling, *info, content_type, siblings[i].coding});
      92           74 :                 return;
      93          162 :             }
      94          162 :         }
      95           60 :         send(res, {file, *original, content_type});
      96          134 :     }
      97              : 
      98              :     // Whether a decoded path has a dotfile segment (as Node's send): longer than one
      99              :     // character and starting with ".", other than ".well-known" (RFC 8615) and ".." (left to
     100              :     // the containment check, as send refuses it before). Segments are split as
     101              :     // std::filesystem splits them.
     102          768 :     bool is_dotfile_path(std::string_view path) {
     103              : #ifdef _WIN32
     104              :         constexpr std::string_view separators = "/\\";
     105              : #else
     106          768 :         constexpr std::string_view separators = "/";
     107              : #endif
     108          768 :         size_t start = 0;
     109         1540 :         while (start < path.size()) {
     110          877 :             auto end = std::min(path.find_first_of(separators, start), path.size());
     111          877 :             auto segment = path.substr(start, end - start);
     112          877 :             if (segment.size() > 1 && segment[0] == '.' && segment != ".." && segment != ".well-known") return true;
     113          772 :             start = end + 1;
     114              :         }
     115          663 :         return false;
     116              :     }
     117              : 
     118              :     // Percent-decodes a raw path parameter in place, with "+" literal: false for a bad escape
     119              :     // or a decoded NUL
     120          801 :     bool decode_path(std::string& path) {
     121          801 :         if (path.find('%') == std::string::npos) return true;
     122          126 :         std::string escaped;
     123          126 :         escaped.reserve(path.size());
     124         2001 :         for (char c : path) {
     125         1875 :             if (c == '+') escaped.append("%2B");
     126         1866 :             else escaped.push_back(c);
     127              :         }
     128          126 :         return util::url::url_decode(escaped, path) && path.find('\0') == std::string::npos;
     129          126 :     }
     130              : 
     131              :     // The fallback file of a directory, if it has one
     132          178 :     std::optional<fs::path> fallback_of(const fs::path& directory, const std::string& fallback) {
     133          178 :         if (fallback.empty()) return std::nullopt;
     134          102 :         auto file = directory / fallback;
     135          102 :         if (!fs::is_regular_file(file)) return std::nullopt;
     136           96 :         return file;
     137          102 :     }
     138              : 
     139          536 :     void send_file(request& req, response& res, const fs::path& file, const fs::path& root,
     140              :                    const static_options& options) {
     141          536 :         if (!options.cache_control.empty()) res.header(header::cache_control, options.cache_control);
     142          536 :         send_static_file(req, res, file, root, options.precompressed);
     143          536 :     }
     144              : 
     145              :     // Answers with the file a decoded path names, or the index of the directory it names: false
     146              :     // if the path has no file, leaving the request unanswered
     147          635 :     bool serve_path(request& req, response& res, const fs::path& directory, const std::string& path,
     148              :                     const fs::path& root, const static_options& options) {
     149          635 :         std::error_code error;
     150          635 :         auto file = fs::weakly_canonical(directory / path, error);
     151          635 :         if (error) {
     152              :             // A name too long to exist is a path without a file (as in Node's send); any other
     153              :             // failure of the file system is one of the server
     154            3 :             if (options.fallthrough && error == std::errc::filename_too_long) return false;
     155            0 :             throw fs::filesystem_error("weakly_canonical", directory / path, error);
     156              :         }
     157          632 :         if (!is_within(file, root)) {
     158          171 :             res.error(http_response::status::forbidden, "Access denied");
     159           57 :             return true;
     160              :         }
     161          575 :         auto status = fs::status(file);
     162          575 :         if (fs::is_regular_file(status)) {
     163          440 :             send_file(req, res, file, root, options);
     164          440 :             return true;
     165              :         }
     166          135 :         if (fs::is_directory(status)) {
     167           29 :             if (auto index = fallback_of(file, options.fallback)) {
     168           20 :                 send_file(req, res, *index, root, options);
     169           20 :                 return true;
     170           29 :             }
     171              :         }
     172          115 :         return false;
     173          635 :     }
     174              : 
     175              : }
     176              : 
     177          801 : bool serve(request& req, response& res, const fs::path& directory, std::string_view raw_path,
     178              :            const static_options& options) {
     179              :     // The path is raw: percent-decoded here as a path, where "+" is literal (as Node's send does),
     180              :     // so escaped first for url_decode, which reads it as a form space. A decoded "/" separates
     181              :     // directories; a NUL could not name a file
     182          801 :     std::string path(raw_path);
     183          801 :     if (!decode_path(path)) {
     184           63 :         res.error(http_response::status::bad_request, "Invalid path");
     185           21 :         return true;
     186              :     }
     187              :     // Dotfiles (".env", ".git/"), judged by the path alone: denied, or ignored as a path
     188              :     // without a file, unless allowed
     189          780 :     bool dotfile = options.dotfiles != dotfiles_policy::allow && is_dotfile_path(path);
     190          780 :     if (dotfile && options.dotfiles == dotfiles_policy::deny) {
     191           81 :         res.error(http_response::status::forbidden, "Access denied");
     192           27 :         return true;
     193              :     }
     194          753 :     auto root = fs::canonical(directory);
     195              : 
     196              :     // An empty path is the root: its fallback, if any
     197          753 :     if (!path.empty() && !dotfile && serve_path(req, res, directory, path, root, options)) return true;
     198              : 
     199              :     // The fallback of the root for the paths without a file (single page applications), which
     200              :     // with fallthrough are for the caller: there it is only the index of the root
     201          236 :     if (!options.fallthrough || path.empty()) {
     202          149 :         if (auto index = fallback_of(root, options.fallback)) {
     203           76 :             send_file(req, res, *index, root, options);
     204           76 :             return true;
     205          149 :         }
     206              :     }
     207          160 :     if (options.fallthrough) return false;
     208          210 :     res.error(http_response::status::not_found, "Not found");
     209           70 :     return true;
     210          801 : }
     211              : 
     212              : } // namespace thinger::http::file_server
        

Generated by: LCOV version 2.0-1