Haio 0.2.X
Homebrew Asset IO/CDN
Loading...
Searching...
No Matches
http.cpp
Go to the documentation of this file.
1#include <bucket/bucket.hpp>
2
3#include <boost/asio/ip/tcp.hpp>
4#include <boost/asio/redirect_error.hpp>
5#include <boost/asio/ssl.hpp>
6#include <boost/asio/use_awaitable.hpp>
7#include <boost/beast/core.hpp>
8#include <boost/beast/http.hpp>
9#include <boost/beast/ssl.hpp>
10#include <boost/url/parse.hpp>
11#include <boost/url/url.hpp>
12
13#include <chrono>
14#include <iostream>
15#include <string_view>
16
17namespace asio = boost::asio;
18namespace beast = boost::beast;
19namespace http = beast::http;
20namespace urls = boost::urls;
21using tcp = asio::ip::tcp;
22
23namespace {
24
25constexpr auto httpTimeout = std::chrono::seconds(15);
26constexpr int maxRedirects = 5;
27
28std::string ensureSlash(std::string value) {
29 if (value.empty() || value.front() != '/') value.insert(value.begin(), '/');
30 return value;
31}
32
33std::string joinUrlPath(std::string_view prefix, std::string_view path) {
34 if (prefix.empty() || prefix == "/") return ensureSlash(std::string(path));
35 if (path.empty()) return ensureSlash(std::string(prefix));
36
37 std::string out(prefix);
38 if (!out.ends_with('/')) out.push_back('/');
39 if (path.starts_with('/')) path.remove_prefix(1);
40 out.append(path);
41 return ensureSlash(std::move(out));
42}
43
44void requireHttpScheme(const urls::url_view_base& url) {
45 if (url.scheme() != "http" && url.scheme() != "https") {
47 }
48}
49
50std::string servicePort(const urls::url_view_base& url) {
51 if (url.has_port()) return std::string(url.port());
52 return url.scheme() == "https" ? "443" : "80";
53}
54
55std::string requestTarget(const urls::url_view_base& url) {
56 auto target = std::string(url.encoded_path());
57 if (target.empty()) target = "/";
58 if (url.has_query()) {
59 target.push_back('?');
60 target += std::string(url.encoded_query());
61 }
62 return target;
63}
64
65urls::url parseEndpoint(std::string endpoint, std::string_view path) {
66 const auto parsed = urls::parse_uri(endpoint);
67 if (!parsed) {
68 std::cerr << "invalid bucket endpoint: " << endpoint << "\n";
69 throw Haio::Cdn::Bucket::Failure(Haio::ErrorCode::InvalidInput, "this bucket is misconfigured");
70 }
71
72 urls::url url(*parsed);
73 // s3 is plain https until it learns to sign
74 if (url.scheme() == "s3") url.set_scheme("https");
75 requireHttpScheme(url);
76 url.set_path(joinUrlPath(url.path(), path));
77 return url;
78}
79
80/**
81 * an open bucket reads the upstream off the request: "https://\*" leaves only the host
82 * to the url, "//\*" leaves the scheme as well.
83 *
84 * this is deliberate and meant for testing, which is why runServer warns about every
85 * open bucket it finds at startup. it will fetch whatever host the caller names,
86 * including ones only this machine can reach, so a deployment that faces anyone but
87 * you should not configure one.
88 */
89urls::url openBucketTarget(const Haio::Cdn::BucketConfig& bucket, std::string_view path) {
90 if (!bucket.scheme.empty()) {
91 const auto scheme = bucket.scheme == "s3" ? std::string("https") : bucket.scheme;
92 if (path.empty()) {
93 throw Haio::Cdn::Bucket::Failure(Haio::ErrorCode::InvalidInput, "open bucket expects /cdn/" + bucket.name + "/<host>/<path>");
94 }
95 return parseEndpoint(scheme + "://" + std::string(path), {});
96 }
97
98 const auto slash = path.find('/');
99 if (slash == std::string_view::npos) {
100 throw Haio::Cdn::Bucket::Failure(Haio::ErrorCode::InvalidInput, "open bucket expects /cdn/" + bucket.name + "/<scheme>/<host>/<path>");
101 }
102
103 const auto scheme = path.substr(0, slash);
104 if (scheme != "http" && scheme != "https") {
105 throw Haio::Cdn::Bucket::Failure(Haio::ErrorCode::InvalidInput, "open bucket expects a http or https scheme, got: " + std::string(scheme));
106 }
107 return parseEndpoint(std::string(scheme) + "://" + std::string(path.substr(slash + 1)), {});
108}
109
110/** shared across requests: building it reloads the whole ca store every time */
111asio::ssl::context& tlsContext() {
112 static asio::ssl::context context = [] {
113 asio::ssl::context created(asio::ssl::context::tls_client);
114 created.set_default_verify_paths();
115 created.set_verify_mode(asio::ssl::verify_peer);
116 return created;
117 }();
118 return context;
119}
120
121template <typename Stream>
122asio::awaitable<http::response<http::vector_body<uint8_t>>> exchange(Stream& stream, std::string_view hostHeader, std::string_view target,
123 const Haio::Cdn::Bucket::Headers& headers) {
124 http::request<http::empty_body> req{http::verb::get, target, 11};
125 req.set(http::field::host, hostHeader);
126 req.set(http::field::user_agent, "haio-cdn");
127 for (const auto& [name, value] : headers) req.set(name, value);
128
129 co_await http::async_write(stream, req, asio::use_awaitable);
130
131 beast::flat_buffer buffer;
132 http::response<http::vector_body<uint8_t>> res;
133 co_await http::async_read(stream, buffer, res, asio::use_awaitable);
134 co_return res;
135}
136
137asio::awaitable<http::response<http::vector_body<uint8_t>>> request(const urls::url& url, const Haio::Cdn::Bucket::Headers& headers) {
138 auto executor = co_await asio::this_coro::executor;
139 tcp::resolver resolver(executor);
140
141 const auto host = std::string(url.host());
142 const auto results = co_await resolver.async_resolve(host, servicePort(url), asio::use_awaitable);
143 const auto hostHeader = std::string(url.encoded_host_and_port());
144 const auto target = requestTarget(url);
145
146 http::response<http::vector_body<uint8_t>> res;
147 beast::error_code ec;
148
149 if (url.scheme() == "https") {
150 beast::ssl_stream<beast::tcp_stream> stream(executor, tlsContext());
151 if (!SSL_set_tlsext_host_name(stream.native_handle(), host.c_str())) {
152 // the host is the bucket's upstream, which the caller has no business
153 // learning from an error; it goes to the log instead
154 std::cerr << "could not set sni for " << host << "\n";
155 throw Haio::Cdn::Bucket::Failure(Haio::ErrorCode::Internal, "could not start a secure connection");
156 }
157 stream.set_verify_callback(asio::ssl::host_name_verification(host));
158
159 beast::get_lowest_layer(stream).expires_after(httpTimeout);
160 co_await beast::get_lowest_layer(stream).async_connect(results, asio::use_awaitable);
161 co_await stream.async_handshake(asio::ssl::stream_base::client, asio::use_awaitable);
162
163 res = co_await exchange(stream, hostHeader, target, headers);
164
165 // a truncated shutdown is the normal case for servers that just close
166 co_await stream.async_shutdown(asio::redirect_error(asio::use_awaitable, ec));
167 } else {
168 beast::tcp_stream stream(executor);
169 stream.expires_after(httpTimeout);
170 co_await stream.async_connect(results, asio::use_awaitable);
171
172 res = co_await exchange(stream, hostHeader, target, headers);
173
174 stream.socket().shutdown(tcp::socket::shutdown_both, ec);
175 }
176
177 co_return res;
178}
179
180/** a redirect may move between http and https in either direction */
181urls::url redirectTarget(const urls::url& from, std::string_view location) {
182 auto ref = urls::parse_uri_reference(location);
183 if (!ref) {
184 std::cerr << "upstream sent an invalid redirect: " << location << "\n";
185 throw Haio::Cdn::Bucket::Failure(Haio::ErrorCode::Upstream, "upstream sent an invalid redirect");
186 }
187
188 urls::url next;
189 if (const auto resolved = urls::resolve(from, *ref, next); !resolved) {
190 std::cerr << "could not resolve redirect: " << location << "\n";
191 throw Haio::Cdn::Bucket::Failure(Haio::ErrorCode::Upstream, "upstream sent a redirect that goes nowhere");
192 }
193
194 requireHttpScheme(next);
195 return next;
196}
197
198asio::awaitable<Haio::Blob> fetchUrl(urls::url url, std::string pathForFormat, Haio::Cdn::Bucket::Headers headers = {}) {
199 for (int hop = 0;; hop++) {
200 auto res = co_await request(url, headers);
201 const auto status = res.result_int();
202
203 if (status >= 300 && status < 400) {
204 if (hop >= maxRedirects) {
205 throw Haio::Cdn::Bucket::Failure(Haio::ErrorCode::Upstream, "upstream redirected more than " + std::to_string(maxRedirects) + " times");
206 }
207 const auto location = res[http::field::location];
208 if (location.empty()) {
209 throw Haio::Cdn::Bucket::Failure(Haio::ErrorCode::Upstream, "upstream sent " + std::to_string(status) + " without a location header");
210 }
211 // the signature was made for the url that redirected, and means nothing at
212 // the next one, so it is dropped rather than replayed somewhere it does not
213 // belong
214 headers.clear();
215 url = redirectTarget(url, std::string_view(location.data(), location.size()));
216 continue;
217 }
218
219 if (status < 200 || status >= 300) {
220 throw Haio::Cdn::Bucket::Failure(Haio::ErrorCode::Upstream, "http bucket returned status " + std::to_string(status));
221 }
222
223 const auto contentType = res[http::field::content_type];
224 const auto found = Haio::Detect(res.body());
225
226 // the bytes win over what the server or the url claim, because those are the
227 // two things that lie: gam creatives carry no extension and are often mislabelled
228 auto format = found.format;
229 if (format == Haio::Format::RAW) {
230 format = Haio::formatFromContentType(std::string_view(contentType.data(), contentType.size()));
231 }
232 if (format == Haio::Format::RAW) {
233 format = Haio::formatFromExtension(pathForFormat.empty() ? requestTarget(url) : pathForFormat);
234 }
235
236 co_return Haio::Blob{format, found.color, std::string(contentType), std::move(pathForFormat), std::move(res.body())};
237 }
238}
239
240}
241
242namespace Haio::Cdn::Bucket {
243
244asio::awaitable<Blob> fetchUrlWith(std::string url, std::string pathForFormat, Headers headers) {
245 const auto parsed = urls::parse_uri(url);
246 if (!parsed) {
247 std::cerr << "invalid signed url: " << url << "\n";
248 throw Failure(Haio::ErrorCode::Internal, "this bucket is misconfigured");
249 }
250 co_return co_await fetchUrl(urls::url{*parsed}, std::move(pathForFormat), std::move(headers));
251}
252
253asio::awaitable<Blob> fetchHttp(const BucketConfig& bucket, std::string path) {
254 if (bucket.open) {
255 auto url = openBucketTarget(bucket, path);
256 auto forFormat = requestTarget(url);
257 co_return co_await fetchUrl(std::move(url), std::move(forFormat));
258 }
259 co_return co_await fetchUrl(parseEndpoint(bucket.url, path), std::move(path));
260}
261
262}
asio::ip::tcp tcp
Definition http.cpp:21
std::map< std::string, std::string > Headers
Definition bucket.hpp:37
boost::asio::awaitable< Blob > fetchUrlWith(std::string url, std::string pathForFormat, Headers headers)
Definition http.cpp:244
boost::asio::awaitable< Blob > fetchHttp(const BucketConfig &bucket, std::string path)
Definition http.cpp:253
Format formatFromContentType(std::string_view contentType)
Definition format.cpp:41
Found Detect(Bytes data)
Format formatFromExtension(std::string_view path)
Definition format.cpp:33
Failure(Haio::ErrorCode why, const std::string &what)
Definition bucket.hpp:30