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
|