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
|