5#include <boost/asio/co_spawn.hpp>
6#include <boost/asio/detached.hpp>
7#include <boost/asio/ip/tcp.hpp>
8#include <boost/asio/signal_set.hpp>
9#include <boost/asio/use_awaitable.hpp>
10#include <boost/beast/core.hpp>
11#include <boost/beast/http.hpp>
12#include <boost/url/parse.hpp>
24namespace asio = boost::asio;
25namespace beast = boost::beast;
26namespace http = beast::http;
27using tcp = asio::ip::tcp;
31std::string joinSegments(
const std::vector<std::string>& segments,
size_t first) {
32 return std::accumulate(
33 segments.begin() +
static_cast<std::ptrdiff_t
>(first),
36 [](std::string out,
const std::string& segment) {
37 if (!out.empty()) out.push_back(
'/');
50Route parseRoute(std::string_view target) {
51 auto parsed = boost::urls::parse_origin_form(target);
52 if (!parsed)
throw std::runtime_error(
"invalid request target: " + parsed.error().message());
54 const auto encodedPath = std::string(parsed->encoded_path());
55 constexpr std::string_view prefix =
"/cdn/";
56 if (!encodedPath.starts_with(prefix))
throw std::runtime_error(
"expected /cdn/<bucket>/<path>");
58 std::vector<std::string> segments;
59 for (
auto segment : parsed->segments()) segments.emplace_back(segment);
60 if (segments.empty() || segments.front() !=
"cdn")
throw std::runtime_error(
"expected /cdn/<bucket>/<path>");
62 const auto tail = std::string_view(encodedPath).substr(prefix.size());
63 const auto query = parsed->has_query() ? std::string(parsed->encoded_query()) : std::string{};
64 if (tail.find(
'/') == std::string_view::npos) {
65 return Route{
"file", segments.size() > 1 ? segments[1] : std::string{}, query};
68 auto bucket = segments.size() > 1 ? segments[1] : std::string{};
69 auto rest = segments.size() > 2 ? joinSegments(segments, 2) : std::string{};
70 if (bucket.empty()) bucket =
"file";
71 return Route{std::move(bucket), std::move(rest), query};
74std::string downloadName(std::string_view path,
Haio::Format format) {
75 const auto slash = path.find_last_of(
'/');
76 const auto base = slash == std::string_view::npos ? path : path.substr(slash + 1);
79 for (
const char c : base) {
80 if (std::isalnum(
static_cast<unsigned char>(c)) || c ==
'-' || c ==
'_' || c ==
'.') name.push_back(c);
82 if (
const auto dot = name.find_last_of(
'.'); dot != std::string::npos) name.resize(dot);
83 if (name.find_first_not_of(
'.') == std::string::npos) name =
"download";
89bool hasTokenKind(
const std::vector<Haio::Token>& tokens,
Haio::TokenKind kind) {
90 return std::ranges::any_of(tokens, [kind](
const Haio::Token& token) {
return token.
kind == kind; });
93bool hasImageTransform(
const std::vector<Haio::Token>& tokens) {
94 return std::ranges::any_of(tokens, [](
const Haio::Token& token) {
113bool alreadyWhatWasAsked(
const std::vector<Haio::Token>& tokens,
Haio::Format format) {
116 return std::ranges::all_of(tokens, [format](
const Haio::Token& token) {
121http::response<http::vector_body<uint8_t>> makeResponse(http::status status, std::string_view contentType, std::vector<uint8_t> body) {
122 http::response<http::vector_body<uint8_t>> res{status, 11};
123 res.set(http::field::server,
"haio-cdn");
124 res.set(http::field::content_type, contentType);
125 res.body() = std::move(body);
126 res.prepare_payload();
130http::response<http::vector_body<uint8_t>> makeText(http::status status, std::string text) {
131 return makeResponse(status,
"text/plain; charset=utf-8", std::vector<uint8_t>(text.begin(), text.end()));
145 return http::status::internal_server_error;
148http::response<http::vector_body<uint8_t>> makeError(
const Haio::Error& error) {
149 return makeText(statusFor(error.
code), error.
message +
"\n");
163 explicit RateLimiter(
size_t perSecond) : rate_(static_cast<double>(perSecond)) {}
165 bool allow(
const std::string& client) {
166 if (rate_ <= 0 || client.empty())
return true;
168 const auto now = std::chrono::steady_clock::now();
175 if (seen_.size() > clientsBeforePrune) {
176 std::erase_if(seen_, [&](
const auto& row) {
return credit(row.second, now) >= rate_; });
177 if (seen_.size() > clientsBeforePrune) seen_.clear();
180 auto found = seen_.find(client);
181 if (found == seen_.end()) found = seen_.emplace(client, Bucket{rate_, now}).first;
183 auto& bucket = found->second;
184 bucket.tokens = credit(bucket, now);
187 if (bucket.tokens < 1.0)
return false;
188 bucket.tokens -= 1.0;
195 std::chrono::steady_clock::time_point filled;
199 double credit(
const Bucket& bucket, std::chrono::steady_clock::time_point now)
const {
200 const auto idle = std::chrono::duration<double>(now - bucket.filled).count();
201 return std::min(rate_, bucket.tokens + idle * rate_);
204 static constexpr size_t clientsBeforePrune = 10000;
206 std::map<std::string, Bucket> seen_;
214std::optional<Haio::Error> checkSize(
const Haio::Cdn::Config& config,
const std::vector<Haio::Token>& tokens) {
215 const auto& limits = config.
security;
217 for (
const auto& token : tokens) {
222 if (token.percent != 0) {
223 if (limits.maxSizePercent > 0 && token.percent > limits.maxSizePercent) {
225 "resize is limited to " + std::to_string(limits.maxSizePercent) +
" percent"};
230 if (limits.maxSizePixel > 0
231 && (token.size.width > limits.maxSizePixel || token.size.height > limits.maxSizePixel)) {
233 "resize is limited to " + std::to_string(limits.maxSizePixel) +
" on each side"};
240asio::awaitable<Haio::Result<Haio::Cdn::CacheEntry>> produce(
const Haio::Cdn::Config& config, Haio::Cdn::Bucket::ZipArchives& archives, Route route) {
242 if (!fetched)
co_return std::unexpected(fetched.error());
243 auto blob = *std::move(fetched);
256 "haio does not recognise " + route.path});
260 if (
auto tooBig = checkSize(config, tokens))
co_return std::unexpected(*tooBig);
262 if (!tokens.empty() && !alreadyWhatWasAsked(tokens, blob.format)) {
263 Haio::Pipeline pipeline;
265 for (
auto token : tokens) pipeline |= std::move(token);
273 if (!converted)
co_return std::unexpected(converted.error());
274 blob = *std::move(converted);
277 co_return Haio::Cdn::CacheEntry{
278 std::move(blob.data),
280 downloadName(route.path, blob.format),
284asio::awaitable<http::response<http::vector_body<uint8_t>>> handleRequest(
const Haio::Cdn::Config& config, Haio::Cdn::Cache cache, std::shared_ptr<RateLimiter> limiter, std::shared_ptr<Haio::Cdn::Bucket::ZipArchives> archives, std::string client, http::request<http::string_body> req) {
285 if (req.method() != http::verb::get && req.method() != http::verb::head) {
286 co_return makeText(http::status::method_not_allowed,
"method not allowed\n");
290 if (!limiter->allow(client)) {
291 auto res = makeText(http::status::too_many_requests,
"too many requests\n");
292 res.set(http::field::retry_after,
"1");
297 const auto route = parseRoute(req.target());
302 return produce(config, *archives, route);
304 if (!entry)
co_return makeError(entry.error());
306 auto res = makeResponse(http::status::ok, entry->contentType, std::move(entry->data));
307 res.set(http::field::content_disposition,
"inline; filename=\"" + entry->filename +
"\"");
309 }
catch (
const std::exception& err) {
311 co_return makeText(http::status::bad_request, std::string(err.what()) +
"\n");
315asio::awaitable<void> session(tcp::socket socket, Haio::Cdn::Config config, Haio::Cdn::Cache cache, std::shared_ptr<RateLimiter> limiter, std::shared_ptr<Haio::Cdn::Bucket::ZipArchives> archives) {
324 if (boost::system::error_code ec;
true) {
325 const auto peer = socket.remote_endpoint(ec);
326 if (!ec) client = peer.address().to_string();
329 beast::flat_buffer buffer;
332 http::request<http::string_body> req;
333 co_await http::async_read(socket, buffer, req, asio::use_awaitable);
334 const bool close = req.need_eof();
335 const bool headOnly = req.method() == http::verb::head;
336 auto res =
co_await handleRequest(config, cache, limiter, archives, client, std::move(req));
338 const auto size = res.body().size();
340 res.content_length(size);
342 res.keep_alive(!close);
343 co_await http::async_write(socket, res, asio::use_awaitable);
346 }
catch (
const boost::system::system_error& err) {
350 if (err.code() != http::error::end_of_stream) {
351 std::cerr <<
"connection dropped: " << err.code().message() <<
"\n";
353 }
catch (
const std::exception& err) {
354 std::cerr <<
"connection dropped: " << err.what() <<
"\n";
357 beast::error_code ec;
358 socket.shutdown(tcp::socket::shutdown_send, ec);
361asio::awaitable<void> listener(Haio::Cdn::Config config) {
362 auto executor =
co_await asio::this_coro::executor;
363 tcp::resolver resolver(executor);
364 const auto resolved =
co_await resolver.async_resolve(config.
host, std::to_string(config.
port), asio::use_awaitable);
365 tcp::acceptor acceptor(executor, *resolved.begin());
367 for (
const auto& [name, bucket] : config.
buckets) {
369 std::cerr <<
"warning: bucket \"" << name <<
"\" is open, so it will fetch whatever host the request names\n";
379 auto archives = std::make_shared<Haio::Cdn::Bucket::ZipArchives>(config.
security.
maxUnzip);
381 std::cout <<
"cache: " << (config.
cache.
url.empty() ?
"memory" : config.
cache.
url)
382 <<
", ttl " << config.
cache.
ttl.count() <<
"s\n";
391 const auto missing = [&config](std::string_view key) {
return !config.
declared.contains(std::string(key)); };
393 if (missing(
"security.timeout")) {
394 std::cerr <<
"warning: security.timeout is not set, so a conversion is stopped after "
397 if (missing(
"security.max_size_pixel")) {
398 std::cerr <<
"warning: security.max_size_pixel is not set, so a resize is capped at "
401 if (missing(
"security.max_size_percent")) {
402 std::cerr <<
"warning: security.max_size_percent is not set, so a resize is capped at "
405 if (missing(
"security.max_requests_by_ip")) {
408 std::cerr <<
"warning: security.max_requests_by_ip is not set, so one address may "
409 "make as many requests per second as it likes\n";
411 if (cache.
enabled() && missing(
"cache.max_usage")) {
412 std::cerr <<
"warning: cache.max_usage is not set, so the cache is capped at "
413 << config.
cache.
maxUsage / (1024 * 1024) <<
"mb by default\n";
415 if (cache.
enabled() && missing(
"security.max_cache_entries_by_ip")) {
416 std::cerr <<
"warning: security.max_cache_entries_by_ip is not set, so one address "
417 "may fill the cache on its own and evict everybody else\n";
419 std::cout <<
"haio cdn listening on http://" << config.
host <<
':' << config.
port <<
"\n";
422 auto socket =
co_await acceptor.async_accept(asio::use_awaitable);
423 asio::co_spawn(executor, session(std::move(socket), config, cache, limiter, archives), asio::detached);
432 co_await listener(std::move(config));
boost::asio::awaitable< Result< CacheEntry > > fetch(std::string key, std::string client, Producer produce)
const std::vector< Token > & tokens() const
boost::asio::awaitable< void > runServer(Config config)
size_t maxCacheEntriesByIp
std::map< std::string, BucketConfig > buckets
std::string cacheKey(std::string_view bucket, std::string_view path, std::string_view query)
std::set< std::string > declared
boost::asio::awaitable< Result< Blob > > fetchBucket(const Config &config, Bucket::ZipArchives &archives, std::string bucket, std::string path)
std::chrono::seconds timeout
Token Encode(Format format, std::optional< Color > color=std::nullopt)
Token Source(std::string bucket, std::string path)
std::optional< Color > color
std::vector< Token > parseQueryTokens(std::string_view query)
Result< Blob > runPipeline(Blob input, const Pipeline &pipeline, std::optional< std::chrono::steady_clock::time_point > deadline=std::nullopt)
std::expected< T, Error > Result
std::string_view contentTypeFor(Format format)
std::string_view extensionFor(Format format)