|
| 1 | +/* |
| 2 | + This file is part of libhttpserver |
| 3 | + Copyright (C) 2011-2026 Sebastiano Merlino |
| 4 | +
|
| 5 | + This library is free software; you can redistribute it and/or |
| 6 | + modify it under the terms of the GNU Lesser General Public |
| 7 | + License as published by the Free Software Foundation; either |
| 8 | + version 2.1 of the License, or (at your option) any later version. |
| 9 | +
|
| 10 | + This library is distributed in the hope that it will be useful, |
| 11 | + but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 12 | + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
| 13 | + Lesser General Public License for more details. |
| 14 | +
|
| 15 | + You should have received a copy of the GNU Lesser General Public |
| 16 | + License along with this library; if not, write to the Free Software |
| 17 | + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 |
| 18 | + USA |
| 19 | +*/ |
| 20 | + |
| 21 | +// hook_dispatcher behavior service (DR-014 §4.11). Logic moved verbatim |
| 22 | +// out of the former hook_phase_dispatch.cpp (the eleven forwarders) and |
| 23 | +// webserver_hook_firing.cpp (the four gated helpers). Those TUs now hold |
| 24 | +// only the thin webserver_impl forwarders during the migration. The one |
| 25 | +// behavioral change is that the per-call error logger now routes through |
| 26 | +// the detail::log_dispatch_error free function (config bag) instead of the |
| 27 | +// webserver_impl member. |
| 28 | + |
| 29 | +#include "httpserver/detail/hook_dispatcher.hpp" |
| 30 | + |
| 31 | +#include <microhttpd.h> |
| 32 | + |
| 33 | +#include <chrono> |
| 34 | +#include <cstddef> |
| 35 | +#include <memory> |
| 36 | +#include <optional> |
| 37 | +#include <string_view> |
| 38 | +#include <utility> |
| 39 | + |
| 40 | +#include "httpserver/hook_action.hpp" |
| 41 | +#include "httpserver/hook_context.hpp" |
| 42 | +#include "httpserver/hook_phase.hpp" |
| 43 | +#include "httpserver/http_request.hpp" |
| 44 | +#include "httpserver/http_resource.hpp" |
| 45 | +#include "httpserver/http_response.hpp" |
| 46 | +#include "httpserver/detail/body.hpp" |
| 47 | +#include "httpserver/detail/dispatch_util.hpp" |
| 48 | +#include "httpserver/detail/hook_bus.hpp" |
| 49 | +#include "httpserver/detail/modded_request.hpp" |
| 50 | +#include "httpserver/detail/resource_hook_table.hpp" |
| 51 | + |
| 52 | +namespace httpserver { |
| 53 | +namespace detail { |
| 54 | + |
| 55 | +namespace { |
| 56 | + |
| 57 | +// Fetch the per-route hook table (if any) from the request's |
| 58 | +// resource_weak_ slot, keeping the shared_ptr alive in res_out so the |
| 59 | +// caller can hold the table pointer valid. Returns nullptr when no |
| 60 | +// per-route table exists. Uses a direct lock() (atomic) rather than |
| 61 | +// expired()+lock() to avoid a TOCTOU window. |
| 62 | +resource_hook_table* per_route_table(detail::modded_request* mr, |
| 63 | + std::shared_ptr<http_resource>& res_out) { |
| 64 | + res_out = mr->resource_weak_.lock(); |
| 65 | + if (!res_out) return nullptr; |
| 66 | + return res_out->hook_table_raw_(); |
| 67 | +} |
| 68 | + |
| 69 | +} // namespace |
| 70 | + |
| 71 | +// ---- eleven per-phase forwarders ----------------------------------------- |
| 72 | + |
| 73 | +void hook_dispatcher::fire_connection_opened( |
| 74 | + const connection_open_ctx& ctx) noexcept { |
| 75 | + hooks_.fire_connection_opened(ctx, |
| 76 | + [this](std::string_view m) { log_dispatch_error(config_, m); }); |
| 77 | +} |
| 78 | + |
| 79 | +void hook_dispatcher::fire_accept_decision(const accept_ctx& ctx) noexcept { |
| 80 | + hooks_.fire_accept_decision(ctx, |
| 81 | + [this](std::string_view m) { log_dispatch_error(config_, m); }); |
| 82 | +} |
| 83 | + |
| 84 | +void hook_dispatcher::fire_connection_closed( |
| 85 | + const connection_close_ctx& ctx) noexcept { |
| 86 | + hooks_.fire_connection_closed(ctx, |
| 87 | + [this](std::string_view m) { log_dispatch_error(config_, m); }); |
| 88 | +} |
| 89 | + |
| 90 | +std::optional<http_response> hook_dispatcher::fire_request_received( |
| 91 | + request_received_ctx& ctx) noexcept { |
| 92 | + return hooks_.fire_request_received(ctx, |
| 93 | + [this](std::string_view m) { log_dispatch_error(config_, m); }); |
| 94 | +} |
| 95 | + |
| 96 | +std::optional<http_response> hook_dispatcher::fire_body_chunk( |
| 97 | + body_chunk_ctx& ctx) noexcept { |
| 98 | + return hooks_.fire_body_chunk(ctx, |
| 99 | + [this](std::string_view m) { log_dispatch_error(config_, m); }); |
| 100 | +} |
| 101 | + |
| 102 | +void hook_dispatcher::fire_route_resolved( |
| 103 | + const route_resolved_ctx& ctx) noexcept { |
| 104 | + hooks_.fire_route_resolved(ctx, |
| 105 | + [this](std::string_view m) { log_dispatch_error(config_, m); }); |
| 106 | +} |
| 107 | + |
| 108 | +std::optional<http_response> hook_dispatcher::fire_before_handler( |
| 109 | + before_handler_ctx& ctx) noexcept { |
| 110 | + return hooks_.fire_before_handler(ctx, |
| 111 | + [this](std::string_view m) { log_dispatch_error(config_, m); }); |
| 112 | +} |
| 113 | + |
| 114 | +std::optional<http_response> hook_dispatcher::fire_handler_exception( |
| 115 | + const handler_exception_ctx& ctx) noexcept { |
| 116 | + return hooks_.fire_handler_exception(ctx, |
| 117 | + [this](std::string_view m) { log_dispatch_error(config_, m); }); |
| 118 | +} |
| 119 | + |
| 120 | +std::optional<http_response> hook_dispatcher::fire_after_handler( |
| 121 | + after_handler_ctx& ctx) noexcept { |
| 122 | + return hooks_.fire_after_handler(ctx, |
| 123 | + [this](std::string_view m) { log_dispatch_error(config_, m); }); |
| 124 | +} |
| 125 | + |
| 126 | +void hook_dispatcher::fire_response_sent( |
| 127 | + const response_sent_ctx& ctx) noexcept { |
| 128 | + hooks_.fire_response_sent(ctx, |
| 129 | + [this](std::string_view m) { log_dispatch_error(config_, m); }); |
| 130 | +} |
| 131 | + |
| 132 | +void hook_dispatcher::fire_request_completed( |
| 133 | + const request_completed_ctx& ctx) noexcept { |
| 134 | + hooks_.fire_request_completed(ctx, |
| 135 | + [this](std::string_view m) { log_dispatch_error(config_, m); }); |
| 136 | +} |
| 137 | + |
| 138 | +// ---- gated: before_handler (server-wide + per-route, short-circuiting) --- |
| 139 | + |
| 140 | +bool hook_dispatcher::fire_before_handler_gated( |
| 141 | + detail::modded_request* mr, |
| 142 | + const std::shared_ptr<http_resource>& hrm) { |
| 143 | + const bool server_gate = hooks_.has_hooks_for(hook_phase::before_handler); |
| 144 | + // rtable comes from hrm directly (already resolved under the route |
| 145 | + // table lock); hrm keeps the resource alive for this function. The |
| 146 | + // acquire barrier from that shared_lock lets hook_table_raw_() observe |
| 147 | + // any prior add_hook() store. |
| 148 | + auto* rtable = hrm->hook_table_raw_(); |
| 149 | + const bool per_route_gate = rtable != nullptr && |
| 150 | + rtable->any_hooks(hook_phase::before_handler); |
| 151 | + if (!server_gate && !per_route_gate) return false; |
| 152 | + |
| 153 | + std::optional<route_descriptor> desc; |
| 154 | + if (!mr->matched_path_template.empty()) { |
| 155 | + desc = route_descriptor{ |
| 156 | + /*path_template=*/std::string_view{mr->matched_path_template}, |
| 157 | + /*methods=*/hrm->get_allowed_methods(), |
| 158 | + /*is_prefix=*/mr->matched_is_prefix}; |
| 159 | + } |
| 160 | + before_handler_ctx ctx{ |
| 161 | + /*request=*/mr->request.get(), |
| 162 | + /*matched=*/std::move(desc), |
| 163 | + /*method=*/mr->method_enum, |
| 164 | + /*resource=*/hrm.get()}; |
| 165 | + if (server_gate) { |
| 166 | + if (auto sc = fire_before_handler(ctx)) { |
| 167 | + mr->response.emplace(std::move(*sc)); |
| 168 | + return true; |
| 169 | + } |
| 170 | + } |
| 171 | + if (per_route_gate) { |
| 172 | + if (auto sc = rtable->fire_before_handler(ctx, |
| 173 | + [this](std::string_view m) { |
| 174 | + log_dispatch_error(config_, m); |
| 175 | + })) { |
| 176 | + mr->response.emplace(std::move(*sc)); |
| 177 | + return true; |
| 178 | + } |
| 179 | + } |
| 180 | + return false; |
| 181 | +} |
| 182 | + |
| 183 | +// ---- gated: after_handler (server-wide + per-route, replace-response) ---- |
| 184 | + |
| 185 | +void hook_dispatcher::fire_after_handler_gated(detail::modded_request* mr, |
| 186 | + http_resource* resource) { |
| 187 | + const bool server_gate = hooks_.has_hooks_for(hook_phase::after_handler); |
| 188 | + // resource is borrowed from finalize_answer's live shared_ptr; no |
| 189 | + // weak_ptr lock() needed. nullptr on the 404 path -- no per-route table. |
| 190 | + auto* rtable = resource != nullptr ? resource->hook_table_raw_() : nullptr; |
| 191 | + const bool route_gate = rtable != nullptr && |
| 192 | + rtable->any_hooks(hook_phase::after_handler); |
| 193 | + |
| 194 | + if (!server_gate && !route_gate) return; |
| 195 | + if (!mr->response) return; // defensive: never fire without a response |
| 196 | + |
| 197 | + after_handler_ctx ctx{mr->request.get(), &*mr->response}; |
| 198 | + if (server_gate) { |
| 199 | + if (auto sc = fire_after_handler(ctx)) { |
| 200 | + // Short-circuit: REPLACE mr->response (emplace destroys the old |
| 201 | + // response, releasing deferred captures now). The per-route |
| 202 | + // chain ALSO sees the replaced response, so refresh ctx. |
| 203 | + mr->response.emplace(std::move(*sc)); |
| 204 | + ctx.response = &*mr->response; |
| 205 | + } |
| 206 | + } |
| 207 | + if (route_gate) { |
| 208 | + if (auto sc = rtable->fire_after_handler(ctx, |
| 209 | + [this](std::string_view m) { |
| 210 | + log_dispatch_error(config_, m); |
| 211 | + })) { |
| 212 | + mr->response.emplace(std::move(*sc)); |
| 213 | + } |
| 214 | + } |
| 215 | +} |
| 216 | + |
| 217 | +// ---- gated: response_sent (observation, server-wide + per-route) --------- |
| 218 | + |
| 219 | +void hook_dispatcher::fire_response_sent_gated(detail::modded_request* mr, |
| 220 | + http_resource* resource) { |
| 221 | + const bool server_gate = hooks_.has_hooks_for(hook_phase::response_sent); |
| 222 | + // resource is borrowed from finalize_answer's live shared_ptr, so read |
| 223 | + // the per-route table directly instead of locking the weak_ptr. nullptr |
| 224 | + // on the skip_handler / 404 paths. |
| 225 | + auto* rtable = resource != nullptr ? resource->hook_table_raw_() : nullptr; |
| 226 | + const bool route_gate = rtable != nullptr && |
| 227 | + rtable->any_hooks(hook_phase::response_sent); |
| 228 | + // Whether any user hook (server-wide or per-route) will observe this |
| 229 | + // firing. Distinct from the log_access alias slot, which fires |
| 230 | + // regardless but never reads ctx.elapsed (see the elapsed gate below). |
| 231 | + const bool user_gate = server_gate || route_gate; |
| 232 | + |
| 233 | + if (!user_gate && !hooks_.has_log_access_alias()) return; |
| 234 | + // mr->response is null only if materialize_and_queue_response's |
| 235 | + // belt-and-suspenders fallback also failed; fire nothing rather than crash. |
| 236 | + if (!mr->response) return; |
| 237 | + |
| 238 | + // 0 for deferred/pipe bodies -- see response_sent_ctx docs. |
| 239 | + const std::size_t bytes_queued = (mr->response->body_ != nullptr) |
| 240 | + ? mr->response->body_->size() : 0; |
| 241 | + // elapsed is consumed by user hooks, not by the log_access alias. |
| 242 | + // Skip the steady_clock::now() syscall when only the alias slot fires. |
| 243 | + // NOTE: the alias body MUST NOT read ctx.elapsed; any future change that |
| 244 | + // needs elapsed in the alias must also remove this optimisation. |
| 245 | + const auto elapsed = user_gate |
| 246 | + ? std::chrono::duration_cast<std::chrono::nanoseconds>( |
| 247 | + std::chrono::steady_clock::now() - mr->start_time) |
| 248 | + : std::chrono::nanoseconds::zero(); |
| 249 | + response_sent_ctx ctx{mr->request.get(), &*mr->response, |
| 250 | + mr->response->get_status(), bytes_queued, elapsed}; |
| 251 | + fire_response_sent(ctx); |
| 252 | + // Per-route chain AFTER server-wide + its alias slot. |
| 253 | + // response_sent is observation-only; no short-circuit logic. |
| 254 | + if (route_gate) { |
| 255 | + rtable->fire_response_sent(ctx, |
| 256 | + [this](std::string_view m) { log_dispatch_error(config_, m); }); |
| 257 | + } |
| 258 | +} |
| 259 | + |
| 260 | +// ---- gated: request_completed (fires from MHD completion callback) ------- |
| 261 | + |
| 262 | +void hook_dispatcher::fire_request_completed_gated( |
| 263 | + detail::modded_request* mr, |
| 264 | + enum MHD_RequestTerminationCode toe) { |
| 265 | + const bool server_gate = |
| 266 | + hooks_.has_hooks_for(hook_phase::request_completed); |
| 267 | + |
| 268 | + // Per-route gate. This fires from the MHD completion callback -- |
| 269 | + // finalize_answer's owning shared_ptr is gone -- so lock() the weak_ptr |
| 270 | + // to keep the resource alive while rtable is in use. Skip the lock() |
| 271 | + // entirely on the common path: route_has_hook_table_ is a snapshot of |
| 272 | + // whether the resource carried any per-route hook table; when it's false |
| 273 | + // and no server-wide hook is registered, no control-block atomics are |
| 274 | + // touched. |
| 275 | + std::shared_ptr<http_resource> res; |
| 276 | + resource_hook_table* rtable = nullptr; |
| 277 | + if (server_gate || mr->route_has_hook_table_) { |
| 278 | + rtable = per_route_table(mr, res); |
| 279 | + } |
| 280 | + const bool per_route_present = rtable != nullptr && |
| 281 | + rtable->any_hooks(hook_phase::request_completed); |
| 282 | + |
| 283 | + if (!server_gate && !per_route_present) { |
| 284 | + return; |
| 285 | + } |
| 286 | + const http_response* resp_ptr = |
| 287 | + mr->response ? &*mr->response : nullptr; |
| 288 | + // mr->start_time may be epoch if answer_to_connection never ran (e.g. a |
| 289 | + // port scan: uri_log created mr but MHD closed before dispatch). Emit |
| 290 | + // nanoseconds{-1} as a sentinel so hook authors can distinguish the |
| 291 | + // degenerate case from a real (but very slow) request. |
| 292 | + const auto raw_duration = |
| 293 | + std::chrono::steady_clock::now() - mr->start_time; |
| 294 | + const auto duration = |
| 295 | + (mr->start_time == std::chrono::steady_clock::time_point{}) |
| 296 | + ? std::chrono::nanoseconds{-1} |
| 297 | + : std::chrono::duration_cast<std::chrono::nanoseconds>( |
| 298 | + raw_duration); |
| 299 | + request_completed_ctx ctx{ |
| 300 | + /*request=*/mr->request.get(), |
| 301 | + /*resp=*/resp_ptr, |
| 302 | + /*succeeded=*/(toe == MHD_REQUEST_TERMINATED_COMPLETED_OK), |
| 303 | + /*duration=*/duration, |
| 304 | + }; |
| 305 | + if (server_gate) { |
| 306 | + fire_request_completed(ctx); |
| 307 | + } |
| 308 | + if (per_route_present) { |
| 309 | + rtable->fire_request_completed(ctx, |
| 310 | + [this](std::string_view m) { log_dispatch_error(config_, m); }); |
| 311 | + } |
| 312 | +} |
| 313 | + |
| 314 | +} // namespace detail |
| 315 | +} // namespace httpserver |
0 commit comments