diff --git a/CMakeLists.txt b/CMakeLists.txt index 48ebeb4..e27a25b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,7 +48,11 @@ if(ENABLE_WERROR) # PCH, std::variant inside pqxx::params) and escape -isystem suppression # because the optimizer attributes them to the system-header location — # a known false-positive class we can't fix in third-party code. - add_compile_options(-Werror -Wno-error=null-dereference -Wno-error=maybe-uninitialized) + # -Warray-bounds joins the list for the same reason: GCC 13 at -O3 emits a + # bogus std::string-SSO bounds error (libstdc++ operator+ inlining, cf. GCC + # PR105651) that comes and goes with unrelated inlining-context changes. + add_compile_options(-Werror -Wno-error=null-dereference -Wno-error=maybe-uninitialized + -Wno-error=array-bounds) endif() # Sanitizer support — when ENABLE_SANITIZERS=ON, build with ASan+UBSan diff --git a/src/api/AccountController.cpp b/src/api/AccountController.cpp new file mode 100644 index 0000000..526e50b --- /dev/null +++ b/src/api/AccountController.cpp @@ -0,0 +1,331 @@ +/** + * @file AccountController.cpp + * @brief Bodies for src/api/AccountController.hpp — compiled once into + * app_core. The per-route flow contracts (idempotency, enumeration + * stance, one-shot token rules) are documented on the declarations + * in the header. + */ + +#include "api/AccountController.hpp" + +#include + +#include + +#include + +#include "api/Guards.hpp" +#include "api/HandlerSupport.hpp" +#include "api/Validation.hpp" +#include "database/Database.hpp" +#include "email/AccountEmails.hpp" +#include "repositories/UserRepository.hpp" +#include "security/Auth.hpp" +#include "security/Password.hpp" +#include "security/SessionStore.hpp" +#include "security/Tokens.hpp" +#include "utils/Crypto.hpp" +#include "utils/ErrorResponse.hpp" + +namespace Api { + +void AccountController::resendConfirm(const HttpRequestPtr& req, + std::function&& callback) { + API_REQUIRE_PRINCIPAL(req, callback, principal); + try { + Repositories::UserRepository repo; + auto user = repo.find(principal->subject); + if (!user) { + callback(ErrorResponse::not_found("user")); + return; + } + if (user->confirmed) { + callback(Response::ok({{"message", "already confirmed"}})); + return; + } + Email::AccountEmails::send_confirm(*user); + callback(Response::ok({{"message", "confirmation email sent"}})); + } catch (const std::exception& e) { + spdlog::error("resendConfirm failed: {}", e.what()); + callback(ErrorResponse::internal_error()); + } +} + +void AccountController::confirm(const HttpRequestPtr& /*req*/, + std::function&& callback, + const std::string& token) { + auto vr = Security::Tokens::verify(secret(), token, Security::Tokens::Purpose::Confirm); + if (!vr.ok) { + callback(ErrorResponse::bad_request("invalid_token", "Confirmation link is invalid or has expired")); + return; + } + // One-shot, consistent with applyReset/applyChangeEmail: a captured + // confirm link shouldn't stay replayable for the token's full (7-day) + // lifetime. TTL matches the confirm token lifetime so the replay guard + // outlives the token it protects. + if (!consume_once(token, /*ttl_sec=*/7 * 24 * 3600)) { + callback(ErrorResponse::bad_request("invalid_token", "Confirmation link has already been used")); + return; + } + try { + Repositories::UserRepository repo; + if (!repo.mark_confirmed(vr.sub)) { + callback(ErrorResponse::not_found("user")); + return; + } + callback(Response::ok({{"message", "Account confirmed"}})); + } catch (const std::exception& e) { + spdlog::error("confirm failed: {}", e.what()); + callback(ErrorResponse::internal_error()); + } +} + +void AccountController::requestReset(const HttpRequestPtr& req, + std::function&& callback) { + json body; + if (!Validation::parse_body(req, body, callback)) + return; + Validation::Errors errs; + Validation::require(errs, body, "email"); + Validation::email(errs, body, "email"); + if (errs.any()) { + callback(Validation::response_400(errs)); + return; + } + const std::string email = body["email"].get(); + try { + Repositories::UserRepository repo; + auto user = repo.find_by_email(email); + if (user) { + Email::AccountEmails::send_reset(*user); + } else { + // Debug level, no address: the probed email is PII and an + // info-level log would be a log-side enumeration channel + // undercutting the deliberate generic 200 below. + spdlog::debug("[reset-request] no matching user — silent ack"); + } + } catch (const std::exception& e) { + // Same generic 200 on backend trouble — a 500 here would leak + // that the lookup ran (and retrying costs the user nothing). + spdlog::error("requestReset failed: {}", e.what()); + } + // Generic 200 either way — no enumeration. + callback(Response::ok({{"message", "If that email is registered, a reset link is on its way."}})); +} + +void AccountController::applyReset(const HttpRequestPtr& req, + std::function&& callback, + const std::string& token) { + auto body = parse_new_password_body(req, callback); + if (!body) + return; + auto vr = Security::Tokens::verify(secret(), token, Security::Tokens::Purpose::ResetPassword); + if (!vr.ok) { + callback(ErrorResponse::bad_request("invalid_token", "Reset link is invalid or has expired")); + return; + } + // One-shot: a captured reset link must not be replayable to set the + // password a second time after the legitimate user used it. + if (!consume_once(token, /*ttl_sec=*/3600)) { + callback(ErrorResponse::bad_request("invalid_token", "Reset link has already been used")); + return; + } + with_repo_errors(callback, "applyReset", [&] { + Repositories::UserRepository repo; + const std::string new_hash = Security::Password::hash((*body)["new_password"].get()); + repo.update_password_hash(vr.sub, new_hash); + // Evict every existing session — a reset must lock out anyone + // holding an old refresh token (incl. an attacker who triggered + // the reset path). Best-effort; the new login mints a fresh one. + Security::Sessions::revoke_all(Security::Auth::get().config().cookies, vr.sub); + callback(Response::ok({{"message", "Password updated"}})); + }); +} + +void AccountController::requestChangeEmail(const HttpRequestPtr& req, + std::function&& callback) { + API_REQUIRE_PRINCIPAL(req, callback, principal); + json body; + if (!Validation::parse_body(req, body, callback)) + return; + Validation::Errors errs; + Validation::require(errs, body, "new_email"); + Validation::email(errs, body, "new_email"); + // require_string: a non-string password reaches get() + // below and throws type_error.302 → bare 500 instead of a 400. + Validation::require_string(errs, body, "password"); + if (errs.any()) { + callback(Validation::response_400(errs)); + return; + } + try { + auto user = + verify_password_or_401(principal->subject, body["password"].get(), "Wrong password", callback); + if (!user) + return; + const std::string new_email = body["new_email"].get(); + Email::AccountEmails::send_change_email(*user, new_email); + callback(Response::ok({{"message", "Confirmation email sent to the new address."}})); + } catch (const std::exception& e) { + spdlog::error("requestChangeEmail failed: {}", e.what()); + callback(ErrorResponse::internal_error()); + } +} + +void AccountController::applyChangeEmail(const HttpRequestPtr& /*req*/, + std::function&& callback, + const std::string& token) { + auto vr = Security::Tokens::verify(secret(), token, Security::Tokens::Purpose::ChangeEmail); + if (!vr.ok) { + callback(ErrorResponse::bad_request("invalid_token", "Change-email link is invalid or has expired")); + return; + } + const auto new_email_it = vr.extra.find("new_email"); + if (new_email_it == vr.extra.end() || !new_email_it->is_string()) { + callback(ErrorResponse::bad_request("invalid_token", "Token is missing the new email")); + return; + } + const std::string new_email = new_email_it->get(); + // Check availability BEFORE consuming the one-shot token, so a + // duplicate-email 409 doesn't permanently burn an otherwise-valid link. + // change_email's UNIQUE constraint still guards the check→write race. + { + Repositories::UserRepository repo; + auto taken = repo.find_by_email(new_email); + if (taken && taken->id != vr.sub) { + callback(ErrorResponse::conflict("email_taken", "That email address is already in use")); + return; + } + } + if (!consume_once(token, /*ttl_sec=*/3600)) { + callback(ErrorResponse::bad_request("invalid_token", "Change-email link has already been used")); + return; + } + with_repo_errors(callback, "applyChangeEmail", [&] { + Repositories::UserRepository repo; + repo.change_email(vr.sub, new_email); + callback(Response::ok({{"message", "Email updated"}})); + }); +} + +void AccountController::joinFromInvite(const HttpRequestPtr& req, + std::function&& callback, + const std::string& token) { + auto body = parse_new_password_body(req, callback); + if (!body) + return; + auto vr = Security::Tokens::verify(secret(), token, Security::Tokens::Purpose::Invite); + if (!vr.ok) { + callback(ErrorResponse::bad_request("invalid_token", "Invitation link is invalid or has expired")); + return; + } + with_repo_errors(callback, "joinFromInvite", [&] { + Repositories::UserRepository repo; + const std::string new_hash = Security::Password::hash((*body)["new_password"].get()); + // redeem_invite is a DB-level one-shot (only matches a still-pending + // invite), so a replayed link can't reset an active account — no + // need for the fail-open Redis guard here. Doing the write first + // also means a transient failure doesn't burn the 7-day token. + if (!repo.redeem_invite(vr.sub, new_hash)) { + callback(ErrorResponse::bad_request("invalid_token", + "This invitation has already been used or is no longer valid")); + return; + } + // Parity with applyReset: drop any sessions for this subject. + Security::Sessions::revoke_all(Security::Auth::get().config().cookies, vr.sub); + callback(Response::ok({{"message", "Account ready — you can now sign in."}})); + }); +} + +void AccountController::changePassword(const HttpRequestPtr& req, + std::function&& callback) { + API_REQUIRE_PRINCIPAL(req, callback, principal); + json body; + if (!Validation::parse_body(req, body, callback)) + return; + Validation::Errors errs; + // require_string on old_password: only new_password has a length + // validator to reject a non-string, so an int here used to reach + // get() and throw type_error.302 → bare 500. + Validation::require_string(errs, body, "old_password"); + Validation::require(errs, body, "new_password"); + Validation::string_length(errs, body, "new_password", Validation::kPasswordMinLen, Validation::kPasswordMaxLen); + if (errs.any()) { + callback(Validation::response_400(errs)); + return; + } + try { + auto user = verify_password_or_401( + principal->subject, body["old_password"].get(), "Original password is incorrect", callback); + if (!user) + return; + Repositories::UserRepository repo; + const std::string new_hash = Security::Password::hash(body["new_password"].get()); + repo.update_password_hash(user->id, new_hash); + // Revoke other sessions on password change (the current client + // will re-auth on its next refresh). Closes the "changed my + // password but the thief stays logged in" gap. + Security::Sessions::revoke_all(Security::Auth::get().config().cookies, user->id); + callback(Response::ok({{"message", "Password updated"}})); + } catch (const std::exception& e) { + spdlog::error("changePassword failed: {}", e.what()); + callback(ErrorResponse::internal_error()); + } +} + +const std::string& AccountController::secret() { + return Security::Auth::get().config().jwt_secret; +} + +std::optional AccountController::parse_new_password_body(const HttpRequestPtr& req, + std::function& callback) { + json body; + if (!Validation::parse_body(req, body, callback)) + return std::nullopt; + Validation::Errors errs; + Validation::require(errs, body, "new_password"); + Validation::string_length(errs, body, "new_password", Validation::kPasswordMinLen, Validation::kPasswordMaxLen); + if (errs.any()) { + callback(Validation::response_400(errs)); + return std::nullopt; + } + return body; +} + +std::optional AccountController::verify_password_or_401( + const std::string& subject, + const std::string& password, + const std::string& wrong_password_message, + const std::function& callback) { + Repositories::UserRepository repo; + auto user = repo.find(subject); + if (!user || !user->password_hash) { + callback(ErrorResponse::unauthorized("invalid_credentials")); + return std::nullopt; + } + if (!Security::Password::verify(password, *user->password_hash)) { + callback(ErrorResponse::unauthorized("invalid_credentials", wrong_password_message)); + return std::nullopt; + } + return user; +} + +bool AccountController::consume_once(const std::string& token, int ttl_sec) { + try { + const std::string hash = Utils::Crypto::sha256_hex(token); + return Database::get().execute_write([&](auto& txn) { + auto r = txn.exec_params( + "INSERT INTO used_tokens (token_hash, expires_at) " + "VALUES ($1, now() + make_interval(secs => $2)) " + "ON CONFLICT (token_hash) DO NOTHING RETURNING token_hash", + hash, + ttl_sec); + return !r.empty(); // a row came back ⇒ this is the first use + }); + } catch (const std::exception& e) { + spdlog::warn("consume_once: nonce write failed ({}) — refusing token (fail-closed)", e.what()); + return false; + } +} + +} // namespace Api diff --git a/src/api/AccountController.hpp b/src/api/AccountController.hpp index e44e57c..7eb5e64 100644 --- a/src/api/AccountController.hpp +++ b/src/api/AccountController.hpp @@ -10,32 +10,25 @@ * All these handlers are intentionally minimal — render template, * issue Tokens, persist via UserRepository. No business logic beyond * what flask-base already specified. + * + * Declarations only — the handler bodies live in AccountController.cpp + * (compiled once into app_core; ADR 0003 as amended 2026-08-22). The route + * macros (ADD_METHOD_TO) must stay in this header: Drogon's METHOD_LIST + * registration is part of the class definition, and + * scripts/check-routes-registered.sh greps the src/api headers for them. */ #pragma once +#include #include #include #include -#include -#include -#include +#include -#include "api/Guards.hpp" -#include "api/HandlerSupport.hpp" -#include "api/Validation.hpp" -#include "database/Database.hpp" #include "domain/User.hpp" -#include "email/AccountEmails.hpp" -#include "repositories/UserRepository.hpp" -#include "security/Auth.hpp" -#include "security/Password.hpp" -#include "security/SessionStore.hpp" -#include "security/Tokens.hpp" -#include "utils/Crypto.hpp" -#include "utils/ErrorResponse.hpp" namespace Api { @@ -62,26 +55,7 @@ class AccountController : public HttpController { // even an already-confirmed user can request, but we return 200 with // a no-op message rather than firing a redundant token. // --------------------------------------------------------------------- - void resendConfirm(const HttpRequestPtr& req, std::function&& callback) { - API_REQUIRE_PRINCIPAL(req, callback, principal); - try { - Repositories::UserRepository repo; - auto user = repo.find(principal->subject); - if (!user) { - callback(ErrorResponse::not_found("user")); - return; - } - if (user->confirmed) { - callback(Response::ok({{"message", "already confirmed"}})); - return; - } - Email::AccountEmails::send_confirm(*user); - callback(Response::ok({{"message", "confirmation email sent"}})); - } catch (const std::exception& e) { - spdlog::error("resendConfirm failed: {}", e.what()); - callback(ErrorResponse::internal_error()); - } - } + void resendConfirm(const HttpRequestPtr& req, std::function&& callback); // --------------------------------------------------------------------- // POST /api/account/confirm/{token} @@ -91,32 +65,7 @@ class AccountController : public HttpController { // --------------------------------------------------------------------- void confirm(const HttpRequestPtr& /*req*/, std::function&& callback, - const std::string& token) { - auto vr = Security::Tokens::verify(secret(), token, Security::Tokens::Purpose::Confirm); - if (!vr.ok) { - callback(ErrorResponse::bad_request("invalid_token", "Confirmation link is invalid or has expired")); - return; - } - // One-shot, consistent with applyReset/applyChangeEmail: a captured - // confirm link shouldn't stay replayable for the token's full (7-day) - // lifetime. TTL matches the confirm token lifetime so the replay guard - // outlives the token it protects. - if (!consume_once(token, /*ttl_sec=*/7 * 24 * 3600)) { - callback(ErrorResponse::bad_request("invalid_token", "Confirmation link has already been used")); - return; - } - try { - Repositories::UserRepository repo; - if (!repo.mark_confirmed(vr.sub)) { - callback(ErrorResponse::not_found("user")); - return; - } - callback(Response::ok({{"message", "Account confirmed"}})); - } catch (const std::exception& e) { - spdlog::error("confirm failed: {}", e.what()); - callback(ErrorResponse::internal_error()); - } - } + const std::string& token); // --------------------------------------------------------------------- // POST /api/account/reset-password-request @@ -125,37 +74,7 @@ class AccountController : public HttpController { // whether the email is registered (flask-base does the same with // its flash message wording). // --------------------------------------------------------------------- - void requestReset(const HttpRequestPtr& req, std::function&& callback) { - json body; - if (!Validation::parse_body(req, body, callback)) - return; - Validation::Errors errs; - Validation::require(errs, body, "email"); - Validation::email(errs, body, "email"); - if (errs.any()) { - callback(Validation::response_400(errs)); - return; - } - const std::string email = body["email"].get(); - try { - Repositories::UserRepository repo; - auto user = repo.find_by_email(email); - if (user) { - Email::AccountEmails::send_reset(*user); - } else { - // Debug level, no address: the probed email is PII and an - // info-level log would be a log-side enumeration channel - // undercutting the deliberate generic 200 below. - spdlog::debug("[reset-request] no matching user — silent ack"); - } - } catch (const std::exception& e) { - // Same generic 200 on backend trouble — a 500 here would leak - // that the lookup ran (and retrying costs the user nothing). - spdlog::error("requestReset failed: {}", e.what()); - } - // Generic 200 either way — no enumeration. - callback(Response::ok({{"message", "If that email is registered, a reset link is on its way."}})); - } + void requestReset(const HttpRequestPtr& req, std::function&& callback); // --------------------------------------------------------------------- // POST /api/account/reset-password/{token} @@ -164,32 +83,7 @@ class AccountController : public HttpController { // --------------------------------------------------------------------- void applyReset(const HttpRequestPtr& req, std::function&& callback, - const std::string& token) { - auto body = parse_new_password_body(req, callback); - if (!body) - return; - auto vr = Security::Tokens::verify(secret(), token, Security::Tokens::Purpose::ResetPassword); - if (!vr.ok) { - callback(ErrorResponse::bad_request("invalid_token", "Reset link is invalid or has expired")); - return; - } - // One-shot: a captured reset link must not be replayable to set the - // password a second time after the legitimate user used it. - if (!consume_once(token, /*ttl_sec=*/3600)) { - callback(ErrorResponse::bad_request("invalid_token", "Reset link has already been used")); - return; - } - with_repo_errors(callback, "applyReset", [&] { - Repositories::UserRepository repo; - const std::string new_hash = Security::Password::hash((*body)["new_password"].get()); - repo.update_password_hash(vr.sub, new_hash); - // Evict every existing session — a reset must lock out anyone - // holding an old refresh token (incl. an attacker who triggered - // the reset path). Best-effort; the new login mints a fresh one. - Security::Sessions::revoke_all(Security::Auth::get().config().cookies, vr.sub); - callback(Response::ok({{"message", "Password updated"}})); - }); - } + const std::string& token); // --------------------------------------------------------------------- // POST /api/account/change-email-request (auth required) @@ -198,34 +92,7 @@ class AccountController : public HttpController { // a token bearing the new_email, sends confirmation email to the // *new* address (not the old one — flask-base behaviour). // --------------------------------------------------------------------- - void requestChangeEmail(const HttpRequestPtr& req, std::function&& callback) { - API_REQUIRE_PRINCIPAL(req, callback, principal); - json body; - if (!Validation::parse_body(req, body, callback)) - return; - Validation::Errors errs; - Validation::require(errs, body, "new_email"); - Validation::email(errs, body, "new_email"); - // require_string: a non-string password reaches get() - // below and throws type_error.302 → bare 500 instead of a 400. - Validation::require_string(errs, body, "password"); - if (errs.any()) { - callback(Validation::response_400(errs)); - return; - } - try { - auto user = verify_password_or_401( - principal->subject, body["password"].get(), "Wrong password", callback); - if (!user) - return; - const std::string new_email = body["new_email"].get(); - Email::AccountEmails::send_change_email(*user, new_email); - callback(Response::ok({{"message", "Confirmation email sent to the new address."}})); - } catch (const std::exception& e) { - spdlog::error("requestChangeEmail failed: {}", e.what()); - callback(ErrorResponse::internal_error()); - } - } + void requestChangeEmail(const HttpRequestPtr& req, std::function&& callback); // --------------------------------------------------------------------- // POST /api/account/change-email/{token} @@ -235,39 +102,7 @@ class AccountController : public HttpController { // --------------------------------------------------------------------- void applyChangeEmail(const HttpRequestPtr& /*req*/, std::function&& callback, - const std::string& token) { - auto vr = Security::Tokens::verify(secret(), token, Security::Tokens::Purpose::ChangeEmail); - if (!vr.ok) { - callback(ErrorResponse::bad_request("invalid_token", "Change-email link is invalid or has expired")); - return; - } - const auto new_email_it = vr.extra.find("new_email"); - if (new_email_it == vr.extra.end() || !new_email_it->is_string()) { - callback(ErrorResponse::bad_request("invalid_token", "Token is missing the new email")); - return; - } - const std::string new_email = new_email_it->get(); - // Check availability BEFORE consuming the one-shot token, so a - // duplicate-email 409 doesn't permanently burn an otherwise-valid link. - // change_email's UNIQUE constraint still guards the check→write race. - { - Repositories::UserRepository repo; - auto taken = repo.find_by_email(new_email); - if (taken && taken->id != vr.sub) { - callback(ErrorResponse::conflict("email_taken", "That email address is already in use")); - return; - } - } - if (!consume_once(token, /*ttl_sec=*/3600)) { - callback(ErrorResponse::bad_request("invalid_token", "Change-email link has already been used")); - return; - } - with_repo_errors(callback, "applyChangeEmail", [&] { - Repositories::UserRepository repo; - repo.change_email(vr.sub, new_email); - callback(Response::ok({{"message", "Email updated"}})); - }); - } + const std::string& token); // --------------------------------------------------------------------- // POST /api/account/join-from-invite/{token} @@ -278,32 +113,7 @@ class AccountController : public HttpController { // --------------------------------------------------------------------- void joinFromInvite(const HttpRequestPtr& req, std::function&& callback, - const std::string& token) { - auto body = parse_new_password_body(req, callback); - if (!body) - return; - auto vr = Security::Tokens::verify(secret(), token, Security::Tokens::Purpose::Invite); - if (!vr.ok) { - callback(ErrorResponse::bad_request("invalid_token", "Invitation link is invalid or has expired")); - return; - } - with_repo_errors(callback, "joinFromInvite", [&] { - Repositories::UserRepository repo; - const std::string new_hash = Security::Password::hash((*body)["new_password"].get()); - // redeem_invite is a DB-level one-shot (only matches a still-pending - // invite), so a replayed link can't reset an active account — no - // need for the fail-open Redis guard here. Doing the write first - // also means a transient failure doesn't burn the 7-day token. - if (!repo.redeem_invite(vr.sub, new_hash)) { - callback(ErrorResponse::bad_request("invalid_token", - "This invitation has already been used or is no longer valid")); - return; - } - // Parity with applyReset: drop any sessions for this subject. - Security::Sessions::revoke_all(Security::Auth::get().config().cookies, vr.sub); - callback(Response::ok({{"message", "Account ready — you can now sign in."}})); - }); - } + const std::string& token); // --------------------------------------------------------------------- // POST /api/account/change-password (auth required) @@ -314,45 +124,10 @@ class AccountController : public HttpController { // flask-base. If you need session-rotation, mint a fresh refresh // pair after the change. // --------------------------------------------------------------------- - void changePassword(const HttpRequestPtr& req, std::function&& callback) { - API_REQUIRE_PRINCIPAL(req, callback, principal); - json body; - if (!Validation::parse_body(req, body, callback)) - return; - Validation::Errors errs; - // require_string on old_password: only new_password has a length - // validator to reject a non-string, so an int here used to reach - // get() and throw type_error.302 → bare 500. - Validation::require_string(errs, body, "old_password"); - Validation::require(errs, body, "new_password"); - Validation::string_length(errs, body, "new_password", Validation::kPasswordMinLen, Validation::kPasswordMaxLen); - if (errs.any()) { - callback(Validation::response_400(errs)); - return; - } - try { - auto user = verify_password_or_401(principal->subject, - body["old_password"].get(), - "Original password is incorrect", - callback); - if (!user) - return; - Repositories::UserRepository repo; - const std::string new_hash = Security::Password::hash(body["new_password"].get()); - repo.update_password_hash(user->id, new_hash); - // Revoke other sessions on password change (the current client - // will re-auth on its next refresh). Closes the "changed my - // password but the thief stays logged in" gap. - Security::Sessions::revoke_all(Security::Auth::get().config().cookies, user->id); - callback(Response::ok({{"message", "Password updated"}})); - } catch (const std::exception& e) { - spdlog::error("changePassword failed: {}", e.what()); - callback(ErrorResponse::internal_error()); - } - } + void changePassword(const HttpRequestPtr& req, std::function&& callback); private: - static const std::string& secret() { return Security::Auth::get().config().jwt_secret; } + static const std::string& secret(); /** * @brief Parse the request body and validate its "new_password" field @@ -361,19 +136,7 @@ class AccountController : public HttpController { * @p callback itself and returns nullopt on any failure. */ static std::optional parse_new_password_body(const HttpRequestPtr& req, - std::function& callback) { - json body; - if (!Validation::parse_body(req, body, callback)) - return std::nullopt; - Validation::Errors errs; - Validation::require(errs, body, "new_password"); - Validation::string_length(errs, body, "new_password", Validation::kPasswordMinLen, Validation::kPasswordMaxLen); - if (errs.any()) { - callback(Validation::response_400(errs)); - return std::nullopt; - } - return body; - } + std::function& callback); /** * @brief Load the user behind @p subject and verify @p password against its @@ -387,19 +150,7 @@ class AccountController : public HttpController { const std::string& subject, const std::string& password, const std::string& wrong_password_message, - const std::function& callback) { - Repositories::UserRepository repo; - auto user = repo.find(subject); - if (!user || !user->password_hash) { - callback(ErrorResponse::unauthorized("invalid_credentials")); - return std::nullopt; - } - if (!Security::Password::verify(password, *user->password_hash)) { - callback(ErrorResponse::unauthorized("invalid_credentials", wrong_password_message)); - return std::nullopt; - } - return user; - } + const std::function& callback); /** * @brief Atomically consume a one-shot token: returns true the FIRST time @@ -411,23 +162,7 @@ class AccountController : public HttpController { * the DB anyway, so refusing the token is the safe choice. Never * throws — callers consume it outside with_repo_errors. */ - static bool consume_once(const std::string& token, int ttl_sec) { - try { - const std::string hash = Utils::Crypto::sha256_hex(token); - return Database::get().execute_write([&](auto& txn) { - auto r = txn.exec_params( - "INSERT INTO used_tokens (token_hash, expires_at) " - "VALUES ($1, now() + make_interval(secs => $2)) " - "ON CONFLICT (token_hash) DO NOTHING RETURNING token_hash", - hash, - ttl_sec); - return !r.empty(); // a row came back ⇒ this is the first use - }); - } catch (const std::exception& e) { - spdlog::warn("consume_once: nonce write failed ({}) — refusing token (fail-closed)", e.what()); - return false; - } - } + static bool consume_once(const std::string& token, int ttl_sec); }; } // namespace Api diff --git a/src/api/AdminController.cpp b/src/api/AdminController.cpp new file mode 100644 index 0000000..7493d14 --- /dev/null +++ b/src/api/AdminController.cpp @@ -0,0 +1,350 @@ +/** + * @file AdminController.cpp + * @brief Bodies for src/api/AdminController.hpp — compiled once into + * app_core. The route list, self-protection rules and helper + * contracts are documented on the declarations in the header. + */ + +#include "api/AdminController.hpp" + +#include + +#include + +#include "api/Guards.hpp" +#include "api/HandlerSupport.hpp" +#include "api/RequestUtils.hpp" +#include "api/Validation.hpp" +#include "email/AccountEmails.hpp" +#include "repositories/RoleRepository.hpp" +#include "repositories/UserRepository.hpp" +#include "security/Audit.hpp" +#include "security/Auth.hpp" +#include "security/Password.hpp" +#include "utils/ErrorResponse.hpp" + +namespace Api { + +void AdminController::listUsers(const HttpRequestPtr& req, std::function&& callback) { + API_REQUIRE_ADMIN(req, callback); + const auto page = parse_page_params(req, /*default_limit=*/50, /*max_limit=*/200); + + with_repo_errors(callback, "admin listUsers", [&] { + Repositories::UserRepository repo; + auto users = repo.list(page.limit, page.offset); + long total = repo.count(); + callback(Response::paginated(to_json_array(users), total, page.limit, page.offset)); + }); +} + +void AdminController::createUser(const HttpRequestPtr& req, std::function&& callback) { + API_REQUIRE_ADMIN(req, callback); + json body; + if (!Validation::parse_body(req, body, callback)) + return; + Validation::Errors errs; + Validation::require(errs, body, "email"); + Validation::require(errs, body, "password"); + Validation::email(errs, body, "email"); + Validation::string_length(errs, body, "password", Validation::kPasswordMinLen, Validation::kPasswordMaxLen); + if (errs.any()) { + callback(Validation::response_400(errs)); + return; + } + // role_id optional — defaults to "User" role. + auto role = resolve_role(body, "Role does not exist", callback); + if (!role) + return; + + with_repo_errors(callback, "admin createUser", [&] { + const std::string hash = Security::Password::hash(body["password"].get()); + // Admin-created users land already-confirmed by default — + // matches flask-base where /admin/new-user skips the email + // confirmation step. + Repositories::UserRepository users; + auto created = users.create(body["email"].get(), + hash, + Validation::opt_string(body, "first_name"), + Validation::opt_string(body, "last_name"), + role->id, + /*confirmed=*/true); + // Attach the role we already loaded instead of re-querying. + created.role = *role; + Security::Audit::record(actor_of(req), "user.create", "user", created.id, {{"email", created.email}}); + callback(Response::created({{"data", json(created)}})); + }); +} + +void AdminController::inviteUser(const HttpRequestPtr& req, std::function&& callback) { + API_REQUIRE_ADMIN(req, callback); + json body; + if (!Validation::parse_body(req, body, callback)) + return; + Validation::Errors errs; + Validation::require(errs, body, "email"); + Validation::email(errs, body, "email"); + if (errs.any()) { + callback(Validation::response_400(errs)); + return; + } + auto role = resolve_role(body, /*invalid_message=*/"", callback); + if (!role) + return; + + with_repo_errors(callback, "admin inviteUser", [&] { + Repositories::UserRepository users; + // No password yet — they'll set one via the invite link. + auto created = users.create(body["email"].get(), + std::nullopt, + Validation::opt_string(body, "first_name"), + Validation::opt_string(body, "last_name"), + role->id, + /*confirmed=*/false); + // Attach the role we already loaded instead of re-querying. + created.role = *role; + Email::AccountEmails::send_invite(created); + Security::Audit::record(actor_of(req), "user.invite", "user", created.id, {{"email", created.email}}); + callback(Response::created({{"data", json(created)}, {"message", "Invitation sent"}})); + }); +} + +void AdminController::getUser(const HttpRequestPtr& req, + std::function&& callback, + const std::string& id) { + API_REQUIRE_ADMIN(req, callback); + if (!require_user_id(id, callback)) + return; + with_repo_errors(callback, "admin getUser", [&] { + Repositories::UserRepository repo; + auto user = repo.find(id); + if (!user) { + callback(ErrorResponse::not_found("user")); + return; + } + callback(Response::ok({{"data", *user}})); + }); +} + +void AdminController::updateUser(const HttpRequestPtr& req, + std::function&& callback, + const std::string& id) { + API_REQUIRE_ADMIN(req, callback); + if (!require_user_id(id, callback)) + return; + json body; + if (!Validation::parse_body(req, body, callback)) + return; + + // Self-protection: an admin can't change their own role away from + // admin (flask-base does the same check). Otherwise the very last + // admin can lock everyone out by accident. + auto principal = Security::Auth::principal_of(req); + const bool changing_self = principal && principal->subject == id; + + Repositories::UserRepository users; + + with_repo_errors(callback, "admin updateUser", [&] { + std::optional new_email; + if (body.contains("email") && body["email"].is_string()) { + Validation::Errors e; + Validation::email(e, body, "email"); + if (e.any()) { + callback(Validation::response_400(e)); + return; + } + new_email = body["email"].get(); + } + std::optional new_role_id; + if (body.contains("role_id") && body["role_id"].is_number_integer()) { + if (changing_self) { + callback(ErrorResponse::bad_request( + "self_role_change", "You cannot change the role of your own account; ask another admin")); + return; + } + const int requested_role_id = body["role_id"].get(); + Repositories::RoleRepository roles; + if (!roles.find(requested_role_id)) { + callback(ErrorResponse::bad_request("invalid_role")); + return; + } + new_role_id = requested_role_id; + } + const auto first_name = Validation::opt_string(body, "first_name"); + const auto last_name = Validation::opt_string(body, "last_name"); + + // One repository call → one transaction: a constraint failure + // (e.g. duplicate email) can't leave the role half-changed the + // way three sequential mutations could. + if (new_email || new_role_id || first_name || last_name) { + users.admin_update(id, new_email, new_role_id, first_name, last_name); + } + // Read from the primary so the echoed row reflects the write we + // just made — a lagging replica would return the pre-update values. + auto fresh = users.find(id, /*from_primary=*/true); + if (!fresh) { + callback(ErrorResponse::not_found("user")); + return; + } + Security::Audit::record(actor_of(req), "user.update", "user", id); + callback(Response::ok({{"data", *fresh}})); + }); +} + +void AdminController::deleteUser(const HttpRequestPtr& req, + std::function&& callback, + const std::string& id) { + API_REQUIRE_ADMIN(req, callback); + if (!require_user_id(id, callback)) + return; + // Self-protection — flask-base parity: app/admin/views.py + // delete_user explicitly refuses to delete current_user. + auto principal = Security::Auth::principal_of(req); + if (principal && principal->subject == id) { + callback(ErrorResponse::bad_request("self_delete", "You cannot delete your own account; ask another admin")); + return; + } + with_repo_errors(callback, "admin deleteUser", [&] { + Repositories::UserRepository repo; + repo.remove(id); + Security::Audit::record(actor_of(req), "user.delete", "user", id); + callback(Response::ok({{"message", "User deleted"}})); + }); +} + +void AdminController::listRoles(const HttpRequestPtr& req, std::function&& callback) { + API_REQUIRE_ADMIN(req, callback); + with_repo_errors(callback, "admin listRoles", [&] { + Repositories::RoleRepository repo; + // CrudBase::list defaults to LIMIT 100; roles are few but pass a + // high cap so the list isn't silently truncated (was unbounded + // before the CrudBase refactor). + auto roles = repo.list(1000); + callback(Response::list(to_json_array(roles))); + }); +} + +void AdminController::createRole(const HttpRequestPtr& req, std::function&& callback) { + API_REQUIRE_ADMIN(req, callback); + json body; + if (!Validation::parse_body(req, body, callback)) + return; + Validation::Errors errs; + Validation::require(errs, body, "name"); + Validation::string_length(errs, body, "name", 1, 64); + if (!body.contains("permissions") || !body["permissions"].is_number_integer()) { + errs.add("permissions", "invalid_type", "must be an integer bitmask"); + } + Validation::boolean(errs, body, "is_default"); + if (errs.any()) { + callback(Validation::response_400(errs)); + return; + } + const auto perms = static_cast(body["permissions"].get()); + // Explicit null passes Validation::boolean (absent == null == "default"), + // but body.value(k, false) would throw type_error.302 on it. + const bool has_is_default = body.contains("is_default") && body["is_default"].is_boolean(); + const bool is_default = has_is_default && body["is_default"].get(); + with_repo_errors(callback, "admin createRole", [&] { + Repositories::RoleRepository repo; + auto created = repo.create(body["name"].get(), perms, is_default); + Security::Audit::record( + actor_of(req), "role.create", "role", std::to_string(created.id), {{"name", created.name}}); + callback(Response::created({{"data", json(created)}})); + }); +} + +void AdminController::updateRole(const HttpRequestPtr& req, + std::function&& callback, + const std::string& id_str) { + API_REQUIRE_ADMIN(req, callback); + const auto role_id = require_role_id(id_str, callback); + if (!role_id) + return; + const int id = *role_id; + json body; + if (!Validation::parse_body(req, body, callback)) + return; + std::optional name; + std::optional permissions; + std::optional is_default; + if (body.contains("name") && body["name"].is_string()) + name = body["name"].get(); + if (body.contains("permissions") && body["permissions"].is_number_integer()) + permissions = static_cast(body["permissions"].get()); + if (body.contains("is_default") && body["is_default"].is_boolean()) + is_default = body["is_default"].get(); + if (!name && !permissions && !is_default) { + callback(ErrorResponse::bad_request("empty_patch", "Provide at least one of name / permissions / is_default")); + return; + } + with_repo_errors(callback, "admin updateRole", [&] { + Repositories::RoleRepository repo; + auto updated = repo.update(id, name, permissions, is_default); + Security::Audit::record(actor_of(req), "role.update", "role", std::to_string(id)); + callback(Response::ok({{"data", json(updated)}})); + }); +} + +void AdminController::deleteRole(const HttpRequestPtr& req, + std::function&& callback, + const std::string& id_str) { + API_REQUIRE_ADMIN(req, callback); + const auto role_id = require_role_id(id_str, callback); + if (!role_id) + return; + const int id = *role_id; + with_repo_errors(callback, "admin deleteRole", [&] { + Repositories::RoleRepository repo; + // Self-protection: refuse to delete the default role — + // future sign-ups would have nowhere to land. + auto existing = repo.find(id); + if (existing && existing->is_default) { + callback(ErrorResponse::bad_request( + "default_role_protected", "The default role cannot be deleted; promote another role to default first")); + return; + } + repo.remove(id); + Security::Audit::record(actor_of(req), "role.delete", "role", std::to_string(id)); + callback(Response::ok({{"message", "Role deleted"}})); + }); +} + +std::string AdminController::actor_of(const HttpRequestPtr& req) { + auto p = Security::Auth::principal_of(req); + return p ? p->subject : std::string{}; +} + +std::optional AdminController::resolve_role(const json& body, + const std::string& invalid_message, + const std::function& callback) { + std::optional requested_role_id; + if (body.contains("role_id") && body["role_id"].is_number_integer()) + requested_role_id = body["role_id"].get(); + Repositories::RoleRepository roles; + auto role = requested_role_id ? roles.find(*requested_role_id) : roles.find_default(); + if (!role) { + callback(ErrorResponse::bad_request("invalid_role", invalid_message)); + return std::nullopt; + } + return role; +} + +bool AdminController::require_user_id(const std::string& id, + const std::function& callback) { + if (is_valid_uuid(id)) + return true; + callback(ErrorResponse::bad_request("invalid_id", "Malformed user id")); + return false; +} + +std::optional AdminController::require_role_id(const std::string& id_str, + const std::function& callback) { + const int id = parse_int(id_str, -1); + if (id <= 0) { + callback(ErrorResponse::bad_request("invalid_id")); + return std::nullopt; + } + return id; +} + +} // namespace Api diff --git a/src/api/AdminController.hpp b/src/api/AdminController.hpp index 39bee95..3457087 100644 --- a/src/api/AdminController.hpp +++ b/src/api/AdminController.hpp @@ -13,32 +13,25 @@ * PATCH /api/admin/users/{id} partial update (email / role / first/last name) * DELETE /api/admin/users/{id} delete user * GET /api/admin/roles list roles + * + * Declarations only — the handler bodies live in AdminController.cpp + * (compiled once into app_core; ADR 0003 as amended 2026-08-22). The route + * macros (ADD_METHOD_TO) must stay in this header: Drogon's METHOD_LIST + * registration is part of the class definition, and + * scripts/check-routes-registered.sh greps the src/api headers for them. */ #pragma once +#include #include #include #include -#include -#include -#include +#include -#include "api/Guards.hpp" -#include "api/HandlerSupport.hpp" -#include "api/RequestUtils.hpp" -#include "api/Validation.hpp" #include "domain/Role.hpp" -#include "domain/User.hpp" -#include "email/AccountEmails.hpp" -#include "repositories/RoleRepository.hpp" -#include "repositories/UserRepository.hpp" -#include "security/Audit.hpp" -#include "security/Auth.hpp" -#include "security/Password.hpp" -#include "utils/ErrorResponse.hpp" namespace Api { @@ -60,299 +53,39 @@ class AdminController : public HttpController { ADD_METHOD_TO(AdminController::deleteRole, "/api/v1/admin/roles/{1}", Delete); METHOD_LIST_END - void listUsers(const HttpRequestPtr& req, std::function&& callback) { - API_REQUIRE_ADMIN(req, callback); - const auto page = parse_page_params(req, /*default_limit=*/50, /*max_limit=*/200); - - with_repo_errors(callback, "admin listUsers", [&] { - Repositories::UserRepository repo; - auto users = repo.list(page.limit, page.offset); - long total = repo.count(); - callback(Response::paginated(to_json_array(users), total, page.limit, page.offset)); - }); - } - - void createUser(const HttpRequestPtr& req, std::function&& callback) { - API_REQUIRE_ADMIN(req, callback); - json body; - if (!Validation::parse_body(req, body, callback)) - return; - Validation::Errors errs; - Validation::require(errs, body, "email"); - Validation::require(errs, body, "password"); - Validation::email(errs, body, "email"); - Validation::string_length(errs, body, "password", Validation::kPasswordMinLen, Validation::kPasswordMaxLen); - if (errs.any()) { - callback(Validation::response_400(errs)); - return; - } - // role_id optional — defaults to "User" role. - auto role = resolve_role(body, "Role does not exist", callback); - if (!role) - return; - - with_repo_errors(callback, "admin createUser", [&] { - const std::string hash = Security::Password::hash(body["password"].get()); - // Admin-created users land already-confirmed by default — - // matches flask-base where /admin/new-user skips the email - // confirmation step. - Repositories::UserRepository users; - auto created = users.create(body["email"].get(), - hash, - Validation::opt_string(body, "first_name"), - Validation::opt_string(body, "last_name"), - role->id, - /*confirmed=*/true); - // Attach the role we already loaded instead of re-querying. - created.role = *role; - Security::Audit::record(actor_of(req), "user.create", "user", created.id, {{"email", created.email}}); - callback(Response::created({{"data", json(created)}})); - }); - } + void listUsers(const HttpRequestPtr& req, std::function&& callback); - void inviteUser(const HttpRequestPtr& req, std::function&& callback) { - API_REQUIRE_ADMIN(req, callback); - json body; - if (!Validation::parse_body(req, body, callback)) - return; - Validation::Errors errs; - Validation::require(errs, body, "email"); - Validation::email(errs, body, "email"); - if (errs.any()) { - callback(Validation::response_400(errs)); - return; - } - auto role = resolve_role(body, /*invalid_message=*/"", callback); - if (!role) - return; + void createUser(const HttpRequestPtr& req, std::function&& callback); - with_repo_errors(callback, "admin inviteUser", [&] { - Repositories::UserRepository users; - // No password yet — they'll set one via the invite link. - auto created = users.create(body["email"].get(), - std::nullopt, - Validation::opt_string(body, "first_name"), - Validation::opt_string(body, "last_name"), - role->id, - /*confirmed=*/false); - // Attach the role we already loaded instead of re-querying. - created.role = *role; - Email::AccountEmails::send_invite(created); - Security::Audit::record(actor_of(req), "user.invite", "user", created.id, {{"email", created.email}}); - callback(Response::created({{"data", json(created)}, {"message", "Invitation sent"}})); - }); - } + void inviteUser(const HttpRequestPtr& req, std::function&& callback); void getUser(const HttpRequestPtr& req, std::function&& callback, - const std::string& id) { - API_REQUIRE_ADMIN(req, callback); - if (!require_user_id(id, callback)) - return; - with_repo_errors(callback, "admin getUser", [&] { - Repositories::UserRepository repo; - auto user = repo.find(id); - if (!user) { - callback(ErrorResponse::not_found("user")); - return; - } - callback(Response::ok({{"data", *user}})); - }); - } + const std::string& id); void updateUser(const HttpRequestPtr& req, std::function&& callback, - const std::string& id) { - API_REQUIRE_ADMIN(req, callback); - if (!require_user_id(id, callback)) - return; - json body; - if (!Validation::parse_body(req, body, callback)) - return; - - // Self-protection: an admin can't change their own role away from - // admin (flask-base does the same check). Otherwise the very last - // admin can lock everyone out by accident. - auto principal = Security::Auth::principal_of(req); - const bool changing_self = principal && principal->subject == id; - - Repositories::UserRepository users; - - with_repo_errors(callback, "admin updateUser", [&] { - std::optional new_email; - if (body.contains("email") && body["email"].is_string()) { - Validation::Errors e; - Validation::email(e, body, "email"); - if (e.any()) { - callback(Validation::response_400(e)); - return; - } - new_email = body["email"].get(); - } - std::optional new_role_id; - if (body.contains("role_id") && body["role_id"].is_number_integer()) { - if (changing_self) { - callback(ErrorResponse::bad_request( - "self_role_change", "You cannot change the role of your own account; ask another admin")); - return; - } - const int requested_role_id = body["role_id"].get(); - Repositories::RoleRepository roles; - if (!roles.find(requested_role_id)) { - callback(ErrorResponse::bad_request("invalid_role")); - return; - } - new_role_id = requested_role_id; - } - const auto first_name = Validation::opt_string(body, "first_name"); - const auto last_name = Validation::opt_string(body, "last_name"); - - // One repository call → one transaction: a constraint failure - // (e.g. duplicate email) can't leave the role half-changed the - // way three sequential mutations could. - if (new_email || new_role_id || first_name || last_name) { - users.admin_update(id, new_email, new_role_id, first_name, last_name); - } - // Read from the primary so the echoed row reflects the write we - // just made — a lagging replica would return the pre-update values. - auto fresh = users.find(id, /*from_primary=*/true); - if (!fresh) { - callback(ErrorResponse::not_found("user")); - return; - } - Security::Audit::record(actor_of(req), "user.update", "user", id); - callback(Response::ok({{"data", *fresh}})); - }); - } + const std::string& id); void deleteUser(const HttpRequestPtr& req, std::function&& callback, - const std::string& id) { - API_REQUIRE_ADMIN(req, callback); - if (!require_user_id(id, callback)) - return; - // Self-protection — flask-base parity: app/admin/views.py - // delete_user explicitly refuses to delete current_user. - auto principal = Security::Auth::principal_of(req); - if (principal && principal->subject == id) { - callback( - ErrorResponse::bad_request("self_delete", "You cannot delete your own account; ask another admin")); - return; - } - with_repo_errors(callback, "admin deleteUser", [&] { - Repositories::UserRepository repo; - repo.remove(id); - Security::Audit::record(actor_of(req), "user.delete", "user", id); - callback(Response::ok({{"message", "User deleted"}})); - }); - } + const std::string& id); - void listRoles(const HttpRequestPtr& req, std::function&& callback) { - API_REQUIRE_ADMIN(req, callback); - with_repo_errors(callback, "admin listRoles", [&] { - Repositories::RoleRepository repo; - // CrudBase::list defaults to LIMIT 100; roles are few but pass a - // high cap so the list isn't silently truncated (was unbounded - // before the CrudBase refactor). - auto roles = repo.list(1000); - callback(Response::list(to_json_array(roles))); - }); - } + void listRoles(const HttpRequestPtr& req, std::function&& callback); - void createRole(const HttpRequestPtr& req, std::function&& callback) { - API_REQUIRE_ADMIN(req, callback); - json body; - if (!Validation::parse_body(req, body, callback)) - return; - Validation::Errors errs; - Validation::require(errs, body, "name"); - Validation::string_length(errs, body, "name", 1, 64); - if (!body.contains("permissions") || !body["permissions"].is_number_integer()) { - errs.add("permissions", "invalid_type", "must be an integer bitmask"); - } - Validation::boolean(errs, body, "is_default"); - if (errs.any()) { - callback(Validation::response_400(errs)); - return; - } - const auto perms = static_cast(body["permissions"].get()); - // Explicit null passes Validation::boolean (absent == null == "default"), - // but body.value(k, false) would throw type_error.302 on it. - const bool has_is_default = body.contains("is_default") && body["is_default"].is_boolean(); - const bool is_default = has_is_default && body["is_default"].get(); - with_repo_errors(callback, "admin createRole", [&] { - Repositories::RoleRepository repo; - auto created = repo.create(body["name"].get(), perms, is_default); - Security::Audit::record( - actor_of(req), "role.create", "role", std::to_string(created.id), {{"name", created.name}}); - callback(Response::created({{"data", json(created)}})); - }); - } + void createRole(const HttpRequestPtr& req, std::function&& callback); void updateRole(const HttpRequestPtr& req, std::function&& callback, - const std::string& id_str) { - API_REQUIRE_ADMIN(req, callback); - const auto role_id = require_role_id(id_str, callback); - if (!role_id) - return; - const int id = *role_id; - json body; - if (!Validation::parse_body(req, body, callback)) - return; - std::optional name; - std::optional permissions; - std::optional is_default; - if (body.contains("name") && body["name"].is_string()) - name = body["name"].get(); - if (body.contains("permissions") && body["permissions"].is_number_integer()) - permissions = static_cast(body["permissions"].get()); - if (body.contains("is_default") && body["is_default"].is_boolean()) - is_default = body["is_default"].get(); - if (!name && !permissions && !is_default) { - callback( - ErrorResponse::bad_request("empty_patch", "Provide at least one of name / permissions / is_default")); - return; - } - with_repo_errors(callback, "admin updateRole", [&] { - Repositories::RoleRepository repo; - auto updated = repo.update(id, name, permissions, is_default); - Security::Audit::record(actor_of(req), "role.update", "role", std::to_string(id)); - callback(Response::ok({{"data", json(updated)}})); - }); - } + const std::string& id_str); void deleteRole(const HttpRequestPtr& req, std::function&& callback, - const std::string& id_str) { - API_REQUIRE_ADMIN(req, callback); - const auto role_id = require_role_id(id_str, callback); - if (!role_id) - return; - const int id = *role_id; - with_repo_errors(callback, "admin deleteRole", [&] { - Repositories::RoleRepository repo; - // Self-protection: refuse to delete the default role — - // future sign-ups would have nowhere to land. - auto existing = repo.find(id); - if (existing && existing->is_default) { - callback(ErrorResponse::bad_request( - "default_role_protected", - "The default role cannot be deleted; promote another role to default first")); - return; - } - repo.remove(id); - Security::Audit::record(actor_of(req), "role.delete", "role", std::to_string(id)); - callback(Response::ok({{"message", "Role deleted"}})); - }); - } + const std::string& id_str); private: /// Acting admin's principal subject for the audit trail ("" when auth off). - static std::string actor_of(const HttpRequestPtr& req) { - auto p = Security::Auth::principal_of(req); - return p ? p->subject : std::string{}; - } + static std::string actor_of(const HttpRequestPtr& req); /** * @brief Resolve the optional "role_id" in @p body (defaults to the @@ -363,41 +96,18 @@ class AdminController : public HttpController { */ static std::optional resolve_role(const json& body, const std::string& invalid_message, - const std::function& callback) { - std::optional requested_role_id; - if (body.contains("role_id") && body["role_id"].is_number_integer()) - requested_role_id = body["role_id"].get(); - Repositories::RoleRepository roles; - auto role = requested_role_id ? roles.find(*requested_role_id) : roles.find_default(); - if (!role) { - callback(ErrorResponse::bad_request("invalid_role", invalid_message)); - return std::nullopt; - } - return role; - } + const std::function& callback); /// Reject a malformed user-id path param with the admin surface's /// published 400 shape — code "invalid_id" (NOT Guards' "invalid_uuid"). /// Returns false after responding — callers /// `if (!require_user_id(id, callback)) return;`. - static bool require_user_id(const std::string& id, const std::function& callback) { - if (is_valid_uuid(id)) - return true; - callback(ErrorResponse::bad_request("invalid_id", "Malformed user id")); - return false; - } + static bool require_user_id(const std::string& id, const std::function& callback); /// Parse a role-id path param; a non-positive or non-numeric value gets /// the shared bare 400 invalid_id. Returns nullopt after responding. static std::optional require_role_id(const std::string& id_str, - const std::function& callback) { - const int id = parse_int(id_str, -1); - if (id <= 0) { - callback(ErrorResponse::bad_request("invalid_id")); - return std::nullopt; - } - return id; - } + const std::function& callback); }; } // namespace Api diff --git a/src/api/ApiKeyController.cpp b/src/api/ApiKeyController.cpp new file mode 100644 index 0000000..1851c0d --- /dev/null +++ b/src/api/ApiKeyController.cpp @@ -0,0 +1,68 @@ +/** + * @file ApiKeyController.cpp + * @brief Bodies for src/api/ApiKeyController.hpp — compiled once into + * app_core. Contract and the show-the-secret-once rule are documented + * on the declarations in the header. + */ + +#include "api/ApiKeyController.hpp" + +#include + +#include "api/Guards.hpp" +#include "api/HandlerSupport.hpp" +#include "api/Validation.hpp" +#include "repositories/ApiKeyRepository.hpp" +#include "security/ApiKeys.hpp" +#include "utils/ErrorResponse.hpp" + +namespace Api { + +void ApiKeyController::list(const HttpRequestPtr& req, std::function&& callback) { + API_REQUIRE_OWNER(req, callback, owner); + Repositories::ApiKeyRepository repo; + auto keys = repo.list_for_user(owner); + const json data = to_json_array(keys); + callback(Response::ok({{"data", data}, {"total", data.size()}})); +} + +void ApiKeyController::create(const HttpRequestPtr& req, std::function&& callback) { + API_REQUIRE_OWNER(req, callback, owner); + json body; + if (!Validation::parse_body(req, body, callback)) + return; + Validation::Errors errs; + // require_string: a non-string "name" would reach get() + // below and throw type_error.302 → bare 500 instead of a 400. + Validation::require_string(errs, body, "name"); + if (errs.any()) { + callback(Validation::response_400(errs)); + return; + } + + const auto gen = Security::ApiKeys::generate(); + Repositories::ApiKeyRepository repo; + auto key = repo.create(owner, body["name"].get(), gen.key_hash, gen.prefix); + + // The plaintext key is surfaced ONCE here; it is never stored (only its + // hash) and can never be shown again. The client must save it now. + json out = key; + out["key"] = gen.plaintext; + callback(Response::created(out)); +} + +void ApiKeyController::remove(const HttpRequestPtr& req, + std::function&& callback, + const std::string& id) { + API_REQUIRE_OWNER(req, callback, owner); + Repositories::ApiKeyRepository repo; + if (!repo.revoke(id, owner)) { + // Not found, already revoked, or someone else's key — all the same + // 404 so a caller can't probe which key ids exist. + callback(ErrorResponse::not_found("api_key")); + return; + } + callback(Response::ok({{"message", "API key revoked"}})); +} + +} // namespace Api diff --git a/src/api/ApiKeyController.hpp b/src/api/ApiKeyController.hpp index d677374..af41ab7 100644 --- a/src/api/ApiKeyController.hpp +++ b/src/api/ApiKeyController.hpp @@ -3,6 +3,12 @@ * @brief Manage the caller's own API keys: create / list / revoke. Owner-scoped * (API_REQUIRE_OWNER) so a user only ever sees or revokes their own keys. * The secret is returned exactly ONCE, from create(). + * + * Declarations only — the handler bodies live in ApiKeyController.cpp + * (compiled once into app_core; ADR 0003 as amended 2026-08-22). The route + * macros (ADD_METHOD_TO) must stay in this header: Drogon's METHOD_LIST + * registration is part of the class definition, and + * scripts/check-routes-registered.sh greps the src/api headers for them. */ #pragma once @@ -12,14 +18,7 @@ #include -#include - -#include "api/Guards.hpp" -#include "api/HandlerSupport.hpp" -#include "api/Validation.hpp" -#include "repositories/ApiKeyRepository.hpp" -#include "security/ApiKeys.hpp" -#include "utils/ErrorResponse.hpp" +#include namespace Api { @@ -34,52 +33,13 @@ class ApiKeyController : public HttpController { ADD_METHOD_TO(ApiKeyController::remove, "/api/v1/account/api-keys/{1}", Delete); METHOD_LIST_END - void list(const HttpRequestPtr& req, std::function&& callback) { - API_REQUIRE_OWNER(req, callback, owner); - Repositories::ApiKeyRepository repo; - auto keys = repo.list_for_user(owner); - const json data = to_json_array(keys); - callback(Response::ok({{"data", data}, {"total", data.size()}})); - } - - void create(const HttpRequestPtr& req, std::function&& callback) { - API_REQUIRE_OWNER(req, callback, owner); - json body; - if (!Validation::parse_body(req, body, callback)) - return; - Validation::Errors errs; - // require_string: a non-string "name" would reach get() - // below and throw type_error.302 → bare 500 instead of a 400. - Validation::require_string(errs, body, "name"); - if (errs.any()) { - callback(Validation::response_400(errs)); - return; - } - - const auto gen = Security::ApiKeys::generate(); - Repositories::ApiKeyRepository repo; - auto key = repo.create(owner, body["name"].get(), gen.key_hash, gen.prefix); + void list(const HttpRequestPtr& req, std::function&& callback); - // The plaintext key is surfaced ONCE here; it is never stored (only its - // hash) and can never be shown again. The client must save it now. - json out = key; - out["key"] = gen.plaintext; - callback(Response::created(out)); - } + void create(const HttpRequestPtr& req, std::function&& callback); void remove(const HttpRequestPtr& req, std::function&& callback, - const std::string& id) { - API_REQUIRE_OWNER(req, callback, owner); - Repositories::ApiKeyRepository repo; - if (!repo.revoke(id, owner)) { - // Not found, already revoked, or someone else's key — all the same - // 404 so a caller can't probe which key ids exist. - callback(ErrorResponse::not_found("api_key")); - return; - } - callback(Response::ok({{"message", "API key revoked"}})); - } + const std::string& id); }; } // namespace Api diff --git a/src/api/AuditController.cpp b/src/api/AuditController.cpp new file mode 100644 index 0000000..9dda1da --- /dev/null +++ b/src/api/AuditController.cpp @@ -0,0 +1,48 @@ +/** + * @file AuditController.cpp + * @brief Bodies for src/api/AuditController.hpp — compiled once into + * app_core. Contract and the kAuditRead permission gating are + * documented on the declarations in the header. + */ + +#include "api/AuditController.hpp" + +#include +#include + +#include + +#include "api/Guards.hpp" +#include "api/HandlerSupport.hpp" +#include "api/RequestUtils.hpp" +#include "domain/AuditEntry.hpp" +#include "domain/Role.hpp" +#include "repositories/AuditRepository.hpp" +#include "utils/ErrorResponse.hpp" + +namespace Api { + +void AuditController::listAudit(const HttpRequestPtr& req, std::function&& callback) { + API_REQUIRE_PERMISSION(req, callback, Domain::Permission::kAuditRead); + + const auto pp = parse_page_params(req, /*default_limit=*/50, /*max_limit=*/200); + + auto param = [&](const char* key) -> std::optional { + auto v = req->getParameter(key); + return v.empty() ? std::nullopt : std::optional{v}; + }; + Repositories::AuditRepository::Filters f; + f.action = param("action"); + f.actor_id = param("actor_id"); + f.target_type = param("target_type"); + f.from = param("from"); + f.to = param("to"); + + with_repo_errors(callback, "admin listAudit", [&] { + Repositories::AuditRepository repo; + auto page = repo.list_filtered(f, pp.limit, pp.offset); + callback(Response::paginated(to_json_array(page.entries), page.total, pp.limit, pp.offset)); + }); +} + +} // namespace Api diff --git a/src/api/AuditController.hpp b/src/api/AuditController.hpp index 3669e53..47712a7 100644 --- a/src/api/AuditController.hpp +++ b/src/api/AuditController.hpp @@ -8,25 +8,21 @@ * Gated by the dedicated Permission::kAuditRead bit (not full-admin) so a * read-only "auditor" role is possible — full admins hold every 0xff bit and * pass automatically. Filters: ?action= &actor_id= &target_type= &from= &to=. + * + * Declarations only — the handler bodies live in AuditController.cpp + * (compiled once into app_core; ADR 0003 as amended 2026-08-22). The route + * macros (ADD_METHOD_TO) must stay in this header: Drogon's METHOD_LIST + * registration is part of the class definition, and + * scripts/check-routes-registered.sh greps the src/api headers for them. */ #pragma once -#include -#include +#include #include -#include - -#include -#include "api/Guards.hpp" -#include "api/HandlerSupport.hpp" -#include "api/RequestUtils.hpp" -#include "domain/AuditEntry.hpp" -#include "domain/Role.hpp" -#include "repositories/AuditRepository.hpp" -#include "utils/ErrorResponse.hpp" +#include namespace Api { @@ -39,28 +35,7 @@ class AuditController : public HttpController { ADD_METHOD_TO(AuditController::listAudit, "/api/v1/admin/audit", Get); METHOD_LIST_END - void listAudit(const HttpRequestPtr& req, std::function&& callback) { - API_REQUIRE_PERMISSION(req, callback, Domain::Permission::kAuditRead); - - const auto pp = parse_page_params(req, /*default_limit=*/50, /*max_limit=*/200); - - auto param = [&](const char* key) -> std::optional { - auto v = req->getParameter(key); - return v.empty() ? std::nullopt : std::optional{v}; - }; - Repositories::AuditRepository::Filters f; - f.action = param("action"); - f.actor_id = param("actor_id"); - f.target_type = param("target_type"); - f.from = param("from"); - f.to = param("to"); - - with_repo_errors(callback, "admin listAudit", [&] { - Repositories::AuditRepository repo; - auto page = repo.list_filtered(f, pp.limit, pp.offset); - callback(Response::paginated(to_json_array(page.entries), page.total, pp.limit, pp.offset)); - }); - } + void listAudit(const HttpRequestPtr& req, std::function&& callback); }; } // namespace Api diff --git a/src/api/AuthController.cpp b/src/api/AuthController.cpp new file mode 100644 index 0000000..7b1b5db --- /dev/null +++ b/src/api/AuthController.cpp @@ -0,0 +1,299 @@ +/** + * @file AuthController.cpp + * @brief Bodies for src/api/AuthController.hpp — compiled once into + * app_core. Contract and flask-base parity notes are documented on + * the declarations in the header. + */ + +#include "api/AuthController.hpp" + +#include + +#include + +#include + +#include "api/HandlerSupport.hpp" +#include "api/Validation.hpp" +#include "cache/Cache.hpp" +#include "email/AccountEmails.hpp" +#include "repositories/RoleRepository.hpp" +#include "repositories/UserRepository.hpp" +#include "security/Audit.hpp" +#include "security/Jwt.hpp" +#include "security/Password.hpp" +#include "security/RateLimit.hpp" +#include "security/SessionStore.hpp" +#include "utils/Crypto.hpp" +#include "utils/ErrorResponse.hpp" +#include "utils/Time.hpp" + +namespace Api { + +void AuthController::registerUser(const HttpRequestPtr& req, std::function&& callback) { + json body; + if (!Validation::parse_body(req, body, callback)) + return; + Validation::Errors errs; + Validation::require(errs, body, "email"); + Validation::require(errs, body, "password"); + Validation::email(errs, body, "email"); + Validation::string_length(errs, body, "password", Validation::kPasswordMinLen, Validation::kPasswordMaxLen); + if (errs.any()) { + callback(Validation::response_400(errs)); + return; + } + + const std::string email = body["email"].get(); + const std::string password = body["password"].get(); + const auto first_name = Validation::opt_string(body, "first_name"); + const auto last_name = Validation::opt_string(body, "last_name"); + + Repositories::RoleRepository roles; + auto default_role = roles.find_default(); + if (!default_role) { + spdlog::error("No default role in DB — run migrations / setup-dev"); + callback(ErrorResponse::service_unavailable("misconfigured", "default role missing")); + return; + } + + // with_repo_errors centralizes the DuplicateEmail->409 / *->500 mapping + // (was hand-rolled here, the exact drift the helper exists to prevent). + with_repo_errors(callback, "register", [&] { + const std::string hash = Security::Password::hash(password); + Repositories::UserRepository users; + auto created = users.create(email, hash, first_name, last_name, default_role->id, /*confirmed=*/false); + + // Attach the role we already loaded so to_json embeds it — no + // need to re-query the row we just inserted. + created.role = *default_role; + // Fire the confirmation email. AccountEmails handles token + // issuing + render + send; failures log but don't break + // registration (the user still has an account, they can hit + // /confirm-resend to retry). + Email::AccountEmails::send_confirm(created); + callback(Response::created( + {{"user", json(created)}, {"message", "Account created. Check your email for the confirmation link."}})); + }); +} + +void AuthController::login(const HttpRequestPtr& req, std::function&& callback) { + json body; + if (!Validation::parse_body(req, body, callback)) + return; + Validation::Errors errs; + // require_string, not require: a wrong-typed field ({"password": 123}) + // would otherwise reach get() and throw type_error.302 — + // a bare 500 on an unauthenticated endpoint instead of a 400. + Validation::require_string(errs, body, "email"); + Validation::require_string(errs, body, "password"); + if (errs.any()) { + callback(Validation::response_400(errs)); + return; + } + + const std::string email = body["email"].get(); + const std::string password = body["password"].get(); + + Repositories::UserRepository users; + auto user = users.find_by_email(email); + + // Equalize timing across user-exists vs not. A missing user (or one with + // no password hash) is verified against a fixed dummy hash so the ~90ms + // argon2 cost is always paid — otherwise the short-circuit was a timing + // oracle for user enumeration (argon2 is large enough to measure; DB + // latency does not mask it). The dummy hash is computed once. + static const std::string kDummyHash = Security::Password::hash("timing-equalizer-not-a-real-password"); + const std::string& hash_to_check = (user && user->password_hash) ? *user->password_hash : kDummyHash; + const bool password_ok = Security::Password::verify(password, hash_to_check); + + if (!user || !user->password_hash || !password_ok) { + // Audit the failed attempt so brute-force / credential-stuffing is + // visible in the trail (it wasn't before — only successful admin + // actions were recorded). No actor (unauthenticated); the attempted + // email + source IP are the investigation handles. Use the shared + // trusted-IP resolver (honors rate_limit.trust_proxy) — NOT a raw + // X-Real-IP read, which is client-spoofable when not behind a proxy. + const std::string ip = Security::RateLimit::client_ip(req); + Security::Audit::record( + /*actor_id=*/"", "auth.login_failed", "user", user ? user->id : "", {{"email", email}, {"ip", ip}}); + // Single message for missing-user + bad-password to defeat enumeration. + callback(ErrorResponse::unauthorized("invalid_credentials", "Invalid email or password")); + return; + } + + // Issue access + refresh, write refresh JTI to Redis for revocation. + auto session = mint_session(*user); + if (!session) { + callback(ErrorResponse::service_unavailable("session_unavailable", "Could not mint session")); + return; + } + + auto http = Response::ok({{"user", json(*user)}}); + Security::Auth::set_session_cookies( + http, Security::Auth::get().config().cookies, session->access, session->refresh); + callback(http); +} + +void AuthController::logout(const HttpRequestPtr& req, std::function&& callback) { + const auto& cfg = Security::Auth::get().config(); + const std::string refresh = Security::Auth::extract_refresh_token(req, cfg.cookies); + if (!refresh.empty()) + revoke_refresh(cfg, refresh); + + auto http = Response::ok({{"message", "logged out"}}); + Security::Auth::set_session_cookies(http, cfg.cookies, "", ""); + callback(http); +} + +void AuthController::refresh(const HttpRequestPtr& req, std::function&& callback) { + const auto& cfg = Security::Auth::get().config(); + const std::string refresh = Security::Auth::extract_refresh_token(req, cfg.cookies); + if (refresh.empty()) { + callback(ErrorResponse::unauthorized("missing_refresh")); + return; + } + + std::string err; + auto claims_opt = Security::Auth::verify_hs256_jwt(refresh, cfg.jwt_secret, err); + if (!claims_opt) { + callback(ErrorResponse::unauthorized(err)); + return; + } + const auto& claims = *claims_opt; + if (claims.value("typ", "") != "refresh") { + callback(ErrorResponse::unauthorized("not_a_refresh")); + return; + } + const std::string sub = claims.value("sub", ""); + const std::string jti = claims.value("jti", ""); + if (sub.empty() || jti.empty()) { + callback(ErrorResponse::unauthorized("malformed_claims")); + return; + } + + // Revocation check. + if (!is_refresh_live(cfg, jti)) { + callback(ErrorResponse::unauthorized("revoked")); + return; + } + + Repositories::UserRepository users; + auto user = users.find(sub); + if (!user) { + // User deleted while session was active. + revoke_refresh(cfg, refresh); // best effort + callback(ErrorResponse::unauthorized("user_gone")); + return; + } + + // Rotate. + revoke_jti(cfg, jti); + auto session = mint_session(*user); + if (!session) { + callback(ErrorResponse::service_unavailable("session_unavailable")); + return; + } + auto http = Response::ok({{"user", *user}}); + Security::Auth::set_session_cookies(http, cfg.cookies, session->access, session->refresh); + callback(http); +} + +void AuthController::me(const HttpRequestPtr& req, std::function&& callback) { + auto principal = Security::Auth::principal_of(req); + if (!principal) { + callback(ErrorResponse::unauthorized("missing_principal")); + return; + } + Repositories::UserRepository users; + auto user = users.find(principal->subject); + if (!user) { + callback(ErrorResponse::not_found("user")); + return; + } + callback(Response::ok({{"user", *user}})); +} + +std::string AuthController::make_jti() { + return Utils::Crypto::random_hex(16); +} + +std::optional AuthController::mint_session(const Domain::User& user) { + const auto& cfg = Security::Auth::get().config(); + if (cfg.jwt_secret.empty()) { + spdlog::error("mint_session: JWT_SECRET unset"); + return std::nullopt; + } + const long now = Utils::Time::now_epoch_seconds(); + + // Roles claim — string array, even for a single role, so the + // existing AuthPrincipal extractor parses it consistently. + json roles_array = json::array(); + if (user.role) + roles_array.push_back(user.role->name); + + // Permissions bitmask in the JWT lets the request layer answer + // require_permission(...) without re-loading the user from DB. + // The bitmask matches Domain::Permission constants. + const std::uint32_t perm_bits = user.role ? user.role->permissions : 0u; + + json access_claims = { + {"sub", user.id}, + {"iat", now}, + {"exp", now + cfg.cookies.access_ttl_sec}, + {"typ", "access"}, + {"confirmed", user.confirmed}, + {"permissions", perm_bits}, + {cfg.jwt_roles_claim, roles_array}, + }; + if (!cfg.jwt_issuer.empty()) + access_claims["iss"] = cfg.jwt_issuer; + if (!cfg.jwt_audience.empty()) + access_claims["aud"] = cfg.jwt_audience; + + const std::string jti = make_jti(); + json refresh_claims = { + {"sub", user.id}, + {"iat", now}, + {"exp", now + cfg.cookies.refresh_ttl_sec}, + {"typ", "refresh"}, + {"jti", jti}, + }; + + Session s; + s.access = Security::Auth::issue_hs256_jwt(access_claims, cfg.jwt_secret); + s.refresh = Security::Auth::issue_hs256_jwt(refresh_claims, cfg.jwt_secret); + + // Track the JTI (live-marker + per-user index for revoke-all). Redis + // down → fail closed: a refresh we can't revoke is worse than a failed + // login. record() returns false on the live-marker write failure. + if (!Cache::is_initialized()) { + spdlog::warn("Cache not initialized — refresh revocation will not work"); + return s; + } + if (!Security::Sessions::record(cfg.cookies, user.id, jti, cfg.cookies.refresh_ttl_sec)) { + spdlog::error("mint_session: failed to record refresh JTI — refusing to mint session"); + return std::nullopt; + } + return s; +} + +bool AuthController::is_refresh_live(const Security::Auth::AuthConfig& cfg, const std::string& jti) { + return Security::Sessions::is_live(cfg.cookies, jti); +} + +void AuthController::revoke_jti(const Security::Auth::AuthConfig& cfg, const std::string& jti) { + Security::Sessions::revoke_jti(cfg.cookies, jti); +} + +void AuthController::revoke_refresh(const Security::Auth::AuthConfig& cfg, const std::string& refresh_token) { + std::string err; + auto claims_opt = Security::Auth::verify_hs256_jwt(refresh_token, cfg.jwt_secret, err); + if (!claims_opt) + return; // already invalid; nothing to revoke + const std::string jti = claims_opt->value("jti", ""); + if (!jti.empty()) + revoke_jti(cfg, jti); +} + +} // namespace Api diff --git a/src/api/AuthController.hpp b/src/api/AuthController.hpp index 98b86a7..79b366a 100644 --- a/src/api/AuthController.hpp +++ b/src/api/AuthController.hpp @@ -11,34 +11,26 @@ * - Email-confirmation token generation lives here so /register can fire * it; the actual SMTP send is wired in stage 2 (AccountController + * Mailer). Until then we log the link at INFO level. + * + * Declarations only — the handler bodies live in AuthController.cpp + * (compiled once into app_core; ADR 0003 as amended 2026-08-22). The route + * macros (ADD_METHOD_TO) must stay in this header: Drogon's METHOD_LIST + * registration is part of the class definition, and + * scripts/check-routes-registered.sh greps the src/api headers for them. */ #pragma once +#include #include #include #include -#include -#include -#include +#include -#include "api/HandlerSupport.hpp" -#include "api/Validation.hpp" -#include "cache/Cache.hpp" #include "domain/User.hpp" -#include "email/AccountEmails.hpp" -#include "repositories/RoleRepository.hpp" -#include "repositories/UserRepository.hpp" -#include "security/Audit.hpp" #include "security/Auth.hpp" -#include "security/Password.hpp" -#include "security/RateLimit.hpp" -#include "security/SessionStore.hpp" -#include "utils/Crypto.hpp" -#include "utils/ErrorResponse.hpp" -#include "utils/Time.hpp" namespace Api { @@ -63,52 +55,7 @@ class AuthController : public HttpController { // token, and (stage 2) emails it. NOT auto-login — flask-base parity: // user has to click the link, then log in. // --------------------------------------------------------------------- - void registerUser(const HttpRequestPtr& req, std::function&& callback) { - json body; - if (!Validation::parse_body(req, body, callback)) - return; - Validation::Errors errs; - Validation::require(errs, body, "email"); - Validation::require(errs, body, "password"); - Validation::email(errs, body, "email"); - Validation::string_length(errs, body, "password", Validation::kPasswordMinLen, Validation::kPasswordMaxLen); - if (errs.any()) { - callback(Validation::response_400(errs)); - return; - } - - const std::string email = body["email"].get(); - const std::string password = body["password"].get(); - const auto first_name = Validation::opt_string(body, "first_name"); - const auto last_name = Validation::opt_string(body, "last_name"); - - Repositories::RoleRepository roles; - auto default_role = roles.find_default(); - if (!default_role) { - spdlog::error("No default role in DB — run migrations / setup-dev"); - callback(ErrorResponse::service_unavailable("misconfigured", "default role missing")); - return; - } - - // with_repo_errors centralizes the DuplicateEmail->409 / *->500 mapping - // (was hand-rolled here, the exact drift the helper exists to prevent). - with_repo_errors(callback, "register", [&] { - const std::string hash = Security::Password::hash(password); - Repositories::UserRepository users; - auto created = users.create(email, hash, first_name, last_name, default_role->id, /*confirmed=*/false); - - // Attach the role we already loaded so to_json embeds it — no - // need to re-query the row we just inserted. - created.role = *default_role; - // Fire the confirmation email. AccountEmails handles token - // issuing + render + send; failures log but don't break - // registration (the user still has an account, they can hit - // /confirm-resend to retry). - Email::AccountEmails::send_confirm(created); - callback(Response::created({{"user", json(created)}, - {"message", "Account created. Check your email for the confirmation link."}})); - }); - } + void registerUser(const HttpRequestPtr& req, std::function&& callback); // --------------------------------------------------------------------- // POST /api/auth/login @@ -118,63 +65,7 @@ class AuthController : public HttpController { // Generic 401 on either wrong email or wrong password (no user // enumeration). flask-base does the same thing with one flash message. // --------------------------------------------------------------------- - void login(const HttpRequestPtr& req, std::function&& callback) { - json body; - if (!Validation::parse_body(req, body, callback)) - return; - Validation::Errors errs; - // require_string, not require: a wrong-typed field ({"password": 123}) - // would otherwise reach get() and throw type_error.302 — - // a bare 500 on an unauthenticated endpoint instead of a 400. - Validation::require_string(errs, body, "email"); - Validation::require_string(errs, body, "password"); - if (errs.any()) { - callback(Validation::response_400(errs)); - return; - } - - const std::string email = body["email"].get(); - const std::string password = body["password"].get(); - - Repositories::UserRepository users; - auto user = users.find_by_email(email); - - // Equalize timing across user-exists vs not. A missing user (or one with - // no password hash) is verified against a fixed dummy hash so the ~90ms - // argon2 cost is always paid — otherwise the short-circuit was a timing - // oracle for user enumeration (argon2 is large enough to measure; DB - // latency does not mask it). The dummy hash is computed once. - static const std::string kDummyHash = Security::Password::hash("timing-equalizer-not-a-real-password"); - const std::string& hash_to_check = (user && user->password_hash) ? *user->password_hash : kDummyHash; - const bool password_ok = Security::Password::verify(password, hash_to_check); - - if (!user || !user->password_hash || !password_ok) { - // Audit the failed attempt so brute-force / credential-stuffing is - // visible in the trail (it wasn't before — only successful admin - // actions were recorded). No actor (unauthenticated); the attempted - // email + source IP are the investigation handles. Use the shared - // trusted-IP resolver (honors rate_limit.trust_proxy) — NOT a raw - // X-Real-IP read, which is client-spoofable when not behind a proxy. - const std::string ip = Security::RateLimit::client_ip(req); - Security::Audit::record( - /*actor_id=*/"", "auth.login_failed", "user", user ? user->id : "", {{"email", email}, {"ip", ip}}); - // Single message for missing-user + bad-password to defeat enumeration. - callback(ErrorResponse::unauthorized("invalid_credentials", "Invalid email or password")); - return; - } - - // Issue access + refresh, write refresh JTI to Redis for revocation. - auto session = mint_session(*user); - if (!session) { - callback(ErrorResponse::service_unavailable("session_unavailable", "Could not mint session")); - return; - } - - auto http = Response::ok({{"user", json(*user)}}); - Security::Auth::set_session_cookies( - http, Security::Auth::get().config().cookies, session->access, session->refresh); - callback(http); - } + void login(const HttpRequestPtr& req, std::function&& callback); // --------------------------------------------------------------------- // POST /api/auth/logout @@ -182,16 +73,7 @@ class AuthController : public HttpController { // Reads refresh-token cookie, deletes its JTI from Redis (so further // /refresh calls fail), and zeroes both cookies. // --------------------------------------------------------------------- - void logout(const HttpRequestPtr& req, std::function&& callback) { - const auto& cfg = Security::Auth::get().config(); - const std::string refresh = Security::Auth::extract_refresh_token(req, cfg.cookies); - if (!refresh.empty()) - revoke_refresh(cfg, refresh); - - auto http = Response::ok({{"message", "logged out"}}); - Security::Auth::set_session_cookies(http, cfg.cookies, "", ""); - callback(http); - } + void logout(const HttpRequestPtr& req, std::function&& callback); // --------------------------------------------------------------------- // POST /api/auth/refresh @@ -200,58 +82,7 @@ class AuthController : public HttpController { // live in Redis, rotates: new access + new refresh (with new JTI), // deletes the old JTI. Returns the user payload. // --------------------------------------------------------------------- - void refresh(const HttpRequestPtr& req, std::function&& callback) { - const auto& cfg = Security::Auth::get().config(); - const std::string refresh = Security::Auth::extract_refresh_token(req, cfg.cookies); - if (refresh.empty()) { - callback(ErrorResponse::unauthorized("missing_refresh")); - return; - } - - std::string err; - auto claims_opt = Security::Auth::verify_hs256_jwt(refresh, cfg.jwt_secret, err); - if (!claims_opt) { - callback(ErrorResponse::unauthorized(err)); - return; - } - const auto& claims = *claims_opt; - if (claims.value("typ", "") != "refresh") { - callback(ErrorResponse::unauthorized("not_a_refresh")); - return; - } - const std::string sub = claims.value("sub", ""); - const std::string jti = claims.value("jti", ""); - if (sub.empty() || jti.empty()) { - callback(ErrorResponse::unauthorized("malformed_claims")); - return; - } - - // Revocation check. - if (!is_refresh_live(cfg, jti)) { - callback(ErrorResponse::unauthorized("revoked")); - return; - } - - Repositories::UserRepository users; - auto user = users.find(sub); - if (!user) { - // User deleted while session was active. - revoke_refresh(cfg, refresh); // best effort - callback(ErrorResponse::unauthorized("user_gone")); - return; - } - - // Rotate. - revoke_jti(cfg, jti); - auto session = mint_session(*user); - if (!session) { - callback(ErrorResponse::service_unavailable("session_unavailable")); - return; - } - auto http = Response::ok({{"user", *user}}); - Security::Auth::set_session_cookies(http, cfg.cookies, session->access, session->refresh); - callback(http); - } + void refresh(const HttpRequestPtr& req, std::function&& callback); // --------------------------------------------------------------------- // GET /api/auth/me @@ -260,20 +91,7 @@ class AuthController : public HttpController { // global auth middleware already gates the path). 401 if missing / // expired; 404 if the user row vanished mid-session. // --------------------------------------------------------------------- - void me(const HttpRequestPtr& req, std::function&& callback) { - auto principal = Security::Auth::principal_of(req); - if (!principal) { - callback(ErrorResponse::unauthorized("missing_principal")); - return; - } - Repositories::UserRepository users; - auto user = users.find(principal->subject); - if (!user) { - callback(ErrorResponse::not_found("user")); - return; - } - callback(Response::ok({{"user", *user}})); - } + void me(const HttpRequestPtr& req, std::function&& callback); private: struct Session { @@ -281,7 +99,7 @@ class AuthController : public HttpController { std::string refresh; }; - static std::string make_jti() { return Utils::Crypto::random_hex(16); } + static std::string make_jti(); /** * @brief Mint access + refresh JWTs, write refresh JTI to Redis. @@ -289,83 +107,13 @@ class AuthController : public HttpController { * unverifiable, so we'd rather refuse the login than mint a * permanently-invalid session. */ - std::optional mint_session(const Domain::User& user) { - const auto& cfg = Security::Auth::get().config(); - if (cfg.jwt_secret.empty()) { - spdlog::error("mint_session: JWT_SECRET unset"); - return std::nullopt; - } - const long now = Utils::Time::now_epoch_seconds(); - - // Roles claim — string array, even for a single role, so the - // existing AuthPrincipal extractor parses it consistently. - json roles_array = json::array(); - if (user.role) - roles_array.push_back(user.role->name); - - // Permissions bitmask in the JWT lets the request layer answer - // require_permission(...) without re-loading the user from DB. - // The bitmask matches Domain::Permission constants. - const std::uint32_t perm_bits = user.role ? user.role->permissions : 0u; - - json access_claims = { - {"sub", user.id}, - {"iat", now}, - {"exp", now + cfg.cookies.access_ttl_sec}, - {"typ", "access"}, - {"confirmed", user.confirmed}, - {"permissions", perm_bits}, - {cfg.jwt_roles_claim, roles_array}, - }; - if (!cfg.jwt_issuer.empty()) - access_claims["iss"] = cfg.jwt_issuer; - if (!cfg.jwt_audience.empty()) - access_claims["aud"] = cfg.jwt_audience; - - const std::string jti = make_jti(); - json refresh_claims = { - {"sub", user.id}, - {"iat", now}, - {"exp", now + cfg.cookies.refresh_ttl_sec}, - {"typ", "refresh"}, - {"jti", jti}, - }; - - Session s; - s.access = Security::Auth::issue_hs256_jwt(access_claims, cfg.jwt_secret); - s.refresh = Security::Auth::issue_hs256_jwt(refresh_claims, cfg.jwt_secret); - - // Track the JTI (live-marker + per-user index for revoke-all). Redis - // down → fail closed: a refresh we can't revoke is worse than a failed - // login. record() returns false on the live-marker write failure. - if (!Cache::is_initialized()) { - spdlog::warn("Cache not initialized — refresh revocation will not work"); - return s; - } - if (!Security::Sessions::record(cfg.cookies, user.id, jti, cfg.cookies.refresh_ttl_sec)) { - spdlog::error("mint_session: failed to record refresh JTI — refusing to mint session"); - return std::nullopt; - } - return s; - } + std::optional mint_session(const Domain::User& user); - static bool is_refresh_live(const Security::Auth::AuthConfig& cfg, const std::string& jti) { - return Security::Sessions::is_live(cfg.cookies, jti); - } + static bool is_refresh_live(const Security::Auth::AuthConfig& cfg, const std::string& jti); - static void revoke_jti(const Security::Auth::AuthConfig& cfg, const std::string& jti) { - Security::Sessions::revoke_jti(cfg.cookies, jti); - } + static void revoke_jti(const Security::Auth::AuthConfig& cfg, const std::string& jti); - static void revoke_refresh(const Security::Auth::AuthConfig& cfg, const std::string& refresh_token) { - std::string err; - auto claims_opt = Security::Auth::verify_hs256_jwt(refresh_token, cfg.jwt_secret, err); - if (!claims_opt) - return; // already invalid; nothing to revoke - const std::string jti = claims_opt->value("jti", ""); - if (!jti.empty()) - revoke_jti(cfg, jti); - } + static void revoke_refresh(const Security::Auth::AuthConfig& cfg, const std::string& refresh_token); }; } // namespace Api diff --git a/src/api/ContentPagesController.cpp b/src/api/ContentPagesController.cpp new file mode 100644 index 0000000..ec479b7 --- /dev/null +++ b/src/api/ContentPagesController.cpp @@ -0,0 +1,119 @@ +/** + * @file ContentPagesController.cpp + * @brief Bodies for src/api/ContentPagesController.hpp — compiled once into + * app_core. Contract and the module-off degradation rules are + * documented on the declarations in the header. + */ + +#include "api/ContentPagesController.hpp" + +#include + +#include "api/HandlerSupport.hpp" +#include "api/PostsController.hpp" +#include "core/Modules.hpp" +#include "repositories/PostRepository.hpp" +#include "utils/Config.hpp" + +namespace Api { + +void ContentPagesController::post_markdown(const HttpRequestPtr& req, + std::function&& callback, + const std::string& slug) { + if (!Core::content_enabled()) { + callback(not_found_markdown()); + return; + } + with_repo_errors(callback, "post_markdown", [&] { + auto found = PostsController::resolve_post(slug, req->getParameter("preview")); + if (!found) { + callback(not_found_markdown()); + return; + } + auto resp = HttpResponse::newHttpResponse(); + resp->setBody("# " + found->title + "\n\n" + found->body); + // Drogon's CT_* enum has no Markdown entry — set the header directly. + resp->setContentTypeString("text/markdown; charset=utf-8"); + callback(resp); + }); +} + +void ContentPagesController::sitemap(const HttpRequestPtr& req, + std::function&& callback) { + (void)req; + with_repo_errors(callback, "sitemap", [&] { + std::vector entries; + if (Core::content_enabled()) { + Repositories::PostRepository repo; + entries = repo.list_published_for_sitemap(); + } + // Escaped once: the base URL is loop-invariant, only slugs vary. + const std::string base = esc(base_url()); + + std::string xml; + xml.reserve(entries.size() * 128 + 256); + xml += "\n"; + xml += "\n"; + xml += " " + base + "/monthly1.0\n"; + for (const auto& e : entries) { + xml += " " + base + "/posts/" + esc(e.slug) + ""; + if (!e.lastmod.empty()) + xml += "" + e.lastmod + ""; + xml += "monthly0.6\n"; + } + xml += "\n"; + + auto resp = HttpResponse::newHttpResponse(); + resp->setBody(std::move(xml)); + resp->setContentTypeString("application/xml; charset=utf-8"); + // Cheap SQL, but crawlers poll: an hour of caching is plenty fresh. + resp->addHeader("Cache-Control", "public, max-age=3600"); + callback(resp); + }); +} + +drogon::HttpResponsePtr ContentPagesController::not_found_markdown() { + auto resp = HttpResponse::newHttpResponse(); + resp->setStatusCode(k404NotFound); + resp->setContentTypeString("text/markdown; charset=utf-8"); + resp->setBody("# 404\n\nNot found.\n"); + return resp; +} + +std::string ContentPagesController::base_url() { + std::string base = "http://localhost:8080"; + if (Config::is_initialized()) + base = Config::get().get("app.base_url", "APP_BASE_URL", base); + if (!base.empty() && base.back() == '/') + base.pop_back(); + return base; +} + +std::string ContentPagesController::esc(const std::string& s) { + std::string o; + o.reserve(s.size() + 16); + for (char c : s) { + switch (c) { + case '&': + o += "&"; + break; + case '<': + o += "<"; + break; + case '>': + o += ">"; + break; + case '"': + o += """; + break; + case '\'': + o += "'"; + break; + default: + o += c; + } + } + return o; +} + +} // namespace Api diff --git a/src/api/ContentPagesController.hpp b/src/api/ContentPagesController.hpp index 185bca0..abd1e09 100644 --- a/src/api/ContentPagesController.hpp +++ b/src/api/ContentPagesController.hpp @@ -10,21 +10,20 @@ * that file called out as a *separate* concern from the SSR shell: the * sitemap, and — here, in place of an HTML page — the raw Markdown body a * static frontend or another renderer can fetch directly. + * + * Declarations only — the handler bodies live in ContentPagesController.cpp + * (compiled once into app_core; ADR 0003 as amended 2026-08-22). The route + * macros (ADD_METHOD_TO) must stay in this header: Drogon's METHOD_LIST + * registration is part of the class definition, and + * scripts/check-routes-registered.sh greps the src/api headers for them. */ #pragma once +#include #include -#include #include -#include - -#include "api/HandlerSupport.hpp" -#include "api/PostsController.hpp" -#include "core/Modules.hpp" -#include "repositories/PostRepository.hpp" -#include "utils/Config.hpp" namespace Api { @@ -49,24 +48,7 @@ class ContentPagesController : public HttpController { // the same 404 Markdown body so none of them leak which case applied. void post_markdown(const HttpRequestPtr& req, std::function&& callback, - const std::string& slug) { - if (!Core::content_enabled()) { - callback(not_found_markdown()); - return; - } - with_repo_errors(callback, "post_markdown", [&] { - auto found = PostsController::resolve_post(slug, req->getParameter("preview")); - if (!found) { - callback(not_found_markdown()); - return; - } - auto resp = HttpResponse::newHttpResponse(); - resp->setBody("# " + found->title + "\n\n" + found->body); - // Drogon's CT_* enum has no Markdown entry — set the header directly. - resp->setContentTypeString("text/markdown; charset=utf-8"); - callback(resp); - }); - } + const std::string& slug); // GET /sitemap.xml — home + one per published post (clean // /posts/ URL). Ported from the fork's sitemap loop verbatim minus @@ -79,47 +61,10 @@ class ContentPagesController : public HttpController { // docstring on why the guard exists — an un-migrated deploy has no // `posts` table yet, so skipping the repository call here, not just the // response, is what avoids a 500). - void sitemap(const HttpRequestPtr& req, std::function&& callback) { - (void)req; - with_repo_errors(callback, "sitemap", [&] { - std::vector entries; - if (Core::content_enabled()) { - Repositories::PostRepository repo; - entries = repo.list_published_for_sitemap(); - } - // Escaped once: the base URL is loop-invariant, only slugs vary. - const std::string base = esc(base_url()); - - std::string xml; - xml.reserve(entries.size() * 128 + 256); - xml += "\n"; - xml += "\n"; - xml += " " + base + "/monthly1.0\n"; - for (const auto& e : entries) { - xml += " " + base + "/posts/" + esc(e.slug) + ""; - if (!e.lastmod.empty()) - xml += "" + e.lastmod + ""; - xml += "monthly0.6\n"; - } - xml += "\n"; - - auto resp = HttpResponse::newHttpResponse(); - resp->setBody(std::move(xml)); - resp->setContentTypeString("application/xml; charset=utf-8"); - // Cheap SQL, but crawlers poll: an hour of caching is plenty fresh. - resp->addHeader("Cache-Control", "public, max-age=3600"); - callback(resp); - }); - } + void sitemap(const HttpRequestPtr& req, std::function&& callback); private: - static drogon::HttpResponsePtr not_found_markdown() { - auto resp = HttpResponse::newHttpResponse(); - resp->setStatusCode(k404NotFound); - resp->setContentTypeString("text/markdown; charset=utf-8"); - resp->setBody("# 404\n\nNot found.\n"); - return resp; - } + static drogon::HttpResponsePtr not_found_markdown(); // Canonical origin for absolute sitemap URLs. Same lookup/trim as // src/email/AccountEmails.hpp's base_url() — app.base_url is this repo's @@ -128,46 +73,14 @@ class ContentPagesController : public HttpController { // header-derived dev fallback; that machinery isn't ported — a sitemap // has no request-scoped notion of "origin", it's a single canonical URL // set, so the configured base is authoritative and nothing else applies). - static std::string base_url() { - std::string base = "http://localhost:8080"; - if (Config::is_initialized()) - base = Config::get().get("app.base_url", "APP_BASE_URL", base); - if (!base.empty() && base.back() == '/') - base.pop_back(); - return base; - } + static std::string base_url(); // XML-escape of the five significant characters. No shared helper for // this exists in Utils::Strings (Task 2 confirmed Post.hpp/ // PostRepository.hpp reference none, and left the file untouched); the // fork itself doesn't use one either — PublicPagesController.hpp carries // this exact same escaper as a private method. Ported verbatim. - static std::string esc(const std::string& s) { - std::string o; - o.reserve(s.size() + 16); - for (char c : s) { - switch (c) { - case '&': - o += "&"; - break; - case '<': - o += "<"; - break; - case '>': - o += ">"; - break; - case '"': - o += """; - break; - case '\'': - o += "'"; - break; - default: - o += c; - } - } - return o; - } + static std::string esc(const std::string& s); }; } // namespace Api diff --git a/src/api/HealthController.cpp b/src/api/HealthController.cpp new file mode 100644 index 0000000..a792a70 --- /dev/null +++ b/src/api/HealthController.cpp @@ -0,0 +1,85 @@ +/** + * @file HealthController.cpp + * @brief Bodies for src/api/HealthController.hpp — compiled once into + * app_core. Contract and probe semantics are documented on the + * declarations in the header. Core::* comes in transitively through + * the header — check-module-deps.sh's CORE_HPP_ALLOWED permits the + * direct core/Core.hpp include only there. + */ + +#include "api/HealthController.hpp" + +#include + +#include + +#include "api/Endpoints.hpp" +#include "utils/ErrorResponse.hpp" + +namespace Api { + +std::string version_or_unknown() { + return Core::is_initialized() ? Core::get().version() : std::string("unknown"); +} + +void HealthController::liveness(const HttpRequestPtr&, std::function&& callback) { + callback(Response::ok({{"status", "alive"}, {"timestamp", std::time(nullptr)}})); +} + +void HealthController::readiness(const HttpRequestPtr&, std::function&& callback) { + // During graceful shutdown we must report NotReady so kube-proxy + // removes us from the Service backends before Drogon stops accepting. + if (Core::is_shutting_down()) { + auto resp = Response::ok({{"status", "draining"}, {"timestamp", std::time(nullptr)}}); + resp->setStatusCode(k503ServiceUnavailable); + callback(resp); + return; + } + bool ready = Core::is_initialized() && Core::health_check(); + auto resp = Response::ok({{"status", ready ? "ready" : "not_ready"}, {"timestamp", std::time(nullptr)}}); + resp->setStatusCode(ready ? k200OK : k503ServiceUnavailable); + callback(resp); +} + +void HealthController::health(const HttpRequestPtr&, std::function&& callback) { + // Pull every component registered via Core::register_health_check — + // services that add their own modules no longer have to hard-code + // lookups in this method. + json components = json::object(); + bool critical_ok = true; // a CRITICAL component is down → 503 unhealthy + bool any_degraded_down = false; // only OPTIONAL deps down → 200 degraded + if (Core::is_initialized()) { + for (const auto& c : Core::get().health_report()) { + components[c.name] = {{"initialized", c.initialized}, {"healthy", c.healthy}, {"critical", c.critical}}; + if (!c.healthy) { + if (c.critical) + critical_ok = false; + else + any_degraded_down = true; + } + } + } else { + critical_ok = false; + } + // A degraded optional dependency (SMTP/storage/Kafka) reports "degraded" + // but stays 200 — only a critical-component failure returns 503, matching + // what /ready (Core::health_check) gates on. + const char* status = !critical_ok ? "unhealthy" : (any_degraded_down ? "degraded" : "healthy"); + auto resp = Response::ok({{"status", status}, + {"version", version_or_unknown()}, + {"timestamp", std::time(nullptr)}, + {"components", components}}); + resp->setStatusCode(critical_ok ? k200OK : k503ServiceUnavailable); + callback(resp); +} + +void RootController::getRoot(const HttpRequestPtr&, std::function&& callback) { + json endpoints_json = json::array(); + for (const auto& ep : get_endpoints()) { + endpoints_json.push_back({{"method", ep.method}, {"path", ep.path}, {"description", ep.description}}); + } + callback(Response::ok( + {{"message", "C++ API Template"}, {"version", version_or_unknown()}, {"endpoints", endpoints_json}})); +} + +} // namespace Api diff --git a/src/api/HealthController.hpp b/src/api/HealthController.hpp index e4bc29f..7d5129f 100644 --- a/src/api/HealthController.hpp +++ b/src/api/HealthController.hpp @@ -2,20 +2,26 @@ * @file HealthController.hpp * @brief Health check and root endpoint controllers * @details Kubernetes probes (/healthz, /ready, /health) and endpoint discovery (/) + * + * Declarations only — the handler bodies live in HealthController.cpp + * (compiled once into app_core; ADR 0003 as amended 2026-08-22). The route + * macros (ADD_METHOD_TO) must stay in this header: Drogon's METHOD_LIST + * registration is part of the class definition, and + * scripts/check-routes-registered.sh greps the src/api headers for them. */ #pragma once -#include +#include +#include #include -#include -#include +#include -#include "api/Endpoints.hpp" +// kept here because check-module-deps.sh's CORE_HPP_ALLOWED lists only this header; the body file receives it +// transitively. #include "core/Core.hpp" -#include "utils/ErrorResponse.hpp" namespace Api { @@ -24,9 +30,7 @@ using json = nlohmann::json; /// Version string for status payloads: the Core-reported version once /// initialized, "unknown" before that (shared by /health and /). -inline std::string version_or_unknown() { - return Core::is_initialized() ? Core::get().version() : std::string("unknown"); -} +std::string version_or_unknown(); /** * @brief Health check controller @@ -40,56 +44,11 @@ class HealthController : public HttpController { ADD_METHOD_TO(HealthController::health, "/health", Get); METHOD_LIST_END - void liveness(const HttpRequestPtr&, std::function&& callback) { - callback(Response::ok({{"status", "alive"}, {"timestamp", std::time(nullptr)}})); - } + void liveness(const HttpRequestPtr&, std::function&& callback); - void readiness(const HttpRequestPtr&, std::function&& callback) { - // During graceful shutdown we must report NotReady so kube-proxy - // removes us from the Service backends before Drogon stops accepting. - if (Core::is_shutting_down()) { - auto resp = Response::ok({{"status", "draining"}, {"timestamp", std::time(nullptr)}}); - resp->setStatusCode(k503ServiceUnavailable); - callback(resp); - return; - } - bool ready = Core::is_initialized() && Core::health_check(); - auto resp = Response::ok({{"status", ready ? "ready" : "not_ready"}, {"timestamp", std::time(nullptr)}}); - resp->setStatusCode(ready ? k200OK : k503ServiceUnavailable); - callback(resp); - } + void readiness(const HttpRequestPtr&, std::function&& callback); - void health(const HttpRequestPtr&, std::function&& callback) { - // Pull every component registered via Core::register_health_check — - // services that add their own modules no longer have to hard-code - // lookups in this method. - json components = json::object(); - bool critical_ok = true; // a CRITICAL component is down → 503 unhealthy - bool any_degraded_down = false; // only OPTIONAL deps down → 200 degraded - if (Core::is_initialized()) { - for (const auto& c : Core::get().health_report()) { - components[c.name] = {{"initialized", c.initialized}, {"healthy", c.healthy}, {"critical", c.critical}}; - if (!c.healthy) { - if (c.critical) - critical_ok = false; - else - any_degraded_down = true; - } - } - } else { - critical_ok = false; - } - // A degraded optional dependency (SMTP/storage/Kafka) reports "degraded" - // but stays 200 — only a critical-component failure returns 503, matching - // what /ready (Core::health_check) gates on. - const char* status = !critical_ok ? "unhealthy" : (any_degraded_down ? "degraded" : "healthy"); - auto resp = Response::ok({{"status", status}, - {"version", version_or_unknown()}, - {"timestamp", std::time(nullptr)}, - {"components", components}}); - resp->setStatusCode(critical_ok ? k200OK : k503ServiceUnavailable); - callback(resp); - } + void health(const HttpRequestPtr&, std::function&& callback); }; /** @@ -101,14 +60,7 @@ class RootController : public HttpController { ADD_METHOD_TO(RootController::getRoot, "/", Get); METHOD_LIST_END - void getRoot(const HttpRequestPtr&, std::function&& callback) { - json endpoints_json = json::array(); - for (const auto& ep : get_endpoints()) { - endpoints_json.push_back({{"method", ep.method}, {"path", ep.path}, {"description", ep.description}}); - } - callback(Response::ok( - {{"message", "C++ API Template"}, {"version", version_or_unknown()}, {"endpoints", endpoints_json}})); - } + void getRoot(const HttpRequestPtr&, std::function&& callback); }; } // namespace Api diff --git a/src/api/JobsController.cpp b/src/api/JobsController.cpp new file mode 100644 index 0000000..dcc218e --- /dev/null +++ b/src/api/JobsController.cpp @@ -0,0 +1,145 @@ +/** + * @file JobsController.cpp + * @brief Bodies for src/api/JobsController.hpp — compiled once into + * app_core. Contract and route-ordering notes are documented on the + * declarations in the header. + */ + +#include "api/JobsController.hpp" + +#include + +#include + +#include "api/Guards.hpp" +#include "api/RequestUtils.hpp" +#include "api/Validation.hpp" +#include "jobs/Jobs.hpp" +#include "utils/ErrorResponse.hpp" + +namespace Api { + +void JobsController::listJobs(const HttpRequestPtr& req, std::function&& callback) { + API_REQUIRE_ADMIN(req, callback); + API_REQUIRE_JOBS_READY(callback); + try { + auto type_param = req->getParameter("type"); + const auto pp = parse_page_params(req, /*default_limit=*/20, /*max_limit=*/200); + + auto page = Jobs::get().list_paged(type_param, pp.limit, pp.offset); + json jobs_json = json::array(); + for (const auto& job : page.jobs) { + jobs_json.push_back(job.to_json()); + } + callback(Response::paginated(jobs_json, page.total, pp.limit, pp.offset)); + } catch (const std::exception& e) { + spdlog::error("Error in GET /api/v1/jobs: {}", e.what()); + callback(ErrorResponse::internal_error()); + } +} + +void JobsController::submitJob(const HttpRequestPtr& req, std::function&& callback) { + API_REQUIRE_ADMIN(req, callback); + API_REQUIRE_JOBS_READY(callback); + try { + json body; + if (!Validation::parse_body(req, body, callback)) + return; + + Validation::Errors errs; + Validation::require(errs, body, "type"); + Validation::string_length(errs, body, "type", 1, 255); + if (errs.any()) { + callback(Validation::response_400(errs)); + return; + } + + auto type = body["type"].get(); + auto payload = body.value("payload", json::object()); + int max_retries = body.value("max_retries", -1); + + auto job = Jobs::get().submit(type, payload, max_retries); + callback(Response::created({{"data", job.to_json()}, {"message", "Job submitted"}})); + } catch (const std::exception& e) { + spdlog::error("Error in POST /api/v1/jobs: {}", e.what()); + callback(ErrorResponse::internal_error()); + } +} + +void JobsController::getJobStatus(const HttpRequestPtr& req, + std::function&& callback, + const std::string& id) { + API_REQUIRE_ADMIN(req, callback); + API_REQUIRE_JOBS_READY(callback); + try { + if (!require_valid_uuid(id, callback)) + return; + auto job = Jobs::get().get_status(id); + if (!job) { + callback(ErrorResponse::not_found("job")); + return; + } + callback(Response::ok({{"data", job->to_json()}})); + } catch (const std::exception& e) { + spdlog::error("Error in GET /api/v1/jobs/{}: {}", id, e.what()); + callback(ErrorResponse::internal_error()); + } +} + +void JobsController::listDlq(const HttpRequestPtr& req, std::function&& callback) { + API_REQUIRE_ADMIN(req, callback); + API_REQUIRE_JOBS_READY(callback); + try { + auto type_param = req->getParameter("type"); + int limit = clamp_int(req->getParameter("limit"), 100, 1, 500); + + auto jobs = Jobs::get().list_dlq(type_param, limit); + json jobs_json = json::array(); + for (const auto& j : jobs) + jobs_json.push_back(j.to_json()); + callback(Response::ok({{"data", jobs_json}, {"count", jobs_json.size()}, {"depth", Jobs::get().dlq_depth()}})); + } catch (const std::exception& e) { + spdlog::error("Error in GET /api/v1/jobs/dlq: {}", e.what()); + callback(ErrorResponse::internal_error()); + } +} + +void JobsController::requeueDlq(const HttpRequestPtr& req, + std::function&& callback, + const std::string& id) { + API_REQUIRE_ADMIN(req, callback); + API_REQUIRE_JOBS_READY(callback); + try { + if (!require_valid_uuid(id, callback)) + return; + if (!Jobs::get().requeue_from_dlq(id)) { + callback(ErrorResponse::not_found("dlq_job")); + return; + } + callback(Response::ok({{"message", "Job requeued from DLQ"}, {"id", id}})); + } catch (const std::exception& e) { + spdlog::error("Error in POST /api/v1/jobs/dlq/{}/requeue: {}", id, e.what()); + callback(ErrorResponse::internal_error()); + } +} + +void JobsController::cancelJob(const HttpRequestPtr& req, + std::function&& callback, + const std::string& id) { + API_REQUIRE_ADMIN(req, callback); + API_REQUIRE_JOBS_READY(callback); + try { + if (!require_valid_uuid(id, callback)) + return; + if (!Jobs::get().cancel(id)) { + callback(ErrorResponse::not_found("cancellable_job")); + return; + } + callback(Response::ok({{"message", "Job cancelled"}})); + } catch (const std::exception& e) { + spdlog::error("Error in DELETE /api/v1/jobs/{}: {}", id, e.what()); + callback(ErrorResponse::internal_error()); + } +} + +} // namespace Api diff --git a/src/api/JobsController.hpp b/src/api/JobsController.hpp index 376b23a..033488b 100644 --- a/src/api/JobsController.hpp +++ b/src/api/JobsController.hpp @@ -2,21 +2,22 @@ * @file JobsController.hpp * @brief Jobs queue HTTP controller * @details Handles /api/v1/jobs endpoints for submitting and querying background jobs + * + * Declarations only — the handler bodies live in JobsController.cpp + * (compiled once into app_core; ADR 0003 as amended 2026-08-22). The route + * macros (ADD_METHOD_TO) must stay in this header: Drogon's METHOD_LIST + * registration is part of the class definition, and + * scripts/check-routes-registered.sh greps the src/api headers for them. */ #pragma once -#include -#include -#include +#include +#include -#include +#include -#include "api/Guards.hpp" -#include "api/RequestUtils.hpp" -#include "api/Validation.hpp" -#include "jobs/Jobs.hpp" -#include "utils/ErrorResponse.hpp" +#include namespace Api { @@ -42,129 +43,23 @@ class JobsController : public HttpController { ADD_METHOD_TO(JobsController::cancelJob, "/api/v1/jobs/{1}", Delete); METHOD_LIST_END - void listJobs(const HttpRequestPtr& req, std::function&& callback) { - API_REQUIRE_ADMIN(req, callback); - API_REQUIRE_JOBS_READY(callback); - try { - auto type_param = req->getParameter("type"); - const auto pp = parse_page_params(req, /*default_limit=*/20, /*max_limit=*/200); - - auto page = Jobs::get().list_paged(type_param, pp.limit, pp.offset); - json jobs_json = json::array(); - for (const auto& job : page.jobs) { - jobs_json.push_back(job.to_json()); - } - callback(Response::paginated(jobs_json, page.total, pp.limit, pp.offset)); - } catch (const std::exception& e) { - spdlog::error("Error in GET /api/v1/jobs: {}", e.what()); - callback(ErrorResponse::internal_error()); - } - } - - void submitJob(const HttpRequestPtr& req, std::function&& callback) { - API_REQUIRE_ADMIN(req, callback); - API_REQUIRE_JOBS_READY(callback); - try { - json body; - if (!Validation::parse_body(req, body, callback)) - return; + void listJobs(const HttpRequestPtr& req, std::function&& callback); - Validation::Errors errs; - Validation::require(errs, body, "type"); - Validation::string_length(errs, body, "type", 1, 255); - if (errs.any()) { - callback(Validation::response_400(errs)); - return; - } - - auto type = body["type"].get(); - auto payload = body.value("payload", json::object()); - int max_retries = body.value("max_retries", -1); - - auto job = Jobs::get().submit(type, payload, max_retries); - callback(Response::created({{"data", job.to_json()}, {"message", "Job submitted"}})); - } catch (const std::exception& e) { - spdlog::error("Error in POST /api/v1/jobs: {}", e.what()); - callback(ErrorResponse::internal_error()); - } - } + void submitJob(const HttpRequestPtr& req, std::function&& callback); void getJobStatus(const HttpRequestPtr& req, std::function&& callback, - const std::string& id) { - API_REQUIRE_ADMIN(req, callback); - API_REQUIRE_JOBS_READY(callback); - try { - if (!require_valid_uuid(id, callback)) - return; - auto job = Jobs::get().get_status(id); - if (!job) { - callback(ErrorResponse::not_found("job")); - return; - } - callback(Response::ok({{"data", job->to_json()}})); - } catch (const std::exception& e) { - spdlog::error("Error in GET /api/v1/jobs/{}: {}", id, e.what()); - callback(ErrorResponse::internal_error()); - } - } - - void listDlq(const HttpRequestPtr& req, std::function&& callback) { - API_REQUIRE_ADMIN(req, callback); - API_REQUIRE_JOBS_READY(callback); - try { - auto type_param = req->getParameter("type"); - int limit = clamp_int(req->getParameter("limit"), 100, 1, 500); + const std::string& id); - auto jobs = Jobs::get().list_dlq(type_param, limit); - json jobs_json = json::array(); - for (const auto& j : jobs) - jobs_json.push_back(j.to_json()); - callback( - Response::ok({{"data", jobs_json}, {"count", jobs_json.size()}, {"depth", Jobs::get().dlq_depth()}})); - } catch (const std::exception& e) { - spdlog::error("Error in GET /api/v1/jobs/dlq: {}", e.what()); - callback(ErrorResponse::internal_error()); - } - } + void listDlq(const HttpRequestPtr& req, std::function&& callback); void requeueDlq(const HttpRequestPtr& req, std::function&& callback, - const std::string& id) { - API_REQUIRE_ADMIN(req, callback); - API_REQUIRE_JOBS_READY(callback); - try { - if (!require_valid_uuid(id, callback)) - return; - if (!Jobs::get().requeue_from_dlq(id)) { - callback(ErrorResponse::not_found("dlq_job")); - return; - } - callback(Response::ok({{"message", "Job requeued from DLQ"}, {"id", id}})); - } catch (const std::exception& e) { - spdlog::error("Error in POST /api/v1/jobs/dlq/{}/requeue: {}", id, e.what()); - callback(ErrorResponse::internal_error()); - } - } + const std::string& id); void cancelJob(const HttpRequestPtr& req, std::function&& callback, - const std::string& id) { - API_REQUIRE_ADMIN(req, callback); - API_REQUIRE_JOBS_READY(callback); - try { - if (!require_valid_uuid(id, callback)) - return; - if (!Jobs::get().cancel(id)) { - callback(ErrorResponse::not_found("cancellable_job")); - return; - } - callback(Response::ok({{"message", "Job cancelled"}})); - } catch (const std::exception& e) { - spdlog::error("Error in DELETE /api/v1/jobs/{}: {}", id, e.what()); - callback(ErrorResponse::internal_error()); - } - } + const std::string& id); }; } // namespace Api diff --git a/src/api/Middleware.cpp b/src/api/Middleware.cpp index e6570c6..ab6ea72 100644 --- a/src/api/Middleware.cpp +++ b/src/api/Middleware.cpp @@ -22,9 +22,11 @@ #include #include +#include #include #include #include +#include #include #include "api/RequestUtils.hpp" diff --git a/src/api/PostsController.cpp b/src/api/PostsController.cpp new file mode 100644 index 0000000..0b94014 --- /dev/null +++ b/src/api/PostsController.cpp @@ -0,0 +1,346 @@ +/** + * @file PostsController.cpp + * @brief Bodies for src/api/PostsController.hpp — compiled once into + * app_core. The module gate, validation rules and preview-token + * contract are documented on the declarations in the header. + */ + +#include "api/PostsController.hpp" + +#include +#include + +#include + +#include "api/Guards.hpp" +#include "api/HandlerSupport.hpp" +#include "api/RequestUtils.hpp" +#include "security/Auth.hpp" +#include "security/Tokens.hpp" +#include "utils/ErrorResponse.hpp" +#include "utils/Time.hpp" + +namespace Api { + +void PostsController::listPosts(const HttpRequestPtr& req, std::function&& callback) { + if (!require_content_enabled(callback)) + return; + API_REQUIRE_ADMIN(req, callback); + const auto page = parse_page_params(req, /*default_limit=*/50, /*max_limit=*/200); + Repositories::AdminListFilter f; + f.q = req->getParameter("q"); + f.status = req->getParameter("status"); + f.topic = req->getParameter("topic"); + f.tag = req->getParameter("tag"); + if (!f.status.empty() && f.status != "draft" && f.status != "published") { + callback(ErrorResponse::bad_request("invalid_status", "status must be draft or published")); + return; + } + with_repo_errors(callback, "listPosts", [&] { + Repositories::PostRepository repo; + auto items = repo.list_admin(f, page.limit, page.offset); + long total = repo.count_admin(f); + json data = items; + callback(Response::paginated(data, total, page.limit, page.offset)); + }); +} + +void PostsController::createPost(const HttpRequestPtr& req, std::function&& callback) { + if (!require_content_enabled(callback)) + return; + API_REQUIRE_ADMIN(req, callback); + json body; + if (!Validation::parse_body(req, body, callback)) + return; + Repositories::PostInput in; + if (!read_input(body, in, callback)) + return; + with_repo_errors(callback, "createPost", [&] { + Repositories::PostRepository repo; + auto created = repo.create(in); + callback(Response::created({{"data", json(created)}})); + }); +} + +void PostsController::getPost(const HttpRequestPtr& req, + std::function&& callback, + const std::string& id) { + if (!require_content_enabled(callback)) + return; + API_REQUIRE_ADMIN(req, callback); + if (!require_valid_uuid(id, callback)) + return; + with_repo_errors(callback, "getPost", [&] { + Repositories::PostRepository repo; + auto found = repo.find(id); + if (!found) { + callback(ErrorResponse::not_found("post")); + return; + } + callback(Response::ok({{"data", json(*found)}})); + }); +} + +void PostsController::updatePost(const HttpRequestPtr& req, + std::function&& callback, + const std::string& id) { + if (!require_content_enabled(callback)) + return; + API_REQUIRE_ADMIN(req, callback); + if (!require_valid_uuid(id, callback)) + return; + json body; + if (!Validation::parse_body(req, body, callback)) + return; + Repositories::PostRepository repo; + // Read the merge base from the PRIMARY: this is a read-modify-write, so + // a lagging replica would make a partial PATCH silently revert whatever + // the omitted fields were last set to. Same reason as + // AdminController::updateUser's post-write re-read. + auto existing = repo.find(id, /*from_primary=*/true); + if (!existing) { + callback(ErrorResponse::not_found("post")); + return; + } + // PATCH is a partial update: merge the body over the existing post so + // omitted fields (incl. status → published_at) are preserved, not wiped. + Repositories::PostInput in; + if (!merge_input(body, *existing, in, callback)) + return; + with_repo_errors(callback, "updatePost", [&] { + auto updated = repo.update(id, in); + callback(Response::ok({{"data", json(updated)}})); + }); +} + +void PostsController::deletePost(const HttpRequestPtr& req, + std::function&& callback, + const std::string& id) { + if (!require_content_enabled(callback)) + return; + API_REQUIRE_ADMIN(req, callback); + if (!require_valid_uuid(id, callback)) + return; + with_repo_errors(callback, "deletePost", [&] { + Repositories::PostRepository repo; + repo.remove(id); + callback(Response::ok({{"message", "Post deleted"}})); + }); +} + +void PostsController::previewToken(const HttpRequestPtr& req, + std::function&& callback, + const std::string& id) { + if (!require_content_enabled(callback)) + return; + API_REQUIRE_ADMIN(req, callback); + if (!require_valid_uuid(id, callback)) + return; + with_repo_errors(callback, "previewToken", [&] { + Repositories::PostRepository repo; + auto found = repo.find(id); + if (!found) { + callback(ErrorResponse::not_found("post")); + return; + } + const auto token = Security::Tokens::issue( + Security::Auth::get().config().jwt_secret, id, Security::Tokens::Purpose::Preview, kPreviewTtl); + const auto exp = Utils::Time::epoch_to_iso8601(Utils::Time::now_epoch_seconds() + kPreviewTtl.count()); + callback( + Response::ok({{"data", {{"url", "/posts/" + found->slug + "?preview=" + token}, {"expires_at", exp}}}})); + }); +} + +std::optional PostsController::resolve_post(const std::string& slug, const std::string& preview) { + Repositories::PostRepository repo; + if (preview.empty()) + return repo.find_published_by_slug(slug); + auto any = repo.find_by_slug_any(slug); + if (!any) + return std::nullopt; + if (any->status == "published") + return any; + auto vr = Security::Tokens::verify( + Security::Auth::get().config().jwt_secret, preview, Security::Tokens::Purpose::Preview); + if (vr.ok && vr.sub == any->id) + return any; + return std::nullopt; // invalid/expired/foreign token behaves like 404 +} + +void PostsController::publicListPosts(const HttpRequestPtr& req, + std::function&& callback) { + if (!require_content_enabled(callback)) + return; + // Hybrid contract: server-side filters + 1-based paging (?page=); + // facets embedded on demand so the index needs exactly one request per + // interaction. limit is hard-clamped to 50 — the old fetch-the-whole- + // feed ?limit=1000 pattern is gone. The RESPONSE envelope is the + // template's standard paginated list contract ({data, total, limit, + // offset} — see Response::paginated / ErrorResponse.hpp), not a + // bespoke shape: offset is the 0-based equivalent of the 1-based page + // query param, derived once and reused for both the repo call and the + // response body so they can't disagree. + const int limit = clamp_int(req->getParameter("limit"), 10, 1, 50); + const int page = clamp_int(req->getParameter("page"), 1, 1, 1000000); + const int offset = (page - 1) * limit; + Repositories::PublicListFilter f; + f.topic = req->getParameter("topic"); + f.tag = req->getParameter("tag"); + f.q = req->getParameter("q"); + + with_repo_errors(callback, "publicListPosts", [&] { + Repositories::PostRepository repo; + auto items = repo.list_published_cards(f, limit, offset); + long total = repo.count_published(f); + json data = items; + json out = {{"data", data}, {"total", total}, {"limit", limit}, {"offset", offset}}; + + if (req->getParameter("include").find("facets") != std::string::npos) { + auto [topics, tags] = repo.facets(f); + json jt = json::array(), jg = json::array(); + for (const auto& t : topics) + jt.push_back({{"name", t.name}, {"count", t.count}}); + for (const auto& t : tags) + jg.push_back({{"name", t.name}, {"count", t.count}}); + out["facets"] = {{"topics", jt}, {"tags", jg}}; + } + callback(Response::ok(out)); + }); +} + +void PostsController::publicGetPost(const HttpRequestPtr& req, + std::function&& callback, + const std::string& slug) { + if (!require_content_enabled(callback)) + return; + with_repo_errors(callback, "publicGetPost", [&] { + auto found = resolve_post(slug, req->getParameter("preview")); + if (!found) { + callback(ErrorResponse::not_found("post")); + return; + } + json data = json(*found); + if (req->getParameter("include").find("adjacent") != std::string::npos) { + Repositories::PostRepository repo; + auto [prev, next] = repo.find_adjacent(found->id); + data["adjacent"] = {{"prev", prev ? json{{"slug", prev->slug}, {"title", prev->title}} : json(nullptr)}, + {"next", next ? json{{"slug", next->slug}, {"title", next->title}} : json(nullptr)}}; + } + callback(Response::ok({{"data", data}})); + }); +} + +std::vector PostsController::validate_tags(const json& body, Validation::Errors& errs) { + std::vector tags; + if (!body["tags"].is_array()) { + errs.add("tags", "not_array", "must be an array of strings"); + return tags; + } + for (const auto& t : body["tags"]) { + if (!t.is_string()) { + errs.add("tags", "not_string", "each tag must be a string"); + break; + } + std::string s = t.get(); + std::size_t b = s.find_first_not_of(" \t"); + std::size_t e = s.find_last_not_of(" \t"); + if (b == std::string::npos) + continue; // blank tag → skip silently + s = s.substr(b, e - b + 1); + if (s.size() > 40) { + errs.add("tags", "too_long", "each tag max length 40"); + break; + } + if (s.find(',') != std::string::npos || s.find('\n') != std::string::npos || + s.find('\r') != std::string::npos) { + errs.add("tags", "invalid", "a tag must not contain commas or line breaks"); + break; + } + tags.push_back(std::move(s)); + } + return tags; +} + +void PostsController::validate_present_fields(const json& body, Validation::Errors& errs) { + if (body.contains("slug")) + Validation::string_length(errs, body, "slug", 1, 160); + if (body.contains("title")) + Validation::string_length(errs, body, "title", 1, 255); + if (body.contains("topic")) + Validation::string_length(errs, body, "topic", 0, 80); + if (body.contains("status")) + Validation::one_of(errs, body, "status", {"draft", "published"}); + // Optional string fields must be strings when present — body.value(k,"") + // throws type_error.306 on a non-string, escaping the handler as a 500. + for (const char* k : {"summary", "body", "status", "topic", "title"}) + if (body.contains(k) && !body[k].is_null() && !body[k].is_string()) + errs.add(k, "not_string", std::string(k) + " must be a string"); + // Slug is the public URL key: a clean path segment only. + if (body.contains("slug") && body["slug"].is_string()) { + const std::string s = body["slug"].get(); + bool ok = !s.empty(); + for (char c : s) + if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-')) + ok = false; + if (!ok || s.front() == '-' || s.back() == '-') + errs.add( + "slug", "invalid", "slug must be lowercase letters, digits and hyphens (no leading/trailing hyphen)"); + } +} + +bool PostsController::read_input(const json& body, + Repositories::PostInput& in, + const std::function& callback) { + Validation::Errors errs; + Validation::require(errs, body, "slug"); + Validation::require(errs, body, "title"); + validate_present_fields(body, errs); + std::vector tags; + if (body.contains("tags") && !body["tags"].is_null()) + tags = validate_tags(body, errs); + if (errs.any()) { + callback(Validation::response_400(errs)); + return false; + } + // Null-safe reads: validate_present_fields lets an explicit null through + // (absent and null both mean "take the default"), but body.value(k, d) + // throws type_error.302 on a null — a 500 for a client that serializes + // empty optionals as null. Same pattern merge_input uses. + in.slug = body["slug"].get(); + in.title = body["title"].get(); + in.summary = Validation::opt_string(body, "summary").value_or(std::string{}); + in.body = Validation::opt_string(body, "body").value_or(std::string{}); + in.status = Validation::opt_string(body, "status").value_or(std::string{"draft"}); + in.topic = Validation::opt_string(body, "topic").value_or(std::string{}); + in.tags = std::move(tags); + return true; +} + +bool PostsController::merge_input(const json& body, + const Domain::Post& existing, + Repositories::PostInput& in, + const std::function& callback) { + Validation::Errors errs; + validate_present_fields(body, errs); + const bool has_tags = body.contains("tags") && !body["tags"].is_null(); + std::vector tags; + if (has_tags) + tags = validate_tags(body, errs); + if (errs.any()) { + callback(Validation::response_400(errs)); + return false; + } + auto keep_str = [&](const char* k, const std::string& cur) { + return (body.contains(k) && body[k].is_string()) ? body[k].get() : cur; + }; + in.slug = keep_str("slug", existing.slug); + in.title = keep_str("title", existing.title); + in.summary = keep_str("summary", existing.summary); + in.body = keep_str("body", existing.body); + in.status = keep_str("status", existing.status); + in.topic = keep_str("topic", existing.topic); + in.tags = has_tags ? std::move(tags) : existing.tags; + return true; +} + +} // namespace Api diff --git a/src/api/PostsController.hpp b/src/api/PostsController.hpp index af680bc..14d93bf 100644 --- a/src/api/PostsController.hpp +++ b/src/api/PostsController.hpp @@ -6,25 +6,29 @@ * default (CONTENT_ENABLED=false), so a deploy that hasn't run the * posts migration yet returns 404 instead of a 500 against a missing * table. + * + * Declarations only — the handler bodies live in PostsController.cpp + * (compiled once into app_core; ADR 0003 as amended 2026-08-22). The route + * macros (ADD_METHOD_TO) must stay in this header: Drogon's METHOD_LIST + * registration is part of the class definition, and + * scripts/check-routes-registered.sh greps the src/api headers for them. */ #pragma once +#include +#include +#include +#include +#include + #include -#include -#include +#include -#include "api/Guards.hpp" -#include "api/HandlerSupport.hpp" -#include "api/RequestUtils.hpp" #include "api/Validation.hpp" #include "domain/Post.hpp" #include "repositories/PostRepository.hpp" -#include "security/Auth.hpp" -#include "security/Tokens.hpp" -#include "utils/ErrorResponse.hpp" -#include "utils/Time.hpp" namespace Api { @@ -46,111 +50,21 @@ class PostsController : public HttpController { ADD_METHOD_TO(PostsController::publicGetPost, "/api/v1/public/posts/{1}", Get); METHOD_LIST_END - void listPosts(const HttpRequestPtr& req, std::function&& callback) { - if (!require_content_enabled(callback)) - return; - API_REQUIRE_ADMIN(req, callback); - const auto page = parse_page_params(req, /*default_limit=*/50, /*max_limit=*/200); - Repositories::AdminListFilter f; - f.q = req->getParameter("q"); - f.status = req->getParameter("status"); - f.topic = req->getParameter("topic"); - f.tag = req->getParameter("tag"); - if (!f.status.empty() && f.status != "draft" && f.status != "published") { - callback(ErrorResponse::bad_request("invalid_status", "status must be draft or published")); - return; - } - with_repo_errors(callback, "listPosts", [&] { - Repositories::PostRepository repo; - auto items = repo.list_admin(f, page.limit, page.offset); - long total = repo.count_admin(f); - json data = items; - callback(Response::paginated(data, total, page.limit, page.offset)); - }); - } + void listPosts(const HttpRequestPtr& req, std::function&& callback); - void createPost(const HttpRequestPtr& req, std::function&& callback) { - if (!require_content_enabled(callback)) - return; - API_REQUIRE_ADMIN(req, callback); - json body; - if (!Validation::parse_body(req, body, callback)) - return; - Repositories::PostInput in; - if (!read_input(body, in, callback)) - return; - with_repo_errors(callback, "createPost", [&] { - Repositories::PostRepository repo; - auto created = repo.create(in); - callback(Response::created({{"data", json(created)}})); - }); - } + void createPost(const HttpRequestPtr& req, std::function&& callback); void getPost(const HttpRequestPtr& req, std::function&& callback, - const std::string& id) { - if (!require_content_enabled(callback)) - return; - API_REQUIRE_ADMIN(req, callback); - if (!require_valid_uuid(id, callback)) - return; - with_repo_errors(callback, "getPost", [&] { - Repositories::PostRepository repo; - auto found = repo.find(id); - if (!found) { - callback(ErrorResponse::not_found("post")); - return; - } - callback(Response::ok({{"data", json(*found)}})); - }); - } + const std::string& id); void updatePost(const HttpRequestPtr& req, std::function&& callback, - const std::string& id) { - if (!require_content_enabled(callback)) - return; - API_REQUIRE_ADMIN(req, callback); - if (!require_valid_uuid(id, callback)) - return; - json body; - if (!Validation::parse_body(req, body, callback)) - return; - Repositories::PostRepository repo; - // Read the merge base from the PRIMARY: this is a read-modify-write, so - // a lagging replica would make a partial PATCH silently revert whatever - // the omitted fields were last set to. Same reason as - // AdminController::updateUser's post-write re-read. - auto existing = repo.find(id, /*from_primary=*/true); - if (!existing) { - callback(ErrorResponse::not_found("post")); - return; - } - // PATCH is a partial update: merge the body over the existing post so - // omitted fields (incl. status → published_at) are preserved, not wiped. - Repositories::PostInput in; - if (!merge_input(body, *existing, in, callback)) - return; - with_repo_errors(callback, "updatePost", [&] { - auto updated = repo.update(id, in); - callback(Response::ok({{"data", json(updated)}})); - }); - } + const std::string& id); void deletePost(const HttpRequestPtr& req, std::function&& callback, - const std::string& id) { - if (!require_content_enabled(callback)) - return; - API_REQUIRE_ADMIN(req, callback); - if (!require_valid_uuid(id, callback)) - return; - with_repo_errors(callback, "deletePost", [&] { - Repositories::PostRepository repo; - repo.remove(id); - callback(Response::ok({{"message", "Post deleted"}})); - }); - } + const std::string& id); // Draft preview: a stateless HMAC token (purpose=preview, sub=post id, // 1h TTL). Reusable within its TTL, nothing stored server-side; the link @@ -159,204 +73,35 @@ class PostsController : public HttpController { void previewToken(const HttpRequestPtr& req, std::function&& callback, - const std::string& id) { - if (!require_content_enabled(callback)) - return; - API_REQUIRE_ADMIN(req, callback); - if (!require_valid_uuid(id, callback)) - return; - with_repo_errors(callback, "previewToken", [&] { - Repositories::PostRepository repo; - auto found = repo.find(id); - if (!found) { - callback(ErrorResponse::not_found("post")); - return; - } - const auto token = Security::Tokens::issue( - Security::Auth::get().config().jwt_secret, id, Security::Tokens::Purpose::Preview, kPreviewTtl); - const auto exp = Utils::Time::epoch_to_iso8601(Utils::Time::now_epoch_seconds() + kPreviewTtl.count()); - callback(Response::ok( - {{"data", {{"url", "/posts/" + found->slug + "?preview=" + token}, {"expires_at", exp}}}})); - }); - } + const std::string& id); // Returns the post for slug honoring an optional ?preview= token: // published posts always; a draft only when the token verifies AND is // bound to this post. Shared with ContentPagesController::post_markdown. - static std::optional resolve_post(const std::string& slug, const std::string& preview) { - Repositories::PostRepository repo; - if (preview.empty()) - return repo.find_published_by_slug(slug); - auto any = repo.find_by_slug_any(slug); - if (!any) - return std::nullopt; - if (any->status == "published") - return any; - auto vr = Security::Tokens::verify( - Security::Auth::get().config().jwt_secret, preview, Security::Tokens::Purpose::Preview); - if (vr.ok && vr.sub == any->id) - return any; - return std::nullopt; // invalid/expired/foreign token behaves like 404 - } + static std::optional resolve_post(const std::string& slug, const std::string& preview); // ── Public site (unauthenticated) ───────────────────────────────────── - void publicListPosts(const HttpRequestPtr& req, std::function&& callback) { - if (!require_content_enabled(callback)) - return; - // Hybrid contract: server-side filters + 1-based paging (?page=); - // facets embedded on demand so the index needs exactly one request per - // interaction. limit is hard-clamped to 50 — the old fetch-the-whole- - // feed ?limit=1000 pattern is gone. The RESPONSE envelope is the - // template's standard paginated list contract ({data, total, limit, - // offset} — see Response::paginated / ErrorResponse.hpp), not a - // bespoke shape: offset is the 0-based equivalent of the 1-based page - // query param, derived once and reused for both the repo call and the - // response body so they can't disagree. - const int limit = clamp_int(req->getParameter("limit"), 10, 1, 50); - const int page = clamp_int(req->getParameter("page"), 1, 1, 1000000); - const int offset = (page - 1) * limit; - Repositories::PublicListFilter f; - f.topic = req->getParameter("topic"); - f.tag = req->getParameter("tag"); - f.q = req->getParameter("q"); - - with_repo_errors(callback, "publicListPosts", [&] { - Repositories::PostRepository repo; - auto items = repo.list_published_cards(f, limit, offset); - long total = repo.count_published(f); - json data = items; - json out = {{"data", data}, {"total", total}, {"limit", limit}, {"offset", offset}}; - - if (req->getParameter("include").find("facets") != std::string::npos) { - auto [topics, tags] = repo.facets(f); - json jt = json::array(), jg = json::array(); - for (const auto& t : topics) - jt.push_back({{"name", t.name}, {"count", t.count}}); - for (const auto& t : tags) - jg.push_back({{"name", t.name}, {"count", t.count}}); - out["facets"] = {{"topics", jt}, {"tags", jg}}; - } - callback(Response::ok(out)); - }); - } + void publicListPosts(const HttpRequestPtr& req, std::function&& callback); void publicGetPost(const HttpRequestPtr& req, std::function&& callback, - const std::string& slug) { - if (!require_content_enabled(callback)) - return; - with_repo_errors(callback, "publicGetPost", [&] { - auto found = resolve_post(slug, req->getParameter("preview")); - if (!found) { - callback(ErrorResponse::not_found("post")); - return; - } - json data = json(*found); - if (req->getParameter("include").find("adjacent") != std::string::npos) { - Repositories::PostRepository repo; - auto [prev, next] = repo.find_adjacent(found->id); - data["adjacent"] = { - {"prev", prev ? json{{"slug", prev->slug}, {"title", prev->title}} : json(nullptr)}, - {"next", next ? json{{"slug", next->slug}, {"title", next->title}} : json(nullptr)}}; - } - callback(Response::ok({{"data", data}})); - }); - } + const std::string& slug); private: // Validate the "tags" field (optional array of non-empty keyword strings, // stored comma-joined so commas/line-breaks are rejected). Only call when // the body carries "tags"; adds to @p errs on any problem. - static std::vector validate_tags(const json& body, Validation::Errors& errs) { - std::vector tags; - if (!body["tags"].is_array()) { - errs.add("tags", "not_array", "must be an array of strings"); - return tags; - } - for (const auto& t : body["tags"]) { - if (!t.is_string()) { - errs.add("tags", "not_string", "each tag must be a string"); - break; - } - std::string s = t.get(); - std::size_t b = s.find_first_not_of(" \t"); - std::size_t e = s.find_last_not_of(" \t"); - if (b == std::string::npos) - continue; // blank tag → skip silently - s = s.substr(b, e - b + 1); - if (s.size() > 40) { - errs.add("tags", "too_long", "each tag max length 40"); - break; - } - if (s.find(',') != std::string::npos || s.find('\n') != std::string::npos || - s.find('\r') != std::string::npos) { - errs.add("tags", "invalid", "a tag must not contain commas or line breaks"); - break; - } - tags.push_back(std::move(s)); - } - return tags; - } + static std::vector validate_tags(const json& body, Validation::Errors& errs); // Validate every field PRESENT in the body (type, length, enum, slug // format). Shared by create (POST) and the partial update (PATCH); neither // requires a field here — presence is the caller's concern. - static void validate_present_fields(const json& body, Validation::Errors& errs) { - if (body.contains("slug")) - Validation::string_length(errs, body, "slug", 1, 160); - if (body.contains("title")) - Validation::string_length(errs, body, "title", 1, 255); - if (body.contains("topic")) - Validation::string_length(errs, body, "topic", 0, 80); - if (body.contains("status")) - Validation::one_of(errs, body, "status", {"draft", "published"}); - // Optional string fields must be strings when present — body.value(k,"") - // throws type_error.306 on a non-string, escaping the handler as a 500. - for (const char* k : {"summary", "body", "status", "topic", "title"}) - if (body.contains(k) && !body[k].is_null() && !body[k].is_string()) - errs.add(k, "not_string", std::string(k) + " must be a string"); - // Slug is the public URL key: a clean path segment only. - if (body.contains("slug") && body["slug"].is_string()) { - const std::string s = body["slug"].get(); - bool ok = !s.empty(); - for (char c : s) - if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-')) - ok = false; - if (!ok || s.front() == '-' || s.back() == '-') - errs.add("slug", - "invalid", - "slug must be lowercase letters, digits and hyphens (no leading/trailing hyphen)"); - } - } + static void validate_present_fields(const json& body, Validation::Errors& errs); // POST create: slug + title required, omitted optionals take defaults. static bool read_input(const json& body, Repositories::PostInput& in, - const std::function& callback) { - Validation::Errors errs; - Validation::require(errs, body, "slug"); - Validation::require(errs, body, "title"); - validate_present_fields(body, errs); - std::vector tags; - if (body.contains("tags") && !body["tags"].is_null()) - tags = validate_tags(body, errs); - if (errs.any()) { - callback(Validation::response_400(errs)); - return false; - } - // Null-safe reads: validate_present_fields lets an explicit null through - // (absent and null both mean "take the default"), but body.value(k, d) - // throws type_error.302 on a null — a 500 for a client that serializes - // empty optionals as null. Same pattern merge_input uses. - in.slug = body["slug"].get(); - in.title = body["title"].get(); - in.summary = Validation::opt_string(body, "summary").value_or(std::string{}); - in.body = Validation::opt_string(body, "body").value_or(std::string{}); - in.status = Validation::opt_string(body, "status").value_or(std::string{"draft"}); - in.topic = Validation::opt_string(body, "topic").value_or(std::string{}); - in.tags = std::move(tags); - return true; - } + const std::function& callback); // PATCH update: seed @p in from the EXISTING post, then overlay ONLY the // fields present in the body. An omitted field keeps its current value — @@ -366,29 +111,7 @@ class PostsController : public HttpController { static bool merge_input(const json& body, const Domain::Post& existing, Repositories::PostInput& in, - const std::function& callback) { - Validation::Errors errs; - validate_present_fields(body, errs); - const bool has_tags = body.contains("tags") && !body["tags"].is_null(); - std::vector tags; - if (has_tags) - tags = validate_tags(body, errs); - if (errs.any()) { - callback(Validation::response_400(errs)); - return false; - } - auto keep_str = [&](const char* k, const std::string& cur) { - return (body.contains(k) && body[k].is_string()) ? body[k].get() : cur; - }; - in.slug = keep_str("slug", existing.slug); - in.title = keep_str("title", existing.title); - in.summary = keep_str("summary", existing.summary); - in.body = keep_str("body", existing.body); - in.status = keep_str("status", existing.status); - in.topic = keep_str("topic", existing.topic); - in.tags = has_tags ? std::move(tags) : existing.tags; - return true; - } + const std::function& callback); }; } // namespace Api diff --git a/src/api/UploadController.cpp b/src/api/UploadController.cpp new file mode 100644 index 0000000..a107a55 --- /dev/null +++ b/src/api/UploadController.cpp @@ -0,0 +1,241 @@ +/** + * @file UploadController.cpp + * @brief Bodies for src/api/UploadController.hpp — compiled once into + * app_core. Contract, module gating and the local-vs-CDN serving + * rules are documented on the declarations in the header. + */ + +#include "api/UploadController.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include "api/Guards.hpp" +#include "api/HandlerSupport.hpp" +#include "api/RequestUtils.hpp" +#include "core/Modules.hpp" +#include "storage/Storage.hpp" +#include "utils/Config.hpp" +#include "utils/Crypto.hpp" +#include "utils/ErrorResponse.hpp" + +namespace Api { + +bool image_bytes_match(const std::string& ext, std::string_view b) { + const auto starts = [&](std::initializer_list sig) { + if (b.size() < sig.size()) + return false; + std::size_t i = 0; + for (unsigned char c : sig) + if (static_cast(b[i++]) != c) + return false; + return true; + }; + if (ext == "jpg" || ext == "jpeg") + return starts({0xFF, 0xD8, 0xFF}); + if (ext == "png") + return starts({0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}); + if (ext == "gif") + return b.size() >= 6 && (b.compare(0, 6, "GIF87a") == 0 || b.compare(0, 6, "GIF89a") == 0); + if (ext == "webp") + return b.size() >= 12 && b.compare(0, 4, "RIFF") == 0 && b.compare(8, 4, "WEBP") == 0; + return false; +} + +void UploadController::upload(const HttpRequestPtr& req, std::function&& callback) { + if (!require_content_enabled(callback)) + return; + API_REQUIRE_ADMIN(req, callback); + + MultiPartParser parser; + if (parser.parse(req) != 0 || parser.getFiles().empty()) { + callback(ErrorResponse::bad_request("no_file", "Expected a multipart file upload")); + return; + } + const auto& file = parser.getFiles()[0]; + + // Lowercased here (not only inside mime_for_ext) because the extension + // also feeds the stored key and the magic-number sniff below. + std::string ext(file.getFileExtension()); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); + const std::string type = mime_for_ext(ext); + if (type.empty()) { + callback(ErrorResponse::bad_request("unsupported_type", "Allowed: jpg, jpeg, png, gif, webp")); + return; + } + + // Validate on a view — the (up to 5 MB) body is only copied into an + // owning string once every check has passed, right before Storage::put. + const std::string_view bytes = file.fileContent(); + constexpr std::size_t kMaxBytes = 5 * 1024 * 1024; // 5 MB + if (bytes.empty() || bytes.size() > kMaxBytes) { + callback(ErrorResponse::bad_request("bad_size", "File must be 1 byte – 5 MB")); + return; + } + if (!image_bytes_match(ext, bytes)) { + callback(ErrorResponse::bad_request("bad_content", "File content does not match its image type")); + return; + } + + if (!require_storage(callback)) + return; + + // Opaque random key (never a client-supplied filename) under a posts/ prefix. + const std::string key = "posts/" + Utils::Crypto::random_hex(16) + "." + ext; + try { + Storage::get().put(key, std::string(bytes), type); + } catch (const std::exception& e) { + spdlog::error("upload: storage put failed for {}: {}", key, e.what()); + callback(ErrorResponse::service_unavailable("storage_error", "Could not store the file")); + return; + } + + callback(Response::created({{"data", {{"key", key}, {"url", Storage::get().url(key)}}}})); +} + +void UploadController::listUploads(const HttpRequestPtr& req, std::function&& callback) { + if (!require_content_enabled(callback)) + return; + API_REQUIRE_ADMIN(req, callback); + if (!require_storage(callback)) + return; + const auto page = parse_page_params(req, /*default_limit=*/50, /*max_limit=*/200); + with_repo_errors(callback, "listUploads", [&] { + auto& st = Storage::get(); + auto all = st.list("posts/"); + json data = json::array(); + const std::size_t from = std::min(static_cast(page.offset), all.size()); + const std::size_t to = std::min(from + static_cast(page.limit), all.size()); + for (std::size_t i = from; i < to; ++i) { + const auto& o = all[i]; + const std::string name = o.key.substr(o.key.rfind('/') + 1); + data.push_back({{"key", o.key}, + {"name", name}, + {"url", st.url(o.key)}, + {"size_bytes", o.size_bytes}, + {"content_type", content_type_for(name)}, + {"created_at", o.last_modified}}); + } + callback(Response::paginated(data, static_cast(all.size()), page.limit, page.offset)); + }); +} + +void UploadController::deleteUpload(const HttpRequestPtr& req, + std::function&& callback, + const std::string& name) { + if (!require_content_enabled(callback)) + return; + API_REQUIRE_ADMIN(req, callback); + if (name.empty() || name.find('/') != std::string::npos || name.find("..") != std::string::npos || + name.find('\\') != std::string::npos) { + callback(ErrorResponse::bad_request("invalid_name", "Expected a single-segment object name")); + return; + } + if (!require_storage(callback)) + return; + const std::string key = "posts/" + name; + with_repo_errors(callback, "deleteUpload", [&] { + if (!Storage::get().exists(key)) { + callback(ErrorResponse::not_found("upload")); + return; + } + Storage::get().remove(key); + callback(Response::ok({{"message", "Upload deleted"}})); + }); +} + +void UploadController::serveUpload(const HttpRequestPtr&, + std::function&& callback, + const std::string& key) { + auto no_such_upload = [&] { callback(ErrorResponse::not_found("upload")); }; + if (!Core::content_enabled()) { + no_such_upload(); + return; + } + // Traversal guard: keys are opaque ids under posts/. key_is_safe covers + // "..", a leading '/' or '\', empty and NUL; the backslash can also sit + // mid-key on a Windows-style path, so reject it anywhere. Every key + // `upload` ever writes is "posts/" + a random hex name + extension + // (see upload() above) — defense-in-depth pins reads to that same + // prefix so this route can never be used to fetch an object stored + // under some other prefix a future writer might introduce. + if (!Storage::key_is_safe(key) || key.find('\\') != std::string::npos || key.rfind("posts/", 0) != 0) { + no_such_upload(); + return; + } + // Content type comes from the extension allowlist — never from the + // request — and anything outside it (no extension, .svg, .html) is a + // 404 rather than an octet-stream download. + const std::string type = content_type_for(key.substr(key.rfind('/') + 1)); + if (type == "application/octet-stream") { + no_such_upload(); + return; + } + if (!Storage::is_initialized() || !serves_uploads_locally()) { + no_such_upload(); + return; + } + std::optional bytes; + try { + bytes = Storage::get().get(key); + } catch (const std::exception& e) { + spdlog::error("serveUpload: storage get failed for {}: {}", key, e.what()); + callback(ErrorResponse::service_unavailable("storage_error", "Could not read the file")); + return; + } + if (!bytes) { + no_such_upload(); + return; + } + auto resp = HttpResponse::newHttpResponse(); + resp->setBody(std::move(*bytes)); + resp->setContentTypeString(type); + // Keys are random and an object is never rewritten in place. + resp->addHeader("Cache-Control", "public, max-age=31536000, immutable"); + callback(resp); +} + +bool UploadController::serves_uploads_locally() { + if (!Config::is_initialized()) + return false; + auto& cfg = Config::get(); + if (cfg.get("storage.backend", "STORAGE_BACKEND", "local") != "local") + return false; + return cfg.get("storage.public_base_url", "STORAGE_PUBLIC_BASE_URL", "").empty(); +} + +bool UploadController::require_storage(const std::function& callback) { + if (Storage::is_initialized()) + return true; + callback(ErrorResponse::service_unavailable("storage_unavailable", "Storage backend not configured")); + return false; +} + +std::string UploadController::mime_for_ext(std::string ext) { + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); + if (ext == "jpg" || ext == "jpeg") + return "image/jpeg"; + if (ext == "png") + return "image/png"; + if (ext == "gif") + return "image/gif"; + if (ext == "webp") + return "image/webp"; + return {}; +} + +std::string UploadController::content_type_for(const std::string& name) { + const auto dot = name.rfind('.'); + const std::string mime = mime_for_ext(dot == std::string::npos ? std::string{} : name.substr(dot + 1)); + return mime.empty() ? "application/octet-stream" : mime; +} + +} // namespace Api diff --git a/src/api/UploadController.hpp b/src/api/UploadController.hpp index 768f772..367ea7b 100644 --- a/src/api/UploadController.hpp +++ b/src/api/UploadController.hpp @@ -14,32 +14,24 @@ * module-off contract as PostsController/ContentPagesController: with * the content module disabled, every route here (including the public * read route) 404s instead of touching Storage. + * + * Declarations only — the handler bodies live in UploadController.cpp + * (compiled once into app_core; ADR 0003 as amended 2026-08-22). The + * route macros (ADD_METHOD_TO) must stay in this header: Drogon's + * METHOD_LIST registration is part of the class definition, and + * scripts/check-routes-registered.sh greps the src/api headers for + * them. */ #pragma once -#include -#include -#include -#include -#include +#include #include #include #include -#include -#include - -#include -#include "api/Guards.hpp" -#include "api/HandlerSupport.hpp" -#include "api/RequestUtils.hpp" -#include "core/Modules.hpp" -#include "storage/Storage.hpp" -#include "utils/Config.hpp" -#include "utils/Crypto.hpp" -#include "utils/ErrorResponse.hpp" +#include namespace Api { @@ -48,26 +40,7 @@ using json = nlohmann::json; // Cheap magic-number sniff: confirm the bytes actually match the claimed image // type, so a .jpg that's really HTML/script can't be stored and served back. -inline bool image_bytes_match(const std::string& ext, std::string_view b) { - const auto starts = [&](std::initializer_list sig) { - if (b.size() < sig.size()) - return false; - std::size_t i = 0; - for (unsigned char c : sig) - if (static_cast(b[i++]) != c) - return false; - return true; - }; - if (ext == "jpg" || ext == "jpeg") - return starts({0xFF, 0xD8, 0xFF}); - if (ext == "png") - return starts({0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}); - if (ext == "gif") - return b.size() >= 6 && (b.compare(0, 6, "GIF87a") == 0 || b.compare(0, 6, "GIF89a") == 0); - if (ext == "webp") - return b.size() >= 12 && b.compare(0, 4, "RIFF") == 0 && b.compare(8, 4, "WEBP") == 0; - return false; -} +bool image_bytes_match(const std::string& ext, std::string_view b); class UploadController : public HttpController { public: @@ -83,110 +56,16 @@ class UploadController : public HttpController { ADD_METHOD_VIA_REGEX(UploadController::serveUpload, "/uploads/(.*)", Get); METHOD_LIST_END - void upload(const HttpRequestPtr& req, std::function&& callback) { - if (!require_content_enabled(callback)) - return; - API_REQUIRE_ADMIN(req, callback); - - MultiPartParser parser; - if (parser.parse(req) != 0 || parser.getFiles().empty()) { - callback(ErrorResponse::bad_request("no_file", "Expected a multipart file upload")); - return; - } - const auto& file = parser.getFiles()[0]; - - // Lowercased here (not only inside mime_for_ext) because the extension - // also feeds the stored key and the magic-number sniff below. - std::string ext(file.getFileExtension()); - std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); - const std::string type = mime_for_ext(ext); - if (type.empty()) { - callback(ErrorResponse::bad_request("unsupported_type", "Allowed: jpg, jpeg, png, gif, webp")); - return; - } - - // Validate on a view — the (up to 5 MB) body is only copied into an - // owning string once every check has passed, right before Storage::put. - const std::string_view bytes = file.fileContent(); - constexpr std::size_t kMaxBytes = 5 * 1024 * 1024; // 5 MB - if (bytes.empty() || bytes.size() > kMaxBytes) { - callback(ErrorResponse::bad_request("bad_size", "File must be 1 byte – 5 MB")); - return; - } - if (!image_bytes_match(ext, bytes)) { - callback(ErrorResponse::bad_request("bad_content", "File content does not match its image type")); - return; - } - - if (!require_storage(callback)) - return; - - // Opaque random key (never a client-supplied filename) under a posts/ prefix. - const std::string key = "posts/" + Utils::Crypto::random_hex(16) + "." + ext; - try { - Storage::get().put(key, std::string(bytes), type); - } catch (const std::exception& e) { - spdlog::error("upload: storage put failed for {}: {}", key, e.what()); - callback(ErrorResponse::service_unavailable("storage_error", "Could not store the file")); - return; - } - - callback(Response::created({{"data", {{"key", key}, {"url", Storage::get().url(key)}}}})); - } + void upload(const HttpRequestPtr& req, std::function&& callback); // GET /api/v1/admin/uploads — offset-paged listing of the posts/ prefix. - void listUploads(const HttpRequestPtr& req, std::function&& callback) { - if (!require_content_enabled(callback)) - return; - API_REQUIRE_ADMIN(req, callback); - if (!require_storage(callback)) - return; - const auto page = parse_page_params(req, /*default_limit=*/50, /*max_limit=*/200); - with_repo_errors(callback, "listUploads", [&] { - auto& st = Storage::get(); - auto all = st.list("posts/"); - json data = json::array(); - const std::size_t from = std::min(static_cast(page.offset), all.size()); - const std::size_t to = std::min(from + static_cast(page.limit), all.size()); - for (std::size_t i = from; i < to; ++i) { - const auto& o = all[i]; - const std::string name = o.key.substr(o.key.rfind('/') + 1); - data.push_back({{"key", o.key}, - {"name", name}, - {"url", st.url(o.key)}, - {"size_bytes", o.size_bytes}, - {"content_type", content_type_for(name)}, - {"created_at", o.last_modified}}); - } - callback(Response::paginated(data, static_cast(all.size()), page.limit, page.offset)); - }); - } + void listUploads(const HttpRequestPtr& req, std::function&& callback); // DELETE /api/v1/admin/uploads/{name} — name is the single-segment // basename of an upload key (keys are posts/.). void deleteUpload(const HttpRequestPtr& req, std::function&& callback, - const std::string& name) { - if (!require_content_enabled(callback)) - return; - API_REQUIRE_ADMIN(req, callback); - if (name.empty() || name.find('/') != std::string::npos || name.find("..") != std::string::npos || - name.find('\\') != std::string::npos) { - callback(ErrorResponse::bad_request("invalid_name", "Expected a single-segment object name")); - return; - } - if (!require_storage(callback)) - return; - const std::string key = "posts/" + name; - with_repo_errors(callback, "deleteUpload", [&] { - if (!Storage::get().exists(key)) { - callback(ErrorResponse::not_found("upload")); - return; - } - Storage::get().remove(key); - callback(Response::ok({{"message", "Upload deleted"}})); - }); - } + const std::string& name); // GET /uploads/{key} — read an uploaded image back out of local storage. // Public (post bodies link to it) and read-only. Everything that isn't a @@ -195,101 +74,27 @@ class UploadController : public HttpController { // set, the stored URLs point at that origin, and nothing links here. void serveUpload(const HttpRequestPtr&, std::function&& callback, - const std::string& key) { - auto no_such_upload = [&] { callback(ErrorResponse::not_found("upload")); }; - if (!Core::content_enabled()) { - no_such_upload(); - return; - } - // Traversal guard: keys are opaque ids under posts/. key_is_safe covers - // "..", a leading '/' or '\', empty and NUL; the backslash can also sit - // mid-key on a Windows-style path, so reject it anywhere. Every key - // `upload` ever writes is "posts/" + a random hex name + extension - // (see upload() above) — defense-in-depth pins reads to that same - // prefix so this route can never be used to fetch an object stored - // under some other prefix a future writer might introduce. - if (!Storage::key_is_safe(key) || key.find('\\') != std::string::npos || key.rfind("posts/", 0) != 0) { - no_such_upload(); - return; - } - // Content type comes from the extension allowlist — never from the - // request — and anything outside it (no extension, .svg, .html) is a - // 404 rather than an octet-stream download. - const std::string type = content_type_for(key.substr(key.rfind('/') + 1)); - if (type == "application/octet-stream") { - no_such_upload(); - return; - } - if (!Storage::is_initialized() || !serves_uploads_locally()) { - no_such_upload(); - return; - } - std::optional bytes; - try { - bytes = Storage::get().get(key); - } catch (const std::exception& e) { - spdlog::error("serveUpload: storage get failed for {}: {}", key, e.what()); - callback(ErrorResponse::service_unavailable("storage_error", "Could not read the file")); - return; - } - if (!bytes) { - no_such_upload(); - return; - } - auto resp = HttpResponse::newHttpResponse(); - resp->setBody(std::move(*bytes)); - resp->setContentTypeString(type); - // Keys are random and an object is never rewritten in place. - resp->addHeader("Cache-Control", "public, max-age=31536000, immutable"); - callback(resp); - } + const std::string& key); private: // True when this process is the origin for uploads: the local backend with // no external public base URL, which is exactly when Storage::url() returns // the same-origin /uploads/ path that serveUpload answers. - static bool serves_uploads_locally() { - if (!Config::is_initialized()) - return false; - auto& cfg = Config::get(); - if (cfg.get("storage.backend", "STORAGE_BACKEND", "local") != "local") - return false; - return cfg.get("storage.public_base_url", "STORAGE_PUBLIC_BASE_URL", "").empty(); - } + static bool serves_uploads_locally(); // Reject with 503 unless a Storage backend is configured. Returns false // after responding — callers `if (!require_storage(callback)) return;`, // same contract as the Api::require_* guards. - static bool require_storage(const std::function& callback) { - if (Storage::is_initialized()) - return true; - callback(ErrorResponse::service_unavailable("storage_unavailable", "Storage backend not configured")); - return false; - } + static bool require_storage(const std::function& callback); // The single extension → MIME table (uploads are raster images only; SVG // intentionally excluded: it can carry inline