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

            Line data    Source code
       1              : #include "file_response.hpp"
       2              : #include "../response.hpp"
       3              : #include "../server_connection.hpp"
       4              : #include "../detail/spawn_handler.hpp"
       5              : #include "../../common/http_request.hpp"
       6              : #include "../../common/syntax.hpp"
       7              : #include "../response_compression.hpp"
       8              : #include "../../../asio/sockets/socket.hpp"
       9              : #include <algorithm>
      10              : #include <cctype>
      11              : #include <fstream>
      12              : #include <optional>
      13              : 
      14              : namespace thinger::http::file_server {
      15              : 
      16              : namespace {
      17              : 
      18              :     // Bodies longer than this are streamed, a piece at a time at the pace of the client,
      19              :     // instead of read whole: what a response holds does not grow with the file (nginx and
      20              :     // Node's send, behind express.static, never read a file whole either)
      21              :     constexpr uint64_t stream_threshold = 1024 * 1024;
      22              : 
      23              :     // The most bytes of a streamed body read and written at once: the pieces of Node's file
      24              :     // streams (their default highWaterMark)
      25              :     constexpr uint64_t stream_piece = 64 * 1024;
      26              : 
      27              :     // Files longer than this are not compressed on the fly, which needs their body whole:
      28              :     // they are sent as they are (or a precompressed sibling of them)
      29              :     constexpr uint64_t compression_cap = 4 * 1024 * 1024;
      30              : 
      31              :     // The media type the file is sent with: the Content-Type a handler or a middleware set
      32              :     // before, as Node's send respects it, else the one of the file
      33         1113 :     const std::string& sent_content_type(const http_response& message, const representation& file) {
      34         1113 :         const auto& content_type = message.get_content_type();
      35         1113 :         return content_type.empty() ? file.content_type : content_type;
      36              :     }
      37              : 
      38              :     // Whether the file is compressed on the fly for the clients accepting it (see
      39              :     // compression::compressible), given the headers of `message`
      40          611 :     bool compressed_on_the_fly(const http_response& message, const representation& file) {
      41         1160 :         return file.encoding.empty() && file.info.size <= compression_cap &&
      42         1160 :                compression::compressible(message, file.info.size, sent_content_type(message, file));
      43              :     }
      44              : 
      45              :     // Headers of the 200 and 304 answers alike: the validators, and how to cache them.
      46              :     // Without a Cache-Control of the handler, caches store the file but revalidate it on
      47              :     // every use (else they could reuse it for a while, guessing from Last-Modified).
      48          640 :     void add_validators(http_response& message, const representation& file) {
      49          844 :         if (!message.has_header(header::cache_control)) message.add_header(header::cache_control, "no-cache");
      50         1920 :         message.set_header("ETag", file.info.etag);
      51         1920 :         message.set_header("Last-Modified", file.info.last_modified);
      52              :         // A precompressed file is sent depending on Accept-Encoding, and a text file may be
      53              :         // sent compressed: another representation either way
      54          640 :         if (!file.encoding.empty() || compressed_on_the_fly(message, file)) {
      55          115 :             compression::add_vary(message, "Accept-Encoding");
      56              :         }
      57          640 :     }
      58              : 
      59              :     // The continuation bytes that follow a UTF-8 lead byte (0 for any other byte)
      60           12 :     int utf8_continuation_bytes(unsigned char lead) {
      61           12 :         if (lead >= 0xf0 && lead < 0xf8) return 3;
      62           12 :         if (lead >= 0xe0 && lead < 0xf0) return 2;
      63            9 :         if (lead >= 0xc0 && lead < 0xe0) return 1;
      64            6 :         return 0;
      65              :     }
      66              : 
      67              :     // The name as the value of filename* (RFC 8187), after its "UTF-8''"
      68           12 :     void append_ext_value(std::string& value, std::string_view name) {
      69              :         static constexpr char hex[] = "0123456789ABCDEF";
      70          162 :         for (unsigned char c : name) {
      71              :             // attr-char: a tchar but '*', '\'' and '%'
      72          150 :             if (syntax::is_token_char(c) && c != '*' && c != '\'' && c != '%') {
      73          108 :                 value += static_cast<char>(c);
      74              :             } else {
      75           42 :                 value += '%';
      76           42 :                 value += hex[c >> 4];
      77           42 :                 value += hex[c & 0x0F];
      78              :             }
      79              :         }
      80           12 :     }
      81              : 
      82              :     // Content-Disposition of a download named `name`, as Express's content-disposition
      83              :     // package writes it (RFC 6266): an ASCII filename (each other character, or control byte,
      84              :     // as '?'), and the name in UTF-8 as filename* (RFC 8187) when the ASCII one is not it, or
      85              :     // could be read percent-decoded
      86           24 :     std::string content_disposition(std::string_view name) {
      87            6 :         auto is_hex = [](char c) { return std::isxdigit(static_cast<unsigned char>(c)) != 0; };
      88           24 :         std::string fallback;
      89           24 :         bool extended = false;
      90           24 :         int continuation = 0;  // bytes left of the UTF-8 character written as '?'
      91          303 :         for (size_t i = 0; i < name.size(); i++) {
      92          279 :             auto c = static_cast<unsigned char>(name[i]);
      93          279 :             if (c < 0x20 || c > 0x7e) {
      94           21 :                 extended = true;
      95           21 :                 if (continuation > 0 && (c & 0xc0) == 0x80) {
      96            9 :                     continuation--;
      97            9 :                     continue;
      98              :                 }
      99           12 :                 continuation = utf8_continuation_bytes(c);
     100           12 :                 fallback += '?';
     101           12 :                 continue;
     102              :             }
     103          258 :             continuation = 0;
     104          258 :             if (c == '%' && i + 2 < name.size() && is_hex(name[i + 1]) && is_hex(name[i + 2])) extended = true;
     105          258 :             if (c == '"' || c == '\\') fallback += '\\';
     106          258 :             fallback += static_cast<char>(c);
     107              :         }
     108           24 :         std::string value = "attachment; filename=\"" + fallback + "\"";
     109           24 :         if (!extended) return value;
     110           12 :         value += "; filename*=UTF-8''";
     111           12 :         append_ext_value(value, name);
     112           12 :         return value;
     113           24 :     }
     114              : 
     115          564 :     void add_content_headers(http_response& message, const representation& file) {
     116          564 :         message.set_status(http_response::status::ok);
     117          564 :         message.set_content_type(sent_content_type(message, file));
     118          824 :         if (!file.encoding.empty()) message.add_header("Content-Encoding", std::string(file.encoding));
     119         2256 :         message.add_header("Accept-Ranges", "bytes");
     120          564 :         if (file.force_download) {
     121           72 :             message.add_header("Content-Disposition", content_disposition(file.path.filename().string()));
     122              :         }
     123          564 :     }
     124              : 
     125              :     // The single byte range of a GET request (RFC 9110, 14.2), unless it is answered 304
     126              :     // (`not_modified`), or If-Range asks for the whole file once it changed: only the date can
     127              :     // match, as the entity tag is weak. The range of a precompressed file is one of its bytes,
     128              :     // as sent (as nginx does).
     129          647 :     std::optional<byte_range> requested_range(const http_request& request, const file_info& file, bool not_modified) {
     130          647 :         if (request.get_method() != method::GET || not_modified) return std::nullopt;
     131          518 :         const auto& range = request.get_header("Range");
     132          518 :         if (range.empty()) return std::nullopt;
     133           59 :         const auto& if_range = request.get_header("If-Range");
     134           59 :         if (!if_range.empty() && if_range != file.last_modified) return std::nullopt;
     135           52 :         return parse_byte_range(range, file.size);
     136              :     }
     137              : 
     138            7 :     void make_unsatisfiable(http_response& message, uint64_t size, const error_formatter* formatter) {
     139            7 :         message.set_status(http_response::status::range_not_satisfiable);
     140           21 :         message.add_header("Content-Range", "bytes */" + std::to_string(size));
     141            7 :         format_error(formatter, {http_response::status::range_not_satisfiable, "Range not satisfiable", nullptr},
     142              :                      message);
     143           21 :     }
     144              : 
     145           38 :     std::string content_range(uint64_t first, uint64_t length, uint64_t size) {
     146           38 :         return "bytes " + std::to_string(first) + "-" + std::to_string(first + length - 1) + "/" + std::to_string(size);
     147              :     }
     148              : 
     149          467 :     bool read_file(const std::filesystem::path& path, uint64_t first, uint64_t length, std::string& content) {
     150          467 :         std::ifstream stream(path, std::ios::binary);
     151          467 :         content.assign(length, '\0');
     152          934 :         return stream && stream.seekg(static_cast<std::streamoff>(first)) &&
     153          934 :                stream.read(content.data(), static_cast<std::streamsize>(length));
     154          467 :     }
     155              : 
     156              :     // Write `length` bytes of `file`, from where it is, as the body of the stream started on
     157              :     // `res`: a piece at a time, each once the previous one is written. A file shorter than
     158              :     // announced, or a client gone, leaves the body incomplete (closing the connection). It
     159              :     // holds the `connection`: once its read loop stops (the connection closes after this
     160              :     // response), only the frame being written would, and the next piece would find it gone.
     161              :     // Its write deadline still closes it, failing the write waited for.
     162           47 :     awaitable<void> write_file(response res, std::shared_ptr<server_connection> connection, std::ifstream file,
     163              :                                uint64_t length) {
     164              :         while (length > 0) {
     165              :             auto size = std::min(length, stream_piece);
     166              :             std::string piece(size, '\0');
     167              :             bool written = false;
     168              :             if (file.read(piece.data(), static_cast<std::streamsize>(size))) {
     169              :                 written = co_await res.write_stream(std::move(piece));
     170              :             }
     171              :             if (!written) {
     172              :                 res.abort_stream();
     173              :                 co_return;
     174              :             }
     175              :             length -= size;
     176              :         }
     177              :         co_await res.end_stream();
     178           94 :     }
     179              : 
     180              :     // Stream bytes [first, first + length) of the file to `connection` (the one of `res`), with
     181              :     // the headers of `draft` (the response's) and the status of a range or the whole file
     182           47 :     void stream(response& res, http_response& draft, const representation& file, bool range, uint64_t first,
     183              :                 uint64_t length, std::shared_ptr<server_connection> connection) {
     184              :         // Opened before any header is set: the error answered instead keeps them
     185           47 :         std::ifstream content(file.path, std::ios::binary);
     186           47 :         if (!content || !content.seekg(static_cast<std::streamoff>(first))) {
     187            0 :             res.error(http_response::status::internal_server_error, "Failed to read file");
     188            0 :             return;
     189              :         }
     190           47 :         add_validators(draft, file);
     191           47 :         add_content_headers(draft, file);
     192           47 :         http_response head;
     193           47 :         head.set_status(range ? http_response::status::partial_content : http_response::status::ok);
     194           71 :         if (range) head.add_header("Content-Range", content_range(first, length, file.info.size));
     195           47 :         if (!res.start_stream(head, length)) return;
     196           47 :         auto& io_context = connection->get_socket()->get_io_context();
     197           47 :         detail::spawn_handler(io_context, "file stream",
     198           94 :                               write_file(res, std::move(connection), std::move(content), length));
     199           47 :     }
     200              : 
     201              :     // Whether a long body, with a connection to stream it to, is streamed: unless it is
     202              :     // compressed on the fly, which needs it whole (the whole file, for a client accepting a
     203              :     // coding), given the headers of `draft`
     204           54 :     bool streamed(const http_response& draft, const http_request& request, const representation& file, bool range) {
     205           68 :         return range || !compressed_on_the_fly(draft, file) ||
     206           68 :                compression::negotiated(request) == content_coding::identity;
     207              :     }
     208              : 
     209              :     // Fill `message` with the answer of the file read whole (`content`, bytes [first,
     210              :     // first + length) of it), or its headers only (a 304, or HEAD): whether it may be compressed
     211          593 :     bool fill_read(http_response& message, const representation& file, method verb, bool not_modified,
     212              :                    bool range, uint64_t first, uint64_t length, std::string content) {
     213          593 :         add_validators(message, file);
     214              : 
     215          593 :         if (not_modified) {
     216           76 :             message.set_status(http_response::status::not_modified);
     217           76 :             return false;
     218              :         }
     219              : 
     220          517 :         add_content_headers(message, file);
     221              : 
     222              :         // HEAD: the headers of the GET answer (with the length of the file, as it is), no body
     223          517 :         if (verb == method::HEAD) {
     224           50 :             message.set_content_length(file.info.size);
     225           50 :             return false;
     226              :         }
     227              : 
     228          467 :         message.set_content(std::move(content));
     229              : 
     230              :         // A range is sent as is: compressing it would change the bytes it refers to
     231          467 :         if (range) {
     232           26 :             message.set_status(http_response::status::partial_content);
     233           78 :             message.add_header("Content-Range", content_range(first, length, file.info.size));
     234           26 :             return false;
     235              :         }
     236              :         // compression::compress() checks by itself whether the body is compressible: only the
     237              :         // conditions it does not know are given here (one reading of the headers per answer)
     238          441 :         return file.encoding.empty() && file.info.size <= compression_cap;
     239              :     }
     240              : 
     241              : }
     242              : 
     243          647 : void send(response& res, const representation& file) {
     244          647 :     const auto& request = res.request_message();
     245          647 :     auto verb = request.get_method();
     246              :     // Conditional request for a file the client has: no body, nor the length of the file
     247          647 :     bool not_modified = (verb == method::GET || verb == method::HEAD) && file.info.not_modified(request);
     248              : 
     249              :     // The body (a byte range of it), read before the message takes the status and headers set:
     250              :     // an error answered instead keeps them
     251          647 :     auto range = requested_range(request, file.info, not_modified);
     252          647 :     if (range && range->length == 0) {
     253            7 :         auto unsatisfiable = res.new_response();
     254            7 :         if (!unsatisfiable) return;
     255            7 :         make_unsatisfiable(*unsatisfiable, file.info.size, res.formatter());
     256            7 :         res.send_message(std::move(unsatisfiable));
     257            7 :         return;
     258            7 :     }
     259          640 :     const auto [first, length] = range.value_or(byte_range{0, file.info.size});
     260          640 :     bool with_body = !not_modified && verb != method::HEAD;
     261              : 
     262              :     // A long body is streamed (see streamed()). A request dispatched in memory has no
     263              :     // connection to stream to: read whole
     264          640 :     auto connection = with_body && length > stream_threshold ? res.get_connection() : nullptr;
     265          640 :     if (connection && streamed(res.draft(), request, file, range.has_value())) {
     266           47 :         stream(res, res.draft(), file, range.has_value(), first, length, std::move(connection));
     267           47 :         return;
     268              :     }
     269              : 
     270          593 :     std::string content;
     271          593 :     if (with_body && !read_file(file.path, first, length, content)) {
     272            0 :         res.error(http_response::status::internal_server_error, "Failed to read file");
     273            0 :         return;
     274              :     }
     275              : 
     276          593 :     auto message = res.new_response();
     277          593 :     if (!message) return;
     278          593 :     bool compress = fill_read(*message, file, verb, not_modified, range.has_value(), first, length, std::move(content));
     279          593 :     res.send_message(std::move(message), compress);
     280          640 : }
     281              : 
     282              : } // namespace thinger::http::file_server
        

Generated by: LCOV version 2.0-1