Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0623c28..4c29ae9 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -18,10 +18,18 @@ add_executable(
card_collection
src/app.cpp
src/asset_store.cpp
+ src/authentication.cpp
+ src/authorization.cpp
+ src/card_pool.cpp
src/card_service.cpp
+ src/clock.cpp
+ src/collection.cpp
src/config.cpp
src/data.cpp
src/data_sqlite.cpp
+ src/email_address.cpp
+ src/email_sender_file.cpp
+ src/email_sender_mailjet.cpp
src/game_registry.cpp
src/image_processor.cpp
src/markdown_renderer.cpp
@@ -30,7 +38,10 @@ add_executable(
src/non_secret_random.cpp
src/public_id.cpp
src/series_service.cpp
+ src/secret_token.cpp
src/startup.cpp
+ src/username.cpp
+ src/user_service.cpp
src/url_builder.cpp
)
@@ -66,19 +77,26 @@ target_link_libraries(
ImageMagick::MagickCore
MacroDown::MacroDown
mw::http-server
+ mw::crypto
mw::mw
mw::sqlite
mw::url
+ nlohmann_json::nlohmann_json
pantor::inja
spdlog::spdlog
tomlplusplus::tomlplusplus
+ uni-algo::uni-algo
)
if(CARD_COLLECTION_BUILD_TESTS)
include(CTest)
enable_testing()
- add_executable(data_mock_test tests/data_mock_test.cpp)
+ add_executable(
+ data_mock_test
+ src/data.cpp
+ tests/data_mock_test.cpp
+ )
target_compile_features(data_mock_test PRIVATE cxx_std_23)
set_target_properties(data_mock_test PROPERTIES CXX_EXTENSIONS OFF)
target_include_directories(
@@ -98,9 +116,140 @@ if(CARD_COLLECTION_BUILD_TESTS)
include(GoogleTest)
gtest_discover_tests(data_mock_test)
+ add_executable(
+ mvp_primitives_test
+ src/authorization.cpp
+ src/card_pool.cpp
+ src/clock.cpp
+ src/email_address.cpp
+ src/email_sender_file.cpp
+ src/secret_token.cpp
+ src/username.cpp
+ tests/mvp_primitives_test.cpp
+ )
+ target_compile_features(mvp_primitives_test PRIVATE cxx_std_23)
+ set_target_properties(
+ mvp_primitives_test
+ PROPERTIES CXX_EXTENSIONS OFF
+ )
+ target_include_directories(
+ mvp_primitives_test
+ PRIVATE
+ ${libmw_SOURCE_DIR}/includes
+ src
+ )
+ target_link_libraries(
+ mvp_primitives_test
+ PRIVATE
+ GTest::gmock_main
+ mw::crypto
+ mw::mw
+ mw::url
+ uni-algo::uni-algo
+ )
+ gtest_discover_tests(mvp_primitives_test)
+
+ add_executable(
+ user_collection_test
+ src/card_pool.cpp
+ src/clock.cpp
+ src/collection.cpp
+ src/data.cpp
+ src/data_sqlite.cpp
+ src/game_registry.cpp
+ src/startup.cpp
+ src/username.cpp
+ src/user_service.cpp
+ tests/user_collection_test.cpp
+ )
+ target_compile_features(user_collection_test PRIVATE cxx_std_23)
+ set_target_properties(
+ user_collection_test
+ PROPERTIES CXX_EXTENSIONS OFF
+ )
+ target_include_directories(
+ user_collection_test
+ PRIVATE
+ ${libmw_SOURCE_DIR}/includes
+ src
+ )
+ target_link_libraries(
+ user_collection_test
+ PRIVATE
+ GTest::gmock_main
+ mw::crypto
+ mw::mw
+ mw::sqlite
+ spdlog::spdlog
+ uni-algo::uni-algo
+ )
+ gtest_discover_tests(user_collection_test)
+
+ add_executable(
+ authentication_test
+ src/authentication.cpp
+ src/clock.cpp
+ src/data.cpp
+ src/data_sqlite.cpp
+ src/email_address.cpp
+ src/game_registry.cpp
+ src/secret_token.cpp
+ src/startup.cpp
+ tests/authentication_test.cpp
+ )
+ target_compile_features(authentication_test PRIVATE cxx_std_23)
+ set_target_properties(
+ authentication_test
+ PROPERTIES CXX_EXTENSIONS OFF
+ )
+ target_include_directories(
+ authentication_test
+ PRIVATE
+ ${libmw_SOURCE_DIR}/includes
+ src
+ )
+ target_link_libraries(
+ authentication_test
+ PRIVATE
+ GTest::gmock_main
+ mw::crypto
+ mw::mw
+ mw::sqlite
+ mw::url
+ spdlog::spdlog
+ )
+ gtest_discover_tests(authentication_test)
+
+ add_executable(
+ email_sender_mailjet_test
+ src/email_sender_mailjet.cpp
+ tests/email_sender_mailjet_test.cpp
+ )
+ target_compile_features(email_sender_mailjet_test PRIVATE cxx_std_23)
+ set_target_properties(
+ email_sender_mailjet_test
+ PROPERTIES CXX_EXTENSIONS OFF
+ )
+ target_include_directories(
+ email_sender_mailjet_test
+ PRIVATE
+ ${libmw_SOURCE_DIR}/includes
+ src
+ )
+ target_link_libraries(
+ email_sender_mailjet_test
+ PRIVATE
+ GTest::gmock_main
+ mw::mw
+ mw::url
+ nlohmann_json::nlohmann_json
+ )
+ gtest_discover_tests(email_sender_mailjet_test)
+
add_executable(
config_test
src/config.cpp
+ src/email_address.cpp
src/non_secret_random.cpp
tests/config_test.cpp
)
@@ -120,6 +269,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
mw::mw
mw::url
tomlplusplus::tomlplusplus
+ uni-algo::uni-algo
)
gtest_discover_tests(config_test)
@@ -153,7 +303,16 @@ if(CARD_COLLECTION_BUILD_TESTS)
app_test
src/app.cpp
src/asset_store.cpp
+ src/authentication.cpp
+ src/authorization.cpp
+ src/card_pool.cpp
src/card_service.cpp
+ src/clock.cpp
+ src/collection.cpp
+ src/data.cpp
+ src/email_address.cpp
+ src/email_sender_file.cpp
+ src/email_sender_mailjet.cpp
src/game_registry.cpp
src/non_secret_random.cpp
src/data_fake.cpp
@@ -162,6 +321,9 @@ if(CARD_COLLECTION_BUILD_TESTS)
src/multipart_reader.cpp
src/public_id.cpp
src/series_service.cpp
+ src/secret_token.cpp
+ src/username.cpp
+ src/user_service.cpp
src/url_builder.cpp
tests/app_test.cpp
)
@@ -182,10 +344,14 @@ if(CARD_COLLECTION_BUILD_TESTS)
ImageMagick::MagickCore
MacroDown::MacroDown
mw::http-server
+ mw::crypto
+ mw::mw
mw::sqlite
mw::url
+ nlohmann_json::nlohmann_json
pantor::inja
spdlog::spdlog
+ uni-algo::uni-algo
)
target_compile_definitions(
app_test
@@ -198,9 +364,17 @@ if(CARD_COLLECTION_BUILD_TESTS)
app_integration_test
src/app.cpp
src/asset_store.cpp
+ src/authentication.cpp
+ src/authorization.cpp
+ src/card_pool.cpp
src/card_service.cpp
+ src/clock.cpp
+ src/collection.cpp
src/data.cpp
src/data_sqlite.cpp
+ src/email_address.cpp
+ src/email_sender_file.cpp
+ src/email_sender_mailjet.cpp
src/game_registry.cpp
src/image_processor.cpp
src/markdown_renderer.cpp
@@ -208,7 +382,10 @@ if(CARD_COLLECTION_BUILD_TESTS)
src/non_secret_random.cpp
src/public_id.cpp
src/series_service.cpp
+ src/secret_token.cpp
src/startup.cpp
+ src/username.cpp
+ src/user_service.cpp
src/url_builder.cpp
tests/app_integration_test.cpp
)
@@ -237,16 +414,20 @@ if(CARD_COLLECTION_BUILD_TESTS)
ImageMagick::MagickCore
MacroDown::MacroDown
mw::http-server
+ mw::crypto
mw::mw
mw::sqlite
mw::url
+ nlohmann_json::nlohmann_json
pantor::inja
spdlog::spdlog
+ uni-algo::uni-algo
)
gtest_discover_tests(app_integration_test)
add_executable(
data_fake_test
+ src/data.cpp
src/data_fake.cpp
tests/data_fake_test.cpp
)
@@ -295,6 +476,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
add_executable(
card_service_test
src/asset_store.cpp
+ src/authorization.cpp
src/card_service.cpp
src/data.cpp
src/data_sqlite.cpp
@@ -304,6 +486,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
src/multipart_reader.cpp
src/non_secret_random.cpp
src/public_id.cpp
+ src/series_service.cpp
src/startup.cpp
tests/card_service_test.cpp
)
diff --git a/cmake/dependencies.cmake b/cmake/dependencies.cmake
index 3483b88..f6d4958 100644
--- a/cmake/dependencies.cmake
+++ b/cmake/dependencies.cmake
@@ -62,7 +62,7 @@ set(LIBMW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(LIBMW_BUILD_URL ON CACHE BOOL "" FORCE)
set(LIBMW_BUILD_SQLITE ON CACHE BOOL "" FORCE)
set(LIBMW_BUILD_HTTP_SERVER ON CACHE BOOL "" FORCE)
-set(LIBMW_BUILD_CRYPTO OFF CACHE BOOL "" FORCE)
+set(LIBMW_BUILD_CRYPTO ON CACHE BOOL "" FORCE)
set(INJA_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(INJA_EXPORT OFF CACHE BOOL "" FORCE)
@@ -76,6 +76,8 @@ set(SPDLOG_USE_STD_FORMAT ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(
spdlog
+ json
+ uni-algo
libmw
macrodown
tomlplusplus
diff --git a/config.example.toml b/config.example.toml
index 6a46f20..91739ec 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -6,3 +6,21 @@ database_path = "var/card_collection.sqlite3"
card_storage_root = "var/cards"
avif_quality = 75
thumbnail_long_side = 256
+
+# Immutable after the first database initialization.
+administrator_email = "admin@example.com"
+
+# Lowering this value can permanently discard accrued availability.
+maximum_accumulated_pulls = 3
+
+[email]
+transport = "file"
+link_file = "/tmp/card-collection-authentication-link.txt"
+
+# Production Mailjet alternative (replace the file settings above):
+# transport = "mailjet"
+# from_address = "cards@example.com"
+# from_name = "Card Collection"
+# mailjet_api_key_environment = "CARD_COLLECTION_MAILJET_API_KEY"
+# mailjet_secret_key_environment = "CARD_COLLECTION_MAILJET_SECRET_KEY"
+# daily_attempt_limit = 180
diff --git a/src/app.cpp b/src/app.cpp
index 2382566..8fec6fd 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -4,6 +4,7 @@
#include <charconv>
#include <cctype>
#include <cstddef>
+#include <cstdlib>
#include <filesystem>
#include <functional>
#include <memory>
@@ -16,11 +17,15 @@
#include <vector>
#include <spdlog/spdlog.h>
+#include <mw/crypto.hpp>
#include <mw/utils.hpp>
+#include "email_sender_file.h"
+#include "email_sender_mailjet.h"
#include "public_id.h"
#include "multipart_reader.h"
#include "game_definition.h"
+#include "secret_token.h"
namespace
{
@@ -49,6 +54,81 @@ struct HtmlSubstitution
RenderedHtml html;
};
+std::unique_ptr<EmailSenderInterface> makeEmailSender(const Config& config)
+{
+ if(config.email.transport == EmailTransport::FILE)
+ {
+ std::filesystem::path target = config.email.link_file;
+ if(target.empty())
+ {
+ target = config.database_path.parent_path() /
+ "latest-authentication-link.txt";
+ }
+ return std::make_unique<FileEmailSender>(std::move(target));
+ }
+ const char* api_key = std::getenv(
+ config.email.mailjet_api_key_environment.c_str());
+ const char* secret_key = std::getenv(
+ config.email.mailjet_secret_key_environment.c_str());
+ if(api_key == nullptr || *api_key == '\0' ||
+ secret_key == nullptr || *secret_key == '\0')
+ {
+ throw std::runtime_error(
+ "Mailjet credential environment variables are missing");
+ }
+ auto sender = std::make_unique<MailjetEmailSender>(
+ std::make_unique<mw::HTTPSession>(),
+ config.email.from_address,
+ config.email.from_name,
+ api_key,
+ secret_key);
+ auto configured = sender->configure();
+ if(!configured)
+ {
+ throw std::runtime_error(
+ "Failed to configure Mailjet transport: " +
+ configured.error().msg());
+ }
+ return sender;
+}
+
+std::optional<std::string> cookieValue(
+ const App::Request& request, std::string_view name)
+{
+ std::optional<std::string> result;
+ const std::size_t header_count = request.get_header_value_count("Cookie");
+ for(std::size_t header_index = 0;
+ header_index < header_count;
+ ++header_index)
+ {
+ const std::string header_value = request.get_header_value(
+ "Cookie", nullptr, header_index);
+ std::string_view header = header_value;
+ while(!header.empty())
+ {
+ const std::size_t separator = header.find(';');
+ std::string_view item = header.substr(0, separator);
+ item = mw::strip(item);
+ const std::size_t equals = item.find('=');
+ if(equals != std::string_view::npos &&
+ item.substr(0, equals) == name)
+ {
+ if(result)
+ {
+ return std::nullopt;
+ }
+ result = std::string(item.substr(equals + 1));
+ }
+ if(separator == std::string_view::npos)
+ {
+ break;
+ }
+ header.remove_prefix(separator + 1);
+ }
+ }
+ return result;
+}
+
RouteSegment literal(std::string value)
{
return {RouteSegmentKind::LITERAL, std::move(value)};
@@ -60,7 +140,41 @@ RouteSegment placeholder(std::string value)
}
const std::unordered_map<std::string, RouteDefinition> ROUTES = {
- {"card-index", {RouteKind::DYNAMIC, {}}},
+ {"welcome", {RouteKind::DYNAMIC, {}}},
+ {"authentication",
+ {RouteKind::DYNAMIC, {literal("authentication")}}},
+ {"authentication-email",
+ {RouteKind::DYNAMIC,
+ {literal("authentication"), literal("email")}}},
+ {"authentication-sent",
+ {RouteKind::DYNAMIC,
+ {literal("authentication"), literal("sent")}}},
+ {"authentication-confirm",
+ {RouteKind::DYNAMIC,
+ {literal("authentication"), literal("confirm"), placeholder("token")}}},
+ {"logout", {RouteKind::DYNAMIC, {literal("logout")}}},
+ {"onboarding",
+ {RouteKind::DYNAMIC,
+ {literal("onboarding"), literal("username")}}},
+ {"account", {RouteKind::DYNAMIC, {literal("account")}}},
+ {"account-username",
+ {RouteKind::DYNAMIC,
+ {literal("account"), literal("username")}}},
+ {"collection", {RouteKind::DYNAMIC, {literal("collection")}}},
+ {"collection-pull",
+ {RouteKind::DYNAMIC,
+ {literal("collection"), literal("pull")}}},
+ {"creator-cards",
+ {RouteKind::DYNAMIC,
+ {literal("creator"), literal("cards")}}},
+ {"card-index",
+ {RouteKind::DYNAMIC, {literal("admin"), literal("cards")}}},
+ {"admin-users",
+ {RouteKind::DYNAMIC, {literal("admin"), literal("users")}}},
+ {"admin-promote",
+ {RouteKind::DYNAMIC,
+ {literal("admin"), literal("users"), placeholder("integer"),
+ literal("promote")}}},
{"card-new", {RouteKind::DYNAMIC, {literal("cards"), literal("new")}}},
{"cards", {RouteKind::DYNAMIC, {literal("cards")}}},
{"card", {RouteKind::DYNAMIC,
@@ -70,19 +184,24 @@ const std::unordered_map<std::string, RouteDefinition> ROUTES = {
{"card-delete",
{RouteKind::DYNAMIC,
{literal("cards"), placeholder("id"), literal("delete")}}},
- {"series-index", {RouteKind::DYNAMIC, {literal("series")}}},
+ {"series-index",
+ {RouteKind::DYNAMIC, {literal("admin"), literal("series")}}},
{"series-new",
- {RouteKind::DYNAMIC, {literal("series"), literal("new")}}},
- {"series", {RouteKind::DYNAMIC, {literal("series")}}},
+ {RouteKind::DYNAMIC,
+ {literal("admin"), literal("series"), literal("new")}}},
+ {"series", {RouteKind::DYNAMIC,
+ {literal("admin"), literal("series")}}},
{"series-edit",
{RouteKind::DYNAMIC,
- {literal("series"), placeholder("integer"), literal("edit")}}},
+ {literal("admin"), literal("series"), placeholder("integer"),
+ literal("edit")}}},
{"series-item",
{RouteKind::DYNAMIC,
- {literal("series"), placeholder("integer")}}},
+ {literal("admin"), literal("series"), placeholder("integer")}}},
{"series-delete",
{RouteKind::DYNAMIC,
- {literal("series"), placeholder("integer"), literal("delete")}}},
+ {literal("admin"), literal("series"), placeholder("integer"),
+ literal("delete")}}},
{"card-asset", {RouteKind::STATIC_MOUNT, {literal("static-cards")}}},
{"static", {RouteKind::STATIC_MOUNT, {literal("static")}}},
};
@@ -253,6 +372,39 @@ void respondOperationError(
respondInternalError(response);
}
+void respondTemplate(
+ inja::Environment& environment,
+ const std::string& filename,
+ const inja::json& data,
+ App::Response& response)
+{
+ try
+ {
+ response.set_content(
+ environment.render_file(filename, data),
+ "text/html; charset=utf-8");
+ }
+ catch(const std::exception& error)
+ {
+ spdlog::error("Failed to render {}: {}", filename, error.what());
+ respondInternalError(response);
+ }
+}
+
+std::string roleName(UserRole role)
+{
+ switch(role)
+ {
+ case UserRole::PLAYER:
+ return "Player";
+ case UserRole::CREATOR:
+ return "Creator";
+ case UserRole::ADMINISTRATOR:
+ return "Administrator";
+ }
+ return "Unknown";
+}
+
std::optional<std::string> optionalText(
const std::unordered_map<std::string, std::string>& fields,
const std::string& name)
@@ -411,6 +563,23 @@ App::App(
"App requires data, games, and a random generator");
}
+ clock_ = std::make_unique<SystemClock>();
+ crypto_ = std::make_unique<mw::Crypto>();
+ email_sender_ = makeEmailSender(config_);
+ authentication_service_ = std::make_unique<AuthenticationService>(
+ *data_source_,
+ *email_sender_,
+ *clock_,
+ *crypto_,
+ config_.base_url,
+ config_.email.daily_attempt_limit);
+ user_service_ = std::make_unique<UserService>(*data_source_);
+ collection_service_ = std::make_unique<CollectionService>(
+ *data_source_,
+ *clock_,
+ *crypto_,
+ config_.maximum_accumulated_pulls);
+
card_service_ = std::make_unique<CardService>(
*data_source_,
*random_,
@@ -459,10 +628,575 @@ App::App(
series_index_template_ = templates_.parse_template("series_index.html");
}
-void App::handleCardNew(
+void App::setCookie(
+ Response& response,
+ const std::string& name,
+ const std::string& value,
+ std::int64_t maximum_age,
+ bool strict_same_site) const
+{
+ std::string path = config_.base_url.path();
+ if(path.empty())
+ {
+ path = "/";
+ }
+ std::string cookie = name + '=' + value + "; Path=" + path +
+ "; HttpOnly; SameSite=" +
+ (strict_same_site ? "Strict" : "Lax") +
+ "; Max-Age=" + std::to_string(maximum_age);
+ if(config_.base_url.scheme() == "https")
+ {
+ cookie += "; Secure";
+ }
+ response.set_header("Set-Cookie", cookie);
+}
+
+std::optional<App::RequestIdentity> App::requireIdentity(
+ const Request& request,
+ Response& response,
+ bool safe_get,
+ bool allow_onboarding)
+{
+ auto raw_token = cookieValue(request, "card_collection_session");
+ std::optional<SessionContext> session;
+ if(raw_token)
+ {
+ auto loaded = authentication_service_->session(*raw_token);
+ if(!loaded)
+ {
+ spdlog::error("Failed to load session: {}", loaded.error().msg());
+ respondInternalError(response);
+ return std::nullopt;
+ }
+ session = std::move(*loaded);
+ }
+ if(!raw_token || !session)
+ {
+ if(raw_token)
+ {
+ setCookie(
+ response, "card_collection_session", "", 0, false);
+ }
+ response.status = safe_get ? 303 : 401;
+ if(safe_get)
+ {
+ response.set_header("Location", urlFor("authentication"));
+ }
+ else
+ {
+ response.set_content(
+ "Authentication required\n", "text/plain; charset=utf-8");
+ }
+ return std::nullopt;
+ }
+ if(!allow_onboarding && !session->user.username)
+ {
+ response.status = safe_get ? 303 : 409;
+ if(safe_get)
+ {
+ response.set_header("Location", urlFor("onboarding"));
+ }
+ else
+ {
+ response.set_content(
+ "Username onboarding required\n",
+ "text/plain; charset=utf-8");
+ }
+ return std::nullopt;
+ }
+ response.set_header("Cache-Control", "private, no-store");
+ return RequestIdentity{std::move(*session), std::move(*raw_token)};
+}
+
+bool App::verifyCsrf(
+ const Request& request,
+ const RequestIdentity& identity,
+ Response& response) const
+{
+ if(request.get_param_value_count("csrf_token") != 1 ||
+ !constantTimeEqual(
+ request.get_param_value("csrf_token"),
+ identity.session.csrf_token))
+ {
+ response.status = 403;
+ response.set_content("Invalid CSRF token\n", "text/plain");
+ return false;
+ }
+ return true;
+}
+
+void App::handleWelcome(const Request& request, Response& response)
+{
+ auto token = cookieValue(request, "card_collection_session");
+ if(token)
+ {
+ auto session = authentication_service_->session(*token);
+ if(!session)
+ {
+ respondInternalError(response);
+ return;
+ }
+ if(*session)
+ {
+ response.status = 303;
+ response.set_header(
+ "Location",
+ (**session).user.username
+ ? urlFor("collection")
+ : urlFor("onboarding"));
+ return;
+ }
+ setCookie(response, "card_collection_session", "", 0, false);
+ }
+ respondTemplate(templates_, "welcome.html", {
+ {"authentication_url", urlFor("authentication")},
+ {"example_url", urlFor("static", {"card_placeholder.svg"})},
+ {"title", "Card Collection"},
+ }, response);
+}
+
+void App::handleAuthentication(
+ [[maybe_unused]] const Request& request,
+ Response& response)
+{
+ auto nonce = generateSecretToken(*crypto_);
+ if(!nonce)
+ {
+ respondInternalError(response);
+ return;
+ }
+ setCookie(
+ response, "card_collection_auth_form", nonce->value, 600, true);
+ response.set_header("Cache-Control", "no-store");
+ respondTemplate(templates_, "authentication.html", {
+ {"action_url", urlFor("authentication-email")},
+ {"form_nonce", nonce->value},
+ {"title", "Sign in · Card Collection"},
+ }, response);
+}
+
+void App::handleAuthenticationEmail(
+ const Request& request, Response& response)
+{
+ const auto nonce = cookieValue(request, "card_collection_auth_form");
+ if(!nonce || request.get_param_value_count("form_nonce") != 1 ||
+ !constantTimeEqual(*nonce, request.get_param_value("form_nonce")))
+ {
+ response.status = 403;
+ response.set_content("Invalid form token\n", "text/plain");
+ return;
+ }
+ if(request.get_param_value_count("email") != 1)
+ {
+ respondBadRequest(response, "Email is required");
+ return;
+ }
+ auto sent = authentication_service_->requestEmail(
+ request.get_param_value("email"));
+ if(!sent)
+ {
+ const auto* limited = sent.error().as<AuthenticationRateLimitError>();
+ if(limited != nullptr)
+ {
+ response.status = 429;
+ response.set_header(
+ "Retry-After", std::to_string(limited->retry_after));
+ response.set_content(limited->msg + "\n", "text/plain");
+ return;
+ }
+ respondOperationError(
+ response, sent.error(), "Failed to send authentication email");
+ return;
+ }
+ setCookie(response, "card_collection_auth_form", "", 0, true);
+ response.status = 303;
+ response.set_header("Location", urlFor("authentication-sent"));
+}
+
+void App::handleAuthenticationSent(
[[maybe_unused]] const Request& request,
Response& response)
{
+ response.set_header("Cache-Control", "no-store");
+ respondTemplate(templates_, "authentication_sent.html", {
+ {"title", "Check your email · Card Collection"},
+ }, response);
+}
+
+void App::handleAuthenticationConfirm(
+ const Request& request, Response& response)
+{
+ const auto parameter = request.path_params.find("token");
+ if(parameter == request.path_params.end())
+ {
+ respondBadRequest(response, "Invalid authentication link");
+ return;
+ }
+ auto challenge = authentication_service_->validate(parameter->second);
+ if(!challenge)
+ {
+ const auto* http_error = challenge.error().as<mw::HTTPError>();
+ if(http_error != nullptr && http_error->code == 400)
+ {
+ respondBadRequest(response, "Invalid authentication link");
+ }
+ else
+ {
+ spdlog::error(
+ "Failed to validate authentication link: {}",
+ challenge.error().msg());
+ respondInternalError(response);
+ }
+ return;
+ }
+ if(!*challenge)
+ {
+ respondBadRequest(response, "Invalid or expired authentication link");
+ return;
+ }
+ auto nonce = generateSecretToken(*crypto_);
+ if(!nonce)
+ {
+ respondInternalError(response);
+ return;
+ }
+ setCookie(
+ response, "card_collection_confirm_form", nonce->value, 600, true);
+ response.set_header("Cache-Control", "no-store");
+ response.set_header("Referrer-Policy", "no-referrer");
+ response.set_header(
+ "Content-Security-Policy", "default-src 'none'; style-src 'self'");
+ respondTemplate(templates_, "authentication_confirm.html", {
+ {"action_url",
+ urlFor("authentication-confirm", {parameter->second})},
+ {"email", (**challenge).email},
+ {"form_nonce", nonce->value},
+ {"title", "Confirm sign in · Card Collection"},
+ }, response);
+}
+
+void App::handleAuthenticationConfirmPost(
+ const Request& request, Response& response)
+{
+ const auto parameter = request.path_params.find("token");
+ const auto nonce = cookieValue(request, "card_collection_confirm_form");
+ if(parameter == request.path_params.end() || !nonce ||
+ request.get_param_value_count("form_nonce") != 1 ||
+ !constantTimeEqual(*nonce, request.get_param_value("form_nonce")))
+ {
+ response.status = 403;
+ response.set_content("Invalid confirmation token\n", "text/plain");
+ return;
+ }
+ auto current = cookieValue(request, "card_collection_session");
+ auto established = authentication_service_->confirm(
+ parameter->second, current);
+ if(!established)
+ {
+ respondOperationError(
+ response, established.error(), "Failed to confirm authentication");
+ return;
+ }
+ setCookie(
+ response,
+ "card_collection_session",
+ established->token,
+ 28 * 24 * 60 * 60,
+ false);
+ setCookie(response, "card_collection_confirm_form", "", 0, true);
+ response.status = 303;
+ response.set_header(
+ "Location",
+ established->user.username
+ ? urlFor("collection")
+ : urlFor("onboarding"));
+}
+
+void App::handleLogout(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, false, true);
+ if(!identity || !verifyCsrf(request, *identity, response))
+ {
+ return;
+ }
+ auto logged_out = authentication_service_->logout(identity->raw_token);
+ if(!logged_out)
+ {
+ respondInternalError(response);
+ return;
+ }
+ setCookie(response, "card_collection_session", "", 0, false);
+ response.status = 303;
+ response.set_header("Location", urlFor("welcome"));
+}
+
+void App::handleOnboarding(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, true, true);
+ if(!identity)
+ {
+ return;
+ }
+ if(identity->session.user.username)
+ {
+ response.status = 303;
+ response.set_header("Location", urlFor("collection"));
+ return;
+ }
+ respondTemplate(templates_, "onboarding_username.html", {
+ {"action_url", urlFor("onboarding")},
+ {"csrf_token", identity->session.csrf_token},
+ {"heading", "Choose a username"},
+ {"submit_label", "Continue"},
+ {"title", "Choose username · Card Collection"},
+ {"username", ""},
+ }, response);
+}
+
+void App::handleOnboardingPost(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, false, true);
+ if(!identity || !verifyCsrf(request, *identity, response))
+ {
+ return;
+ }
+ if(identity->session.user.username ||
+ request.get_param_value_count("username") != 1)
+ {
+ respondBadRequest(response, "Username is required");
+ return;
+ }
+ auto updated = user_service_->setUsername(
+ identity->session.user.id, request.get_param_value("username"));
+ if(!updated)
+ {
+ respondOperationError(response, updated.error(), "Failed username");
+ return;
+ }
+ response.status = 303;
+ response.set_header("Location", urlFor("collection"));
+}
+
+void App::handleAccount(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, true);
+ if(!identity)
+ {
+ return;
+ }
+ respondTemplate(templates_, "account.html", {
+ {"csrf_token", identity->session.csrf_token},
+ {"email", identity->session.user.email},
+ {"logout_url", urlFor("logout")},
+ {"title", "Account · Card Collection"},
+ {"username", *identity->session.user.username},
+ {"username_url", urlFor("account-username")},
+ }, response);
+}
+
+void App::handleAccountUsername(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, true);
+ if(!identity)
+ {
+ return;
+ }
+ respondTemplate(templates_, "onboarding_username.html", {
+ {"action_url", urlFor("account-username")},
+ {"csrf_token", identity->session.csrf_token},
+ {"heading", "Change username"},
+ {"submit_label", "Save"},
+ {"title", "Change username · Card Collection"},
+ {"username", *identity->session.user.username},
+ }, response);
+}
+
+void App::handleAccountUsernamePost(
+ const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, false);
+ if(!identity || !verifyCsrf(request, *identity, response))
+ {
+ return;
+ }
+ if(request.get_param_value_count("username") != 1)
+ {
+ respondBadRequest(response, "Username is required");
+ return;
+ }
+ auto updated = user_service_->setUsername(
+ identity->session.user.id, request.get_param_value("username"));
+ if(!updated)
+ {
+ respondOperationError(response, updated.error(), "Failed username");
+ return;
+ }
+ response.status = 303;
+ response.set_header("Location", urlFor("account"));
+}
+
+void App::handleCollection(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, true);
+ if(!identity)
+ {
+ return;
+ }
+ auto user = collection_service_->refresh(identity->session.user.id);
+ auto collection = data_source_->getCollection(identity->session.user.id);
+ auto pool = data_source_->getPoolCards();
+ if(!user || !collection || !pool)
+ {
+ respondInternalError(response);
+ return;
+ }
+ inja::json entries = inja::json::array();
+ for(const CollectionEntry& entry : *collection)
+ {
+ auto public_id_result = formatPublicId(entry.card.identity);
+ if(!public_id_result)
+ {
+ respondInternalError(response);
+ return;
+ }
+ const std::string& public_id = *public_id_result;
+ const std::string thumbnail_name =
+ "thumb." + entry.card.thumbnail_extension;
+ const std::filesystem::path thumbnail_path =
+ config_.card_storage_root / "published" / public_id /
+ thumbnail_name;
+ std::error_code image_error;
+ const bool thumbnail_exists = std::filesystem::is_regular_file(
+ thumbnail_path, image_error);
+ const std::string thumbnail_url = thumbnail_exists
+ ? urlFor(
+ "card-asset",
+ {public_id + '/' + thumbnail_name},
+ {{"v", std::to_string(entry.card.revision)}})
+ : urlFor("static", {"card_placeholder.svg"});
+ entries.push_back({
+ {"name", entry.card.name},
+ {"quantity", entry.quantity},
+ {"thumbnail_url", thumbnail_url},
+ {"url", urlFor("card", {public_id})},
+ });
+ }
+ const bool pull_disabled = pool->empty() || user->stored_pulls == 0;
+ respondTemplate(templates_, "collection.html", {
+ {"available_pulls", user->stored_pulls},
+ {"csrf_token", identity->session.csrf_token},
+ {"entries", std::move(entries)},
+ {"pool_empty", pool->empty()},
+ {"pull_disabled", pull_disabled},
+ {"pull_url", urlFor("collection-pull")},
+ {"title", "Collection · Card Collection"},
+ }, response);
+}
+
+void App::handleCollectionPull(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, false);
+ if(!identity || !verifyCsrf(request, *identity, response))
+ {
+ return;
+ }
+ auto pulled = collection_service_->pull(identity->session.user.id);
+ if(!pulled)
+ {
+ respondOperationError(response, pulled.error(), "Failed card pull");
+ return;
+ }
+ response.status = 303;
+ auto public_id = formatPublicId(pulled->card.identity);
+ if(!public_id)
+ {
+ respondInternalError(response);
+ return;
+ }
+ response.set_header(
+ "Location", urlFor("card", {*public_id}));
+}
+
+void App::handleAdminUsers(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, true);
+ if(!identity)
+ {
+ return;
+ }
+ AuthorizationService authorization;
+ if(!authorization.canAdminister(identity->session.user))
+ {
+ response.status = 403;
+ return;
+ }
+ auto users = data_source_->getUsers();
+ if(!users)
+ {
+ respondInternalError(response);
+ return;
+ }
+ inja::json template_users = inja::json::array();
+ for(const User& user : *users)
+ {
+ template_users.push_back({
+ {"can_promote", user.role == UserRole::PLAYER},
+ {"email", user.email},
+ {"promote_url", urlFor(
+ "admin-promote", {std::to_string(user.id)})},
+ {"role", roleName(user.role)},
+ {"username", user.username.value_or("Onboarding")},
+ });
+ }
+ respondTemplate(templates_, "user_admin.html", {
+ {"csrf_token", identity->session.csrf_token},
+ {"title", "Users · Card Collection"},
+ {"users", std::move(template_users)},
+ }, response);
+}
+
+void App::handleAdminPromote(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, false);
+ if(!identity || !verifyCsrf(request, *identity, response))
+ {
+ return;
+ }
+ const auto parameter = request.path_params.find("id");
+ const auto target = parameter == request.path_params.end()
+ ? std::nullopt
+ : parsePositiveId(parameter->second);
+ if(!target)
+ {
+ respondNotFound(response);
+ return;
+ }
+ auto promoted = user_service_->promote(
+ identity->session.user.id, *target);
+ if(!promoted)
+ {
+ respondOperationError(response, promoted.error(), "Failed promotion");
+ return;
+ }
+ response.status = 303;
+ response.set_header("Location", urlFor("admin-users"));
+}
+
+void App::handleCardNew(
+ const Request& request,
+ Response& response)
+{
+ auto identity = requireIdentity(request, response, true);
+ AuthorizationService authorization;
+ if(!identity)
+ {
+ return;
+ }
+ if(!authorization.canCreateCard(identity->session.user))
+ {
+ response.status = 403;
+ return;
+ }
inja::json games = inja::json::array();
for(const GameDefinition* game : games_->games())
{
@@ -505,7 +1239,11 @@ void App::handleCardNew(
}
const inja::json template_data = {
{"action_url", urlFor("cards")},
- {"back_url", urlFor("card-index")},
+ {"back_url",
+ urlFor(identity->session.user.role == UserRole::ADMINISTRATOR
+ ? "card-index"
+ : "creator-cards")},
+ {"csrf_token", identity->session.csrf_token},
{"display_id", "New addition"},
{"foil_action", "keep"},
{"foil_url", ""},
@@ -526,6 +1264,8 @@ void App::handleCardNew(
{"spectral_lut_url",
urlFor("static", {"foil/spectral_xyz.bin"})},
{"rarity", 0},
+ {"show_rarity",
+ identity->session.user.role == UserRole::ADMINISTRATOR},
{"revision", 0},
{"selected_game", ""},
{"series", std::move(series)},
@@ -555,6 +1295,11 @@ void App::handleCardEdit(
const Request& request,
Response& response)
{
+ auto actor = requireIdentity(request, response, true);
+ if(!actor)
+ {
+ return;
+ }
const auto id_parameter = request.path_params.find("id");
if(id_parameter == request.path_params.end())
{
@@ -583,6 +1328,12 @@ void App::handleCardEdit(
return;
}
const Card& card = **card_result;
+ AuthorizationService authorization;
+ if(!authorization.canEditCard(actor->session.user, card))
+ {
+ respondNotFound(response);
+ return;
+ }
auto public_id_result = formatPublicId(card.identity);
if(!public_id_result)
{
@@ -675,8 +1426,9 @@ void App::handleCardEdit(
}
const inja::json template_data = {
- {"action_url", urlFor("card", {public_id})},
+ {"action_url", urlFor("card-edit", {public_id})},
{"back_url", urlFor("card", {public_id})},
+ {"csrf_token", actor->session.csrf_token},
{"display_id", uppercaseAscii(public_id)},
{"foil_action", "keep"},
{"foil_url", foil_url},
@@ -691,6 +1443,8 @@ void App::handleCardEdit(
{"preview_script_url",
urlFor("static", {"foil/card_preview.js"})},
{"rarity", card.rarity},
+ {"show_rarity",
+ actor->session.user.role == UserRole::ADMINISTRATOR},
{"revision", card.revision},
{"selected_game", card.identity.game_short_name.value_or("")},
{"series", std::move(series)},
@@ -728,6 +1482,17 @@ void App::handleCardCreate(
Response& response,
const ContentReader& content_reader)
{
+ auto actor = requireIdentity(request, response, false);
+ AuthorizationService authorization;
+ if(!actor)
+ {
+ return;
+ }
+ if(!authorization.canCreateCard(actor->session.user))
+ {
+ response.status = 403;
+ return;
+ }
if(!request.is_multipart_form_data())
{
respondBadRequest(response, "Expected a multipart form upload");
@@ -742,6 +1507,14 @@ void App::handleCardCreate(
response, upload.error(), "Failed to receive a card upload");
return;
}
+ const auto csrf = upload->fields.find("csrf_token");
+ if(csrf == upload->fields.end() ||
+ !constantTimeEqual(csrf->second, actor->session.csrf_token))
+ {
+ response.status = 403;
+ response.set_content("Invalid CSRF token\n", "text/plain");
+ return;
+ }
const auto game = upload->fields.find("game");
const std::string game_short_name = game == upload->fields.end()
@@ -775,7 +1548,12 @@ void App::handleCardCreate(
respondBadRequest(response, "Front artwork is required");
return;
}
- auto rarity = parseRarity(upload->fields);
+ const bool rarity_was_submitted = upload->fields.contains("rarity");
+ mw::E<std::int64_t> rarity = std::int64_t{0};
+ if(rarity_was_submitted)
+ {
+ rarity = parseRarity(upload->fields);
+ }
if(!rarity)
{
respondBadRequest(response, rarity.error().msg());
@@ -791,6 +1569,7 @@ void App::handleCardCreate(
*upload->front,
upload->foil,
upload->thumbnail,
+ rarity_was_submitted,
};
mw::E<std::string> created = std::unexpected(
mw::runtimeError("Card creation was not dispatched"));
@@ -802,7 +1581,8 @@ void App::handleCardCreate(
response, "Loose cards cannot belong to a series");
return;
}
- created = card_service_->createLooseCard(std::move(input));
+ created = card_service_->createLooseCard(
+ actor->session.user.id, std::move(input));
}
else
{
@@ -833,6 +1613,7 @@ void App::handleCardCreate(
return;
}
created = card_service_->createGameCard(
+ actor->session.user.id,
std::move(input),
*definition,
**metadata,
@@ -854,6 +1635,11 @@ void App::handleCardUpdate(
Response& response,
const ContentReader& content_reader)
{
+ auto actor = requireIdentity(request, response, false);
+ if(!actor)
+ {
+ return;
+ }
const auto id_parameter = request.path_params.find("id");
if(id_parameter == request.path_params.end())
{
@@ -881,6 +1667,12 @@ void App::handleCardUpdate(
respondNotFound(response);
return;
}
+ AuthorizationService authorization;
+ if(!authorization.canEditCard(actor->session.user, **card_result))
+ {
+ respondNotFound(response);
+ return;
+ }
if(!request.is_multipart_form_data())
{
respondBadRequest(response, "Expected a multipart form upload");
@@ -895,6 +1687,14 @@ void App::handleCardUpdate(
response, upload.error(), "Failed to receive a card edit");
return;
}
+ const auto csrf = upload->fields.find("csrf_token");
+ if(csrf == upload->fields.end() ||
+ !constantTimeEqual(csrf->second, actor->session.csrf_token))
+ {
+ response.status = 403;
+ response.set_content("Invalid CSRF token\n", "text/plain");
+ return;
+ }
if(upload->fields.contains("game"))
{
respondBadRequest(response, "Card identity cannot be edited");
@@ -922,7 +1722,12 @@ void App::handleCardUpdate(
respondBadRequest(response, "Card name is too long");
return;
}
- auto rarity = parseRarity(upload->fields);
+ const bool rarity_was_submitted = upload->fields.contains("rarity");
+ mw::E<std::int64_t> rarity = (**card_result).rarity;
+ if(rarity_was_submitted)
+ {
+ rarity = parseRarity(upload->fields);
+ }
if(!rarity)
{
respondBadRequest(response, rarity.error().msg());
@@ -994,6 +1799,7 @@ void App::handleCardUpdate(
upload->front,
upload->foil,
upload->thumbnail,
+ rarity_was_submitted,
};
mw::E<std::string> updated = std::unexpected(
mw::runtimeError("Card update was not dispatched"));
@@ -1005,7 +1811,8 @@ void App::handleCardUpdate(
response, "Loose cards cannot belong to a series");
return;
}
- updated = card_service_->updateLooseCard(std::move(input));
+ updated = card_service_->updateLooseCard(
+ actor->session.user.id, std::move(input));
}
else
{
@@ -1038,6 +1845,7 @@ void App::handleCardUpdate(
return;
}
updated = card_service_->updateGameCard(
+ actor->session.user.id,
std::move(input),
*definition,
**metadata,
@@ -1058,6 +1866,11 @@ void App::handleCardView(
const Request& request,
Response& response)
{
+ auto actor = requireIdentity(request, response, true);
+ if(!actor)
+ {
+ return;
+ }
const auto id_parameter = request.path_params.find("id");
if(id_parameter == request.path_params.end())
{
@@ -1087,6 +1900,38 @@ void App::handleCardView(
return;
}
const Card& card = **card_result;
+ auto owns = data_source_->userOwnsCard(actor->session.user.id, card.id);
+ AuthorizationService authorization;
+ if(!owns)
+ {
+ respondInternalError(response);
+ return;
+ }
+ if(!authorization.canViewCard(actor->session.user, card, *owns))
+ {
+ respondNotFound(response);
+ return;
+ }
+
+ auto pool_cards = data_source_->getPoolCards();
+ if(!pool_cards)
+ {
+ respondInternalError(response);
+ return;
+ }
+ CardPoolService pool_service;
+ const auto pool = pool_service.calculate(*pool_cards);
+ std::string probability = "Not currently in the pull pool";
+ const auto pool_entry = std::ranges::find_if(
+ pool,
+ [&card](const CardPoolEntry& entry)
+ {
+ return entry.card.id == card.id;
+ });
+ if(pool_entry != pool.end())
+ {
+ probability = formatProbability(pool_entry->probability);
+ }
auto public_id_result = formatPublicId(card.identity);
if(!public_id_result)
@@ -1257,6 +2102,7 @@ void App::handleCardView(
{"name", card.name},
{"preview_script_url",
urlFor("static", {"foil/card_preview.js"})},
+ {"probability", probability},
{"rarity", card.rarity},
{"series", std::move(series)},
{"shader_fragment_url",
@@ -1294,6 +2140,17 @@ void App::handleCardDeleteConfirm(
const Request& request,
Response& response)
{
+ auto actor = requireIdentity(request, response, true);
+ AuthorizationService authorization;
+ if(!actor)
+ {
+ return;
+ }
+ if(!authorization.canDeleteCard(actor->session.user))
+ {
+ response.status = 403;
+ return;
+ }
const auto parameter = request.path_params.find("id");
if(parameter == request.path_params.end())
{
@@ -1325,6 +2182,7 @@ void App::handleCardDeleteConfirm(
const inja::json template_data = {
{"action_url", urlFor("card-delete", {parameter->second})},
{"back_url", urlFor("card", {parameter->second})},
+ {"csrf_token", actor->session.csrf_token},
{"name", (**card).name},
{"title", "Delete card · Card Collection"},
};
@@ -1346,6 +2204,11 @@ void App::handleCardDelete(
const Request& request,
Response& response)
{
+ auto actor = requireIdentity(request, response, false);
+ if(!actor || !verifyCsrf(request, *actor, response))
+ {
+ return;
+ }
const auto parameter = request.path_params.find("id");
if(parameter == request.path_params.end())
{
@@ -1374,7 +2237,8 @@ void App::handleCardDelete(
}
return;
}
- auto deleted = card_service_->deleteCard(**card);
+ auto deleted = card_service_->deleteCard(
+ actor->session.user.id, **card);
if(!deleted)
{
respondOperationError(
@@ -1410,7 +2274,24 @@ void App::handleCardIndex(
const Request& request,
Response& response)
{
- auto cards_result = data_source_->getCards();
+ auto actor = requireIdentity(request, response, true);
+ AuthorizationService authorization;
+ if(!actor)
+ {
+ return;
+ }
+ const bool creator_route = request.path == getPath("creator-cards");
+ if((creator_route &&
+ !authorization.canCreateCard(actor->session.user)) ||
+ (!creator_route &&
+ !authorization.canAdminister(actor->session.user)))
+ {
+ response.status = 403;
+ return;
+ }
+ auto cards_result = creator_route
+ ? data_source_->getCardsByCreator(actor->session.user.id)
+ : data_source_->getCards();
if(!cards_result)
{
spdlog::error(
@@ -1485,14 +2366,21 @@ void App::handleCardIndex(
});
}
+ const std::string index_route = creator_route
+ ? "creator-cards"
+ : "card-index";
const inja::json template_data = {
{"ascending_url",
- urlFor("card-index", {}, {{"sort", "id"}, {"direction", "asc"}})},
+ urlFor(index_route, {}, {{"sort", "id"}, {"direction", "asc"}})},
+ {"administrator", !creator_route},
{"cards", std::move(template_cards)},
+ {"create_url", urlFor("card-new")},
{"descending", descending},
{"descending_url",
- urlFor("card-index", {}, {{"sort", "id"}, {"direction", "desc"}})},
+ urlFor(index_route, {}, {{"sort", "id"}, {"direction", "desc"}})},
+ {"series_url", urlFor("series-index")},
{"title", "Card Collection"},
+ {"users_url", urlFor("admin-users")},
};
try
@@ -1510,9 +2398,20 @@ void App::handleCardIndex(
}
void App::handleSeriesIndex(
- [[maybe_unused]] const Request& request,
+ const Request& request,
Response& response)
{
+ auto actor = requireIdentity(request, response, true);
+ AuthorizationService authorization;
+ if(!actor)
+ {
+ return;
+ }
+ if(!authorization.canAdminister(actor->session.user))
+ {
+ response.status = 403;
+ return;
+ }
auto series_result = data_source_->getSeries();
if(!series_result)
{
@@ -1581,9 +2480,20 @@ void App::handleSeriesIndex(
}
void App::handleSeriesNew(
- [[maybe_unused]] const Request& request,
+ const Request& request,
Response& response)
{
+ auto actor = requireIdentity(request, response, true);
+ AuthorizationService authorization;
+ if(!actor)
+ {
+ return;
+ }
+ if(!authorization.canAdminister(actor->session.user))
+ {
+ response.status = 403;
+ return;
+ }
inja::json games = inja::json::array();
for(const GameDefinition* game : games_->games())
{
@@ -1595,6 +2505,7 @@ void App::handleSeriesNew(
const inja::json template_data = {
{"action_url", urlFor("series")},
{"back_url", urlFor("series-index")},
+ {"csrf_token", actor->session.csrf_token},
{"description", ""},
{"game", ""},
{"games", std::move(games)},
@@ -1622,12 +2533,18 @@ void App::handleSeriesCreate(
const Request& request,
Response& response)
{
+ auto actor = requireIdentity(request, response, false);
+ if(!actor || !verifyCsrf(request, *actor, response))
+ {
+ return;
+ }
if(!request.has_param("game") || !request.has_param("name"))
{
respondBadRequest(response, "Game and series name are required");
return;
}
auto created = series_service_->create(
+ actor->session.user.id,
request.get_param_value("game"),
request.get_param_value("name"),
request.has_param("description")
@@ -1647,6 +2564,17 @@ void App::handleSeriesEdit(
const Request& request,
Response& response)
{
+ auto actor = requireIdentity(request, response, true);
+ AuthorizationService authorization;
+ if(!actor)
+ {
+ return;
+ }
+ if(!authorization.canAdminister(actor->session.user))
+ {
+ response.status = 403;
+ return;
+ }
const auto parameter = request.path_params.find("id");
const auto id = parameter == request.path_params.end()
? std::nullopt
@@ -1676,6 +2604,7 @@ void App::handleSeriesEdit(
const inja::json template_data = {
{"action_url", urlFor("series-item", {std::to_string(*id)})},
{"back_url", urlFor("series-index")},
+ {"csrf_token", actor->session.csrf_token},
{"description", series.description},
{"game", game == nullptr
? uppercaseAscii(series.game_short_name)
@@ -1705,6 +2634,11 @@ void App::handleSeriesUpdate(
const Request& request,
Response& response)
{
+ auto actor = requireIdentity(request, response, false);
+ if(!actor || !verifyCsrf(request, *actor, response))
+ {
+ return;
+ }
const auto parameter = request.path_params.find("id");
const auto id = parameter == request.path_params.end()
? std::nullopt
@@ -1715,6 +2649,7 @@ void App::handleSeriesUpdate(
return;
}
auto updated = series_service_->update(
+ actor->session.user.id,
*id,
request.get_param_value("name"),
request.has_param("description")
@@ -1734,6 +2669,17 @@ void App::handleSeriesDeleteConfirm(
const Request& request,
Response& response)
{
+ auto actor = requireIdentity(request, response, true);
+ AuthorizationService authorization;
+ if(!actor)
+ {
+ return;
+ }
+ if(!authorization.canAdminister(actor->session.user))
+ {
+ response.status = 403;
+ return;
+ }
const auto parameter = request.path_params.find("id");
const auto id = parameter == request.path_params.end()
? std::nullopt
@@ -1763,6 +2709,7 @@ void App::handleSeriesDeleteConfirm(
const inja::json template_data = {
{"action_url", urlFor("series-delete", {std::to_string(*id)})},
{"back_url", urlFor("series-index")},
+ {"csrf_token", actor->session.csrf_token},
{"name", (**series_result).name},
{"title", "Delete series · Card Collection"},
};
@@ -1785,6 +2732,11 @@ void App::handleSeriesDelete(
const Request& request,
Response& response)
{
+ auto actor = requireIdentity(request, response, false);
+ if(!actor || !verifyCsrf(request, *actor, response))
+ {
+ return;
+ }
const auto parameter = request.path_params.find("id");
const auto id = parameter == request.path_params.end()
? std::nullopt
@@ -1794,7 +2746,7 @@ void App::handleSeriesDelete(
respondBadRequest(response, "A valid series ID is required");
return;
}
- auto removed = series_service_->remove(*id);
+ auto removed = series_service_->remove(actor->session.user.id, *id);
if(!removed)
{
respondOperationError(
@@ -1807,6 +2759,25 @@ void App::handleSeriesDelete(
void App::setup()
{
+ server.set_post_routing_handler(
+ []([[maybe_unused]] const httplib::Request& request,
+ httplib::Response& response)
+ {
+ if(!response.has_header("Content-Security-Policy"))
+ {
+ response.set_header(
+ "Content-Security-Policy",
+ "default-src 'self'; img-src 'self' data:; "
+ "script-src 'self' 'unsafe-inline'; style-src 'self'; "
+ "object-src 'none'; base-uri 'self'; "
+ "frame-ancestors 'none'");
+ }
+ if(!response.has_header("Referrer-Policy"))
+ {
+ response.set_header("Referrer-Policy", "same-origin");
+ }
+ response.set_header("X-Content-Type-Options", "nosniff");
+ });
const std::filesystem::path published_cards =
config_.card_storage_root / "published";
if(!server.set_mount_point(
@@ -1820,9 +2791,60 @@ void App::setup()
spdlog::error("Failed to mount published card assets");
}
+ server.Get(
+ getPath("welcome"),
+ std::bind_front(&App::handleWelcome, this));
+ server.Get(
+ getPath("authentication"),
+ std::bind_front(&App::handleAuthentication, this));
+ server.Post(
+ getPath("authentication-email"),
+ std::bind_front(&App::handleAuthenticationEmail, this));
+ server.Get(
+ getPath("authentication-sent"),
+ std::bind_front(&App::handleAuthenticationSent, this));
+ server.Get(
+ getPath("authentication-confirm", {"token"}),
+ std::bind_front(&App::handleAuthenticationConfirm, this));
+ server.Post(
+ getPath("authentication-confirm", {"token"}),
+ std::bind_front(&App::handleAuthenticationConfirmPost, this));
+ server.Post(
+ getPath("logout"),
+ std::bind_front(&App::handleLogout, this));
+ server.Get(
+ getPath("onboarding"),
+ std::bind_front(&App::handleOnboarding, this));
+ server.Post(
+ getPath("onboarding"),
+ std::bind_front(&App::handleOnboardingPost, this));
+ server.Get(
+ getPath("account"),
+ std::bind_front(&App::handleAccount, this));
+ server.Get(
+ getPath("account-username"),
+ std::bind_front(&App::handleAccountUsername, this));
+ server.Post(
+ getPath("account-username"),
+ std::bind_front(&App::handleAccountUsernamePost, this));
+ server.Get(
+ getPath("collection"),
+ std::bind_front(&App::handleCollection, this));
+ server.Post(
+ getPath("collection-pull"),
+ std::bind_front(&App::handleCollectionPull, this));
+ server.Get(
+ getPath("admin-users"),
+ std::bind_front(&App::handleAdminUsers, this));
+ server.Post(
+ getPath("admin-promote", {"id"}),
+ std::bind_front(&App::handleAdminPromote, this));
server.Get(
getPath("card-index"),
std::bind_front(&App::handleCardIndex, this));
+ server.Get(
+ getPath("creator-cards"),
+ std::bind_front(&App::handleCardIndex, this));
server.Get(
getPath("card-new"),
std::bind_front(&App::handleCardNew, this));
@@ -1836,7 +2858,7 @@ void App::setup()
getPath("cards"),
std::bind_front(&App::handleCardCreate, this));
server.Post(
- getPath("card", {"id"}),
+ getPath("card-edit", {"id"}),
std::bind_front(&App::handleCardUpdate, this));
server.Get(
getPath("card-delete", {"id"}),
diff --git a/src/app.h b/src/app.h
index 2104776..b5fdccf 100644
--- a/src/app.h
+++ b/src/app.h
@@ -1,19 +1,26 @@
#pragma once
#include <memory>
+#include <optional>
#include <string>
#include <vector>
#include <inja/inja.hpp>
+#include <mw/crypto.hpp>
#include <mw/http_server.hpp>
+#include "authentication.h"
#include "card_service.h"
+#include "clock.h"
+#include "collection.h"
#include "config.h"
#include "data.h"
+#include "email_sender.h"
#include "game_registry.h"
#include "non_secret_random.h"
#include "series_service.h"
#include "url_builder.h"
+#include "user_service.h"
/// Card Collection HTTP application and named-route owner.
class App : public mw::HTTPServer
@@ -47,6 +54,59 @@ public:
/// Render the card index.
void handleCardIndex(const Request& request, Response& response);
+ /// Redirect an authenticated visitor or render the public welcome page.
+ void handleWelcome(const Request& request, Response& response);
+
+ /// Render the passwordless email request form.
+ void handleAuthentication(const Request& request, Response& response);
+
+ /// Reserve and send a passwordless authentication link.
+ void handleAuthenticationEmail(
+ const Request& request, Response& response);
+
+ /// Render the neutral authentication-email acknowledgement.
+ void handleAuthenticationSent(
+ const Request& request, Response& response);
+
+ /// Render a non-consuming authentication confirmation form.
+ void handleAuthenticationConfirm(
+ const Request& request, Response& response);
+
+ /// Consume an authentication challenge and establish a session.
+ void handleAuthenticationConfirmPost(
+ const Request& request, Response& response);
+
+ /// Revoke the current session.
+ void handleLogout(const Request& request, Response& response);
+
+ /// Render the required initial-username form.
+ void handleOnboarding(const Request& request, Response& response);
+
+ /// Persist the required initial username.
+ void handleOnboardingPost(const Request& request, Response& response);
+
+ /// Render the current account summary.
+ void handleAccount(const Request& request, Response& response);
+
+ /// Render the username-change form.
+ void handleAccountUsername(const Request& request, Response& response);
+
+ /// Persist a username change.
+ void handleAccountUsernamePost(
+ const Request& request, Response& response);
+
+ /// Render the current user's card collection and pull balance.
+ void handleCollection(const Request& request, Response& response);
+
+ /// Perform one atomic collection pull.
+ void handleCollectionPull(const Request& request, Response& response);
+
+ /// Render the administrator's user table.
+ void handleAdminUsers(const Request& request, Response& response);
+
+ /// Permanently promote one player to creator.
+ void handleAdminPromote(const Request& request, Response& response);
+
/// Render the create-card form.
void handleCardNew(const Request& request, Response& response);
@@ -100,6 +160,12 @@ public:
void handleSeriesDelete(const Request& request, Response& response);
private:
+ struct RequestIdentity
+ {
+ SessionContext session;
+ std::string raw_token;
+ };
+
/// Register implemented handlers and static mounts.
void setup() override;
@@ -111,10 +177,37 @@ private:
/// Return the request prefix for a named static mount.
std::string getMountPath(const std::string& name) const;
+ /// Load and authorize one browser session, writing failures to response.
+ std::optional<RequestIdentity> requireIdentity(
+ const Request& request,
+ Response& response,
+ bool safe_get,
+ bool allow_onboarding = false);
+
+ /// Verify one authenticated mutation's synchronizer token.
+ bool verifyCsrf(
+ const Request& request,
+ const RequestIdentity& identity,
+ Response& response) const;
+
+ /// Set or expire a host-only application cookie.
+ void setCookie(
+ Response& response,
+ const std::string& name,
+ const std::string& value,
+ std::int64_t maximum_age,
+ bool strict_same_site) const;
+
Config config_;
std::unique_ptr<DataSourceInterface> data_source_;
std::unique_ptr<GameRegistry> games_;
std::unique_ptr<NonSecretRandom> random_;
+ std::unique_ptr<ClockInterface> clock_;
+ std::unique_ptr<mw::CryptoInterface> crypto_;
+ std::unique_ptr<EmailSenderInterface> email_sender_;
+ std::unique_ptr<AuthenticationService> authentication_service_;
+ std::unique_ptr<UserService> user_service_;
+ std::unique_ptr<CollectionService> collection_service_;
std::unique_ptr<CardService> card_service_;
std::unique_ptr<SeriesService> series_service_;
UrlBuilder url_builder_;
diff --git a/src/authentication.cpp b/src/authentication.cpp
new file mode 100644
index 0000000..d93014b
--- /dev/null
+++ b/src/authentication.cpp
@@ -0,0 +1,317 @@
+#include "authentication.h"
+
+#include <chrono>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "email_address.h"
+#include "secret_token.h"
+
+namespace
+{
+
+constexpr std::int64_t CHALLENGE_LIFETIME_SECONDS = 10 * 60;
+constexpr std::int64_t SESSION_LIFETIME_SECONDS = 28 * 24 * 60 * 60;
+constexpr int TOKEN_ATTEMPTS = 3;
+
+} // namespace
+
+AuthenticationService::AuthenticationService(
+ DataSourceInterface& data_source,
+ EmailSenderInterface& email_sender,
+ ClockInterface& clock,
+ mw::CryptoInterface& crypto,
+ mw::URL base_url,
+ std::uint32_t daily_attempt_limit)
+ : data_source_(data_source),
+ email_sender_(email_sender),
+ clock_(clock),
+ crypto_(crypto),
+ base_url_(std::move(base_url)),
+ daily_attempt_limit_(daily_attempt_limit)
+{}
+
+std::int64_t AuthenticationService::nowSeconds() const
+{
+ return std::chrono::duration_cast<std::chrono::seconds>(
+ clock_.now().time_since_epoch()).count();
+}
+
+mw::URL AuthenticationService::confirmationUrl(
+ const std::string& token) const
+{
+ mw::URL url = base_url_;
+ url.appendPath("authentication/confirm");
+ url.appendPath(token);
+ return url;
+}
+
+mw::E<void> AuthenticationService::requestEmail(
+ const std::string& submitted_email)
+{
+ auto address = normalizeEmail(submitted_email);
+ if(!address)
+ {
+ return std::unexpected(mw::httpError(400, "Invalid email address"));
+ }
+ const std::int64_t now = nowSeconds();
+ const std::int64_t day = utcDay(clock_.now());
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto reservation = (*transaction)->reserveAuthenticationEmail(
+ address->key,
+ now,
+ email_sender_.usesGlobalQuota(),
+ day,
+ daily_attempt_limit_);
+ if(!reservation)
+ {
+ return std::unexpected(std::move(reservation.error()));
+ }
+ if(reservation->status != AuthenticationReservationStatus::RESERVED)
+ {
+ return std::unexpected(mw::Error(AuthenticationRateLimitError{
+ "Authentication email rate limit exceeded",
+ reservation->retry_after}));
+ }
+
+ std::optional<SecretToken> challenge_token;
+ std::optional<std::int64_t> challenge_id;
+ for(int attempt = 0; attempt < TOKEN_ATTEMPTS; ++attempt)
+ {
+ auto generated = generateSecretToken(crypto_);
+ if(!generated)
+ {
+ return std::unexpected(std::move(generated.error()));
+ }
+ auto inserted = (*transaction)->insertAuthenticationChallenge(
+ address->email,
+ address->key,
+ generated->hash,
+ now,
+ now + CHALLENGE_LIFETIME_SECONDS);
+ if(inserted)
+ {
+ challenge_token = std::move(*generated);
+ challenge_id = *inserted;
+ break;
+ }
+ }
+ if(!challenge_token || !challenge_id)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to allocate a unique authentication challenge"));
+ }
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ return std::unexpected(std::move(committed.error()));
+ }
+
+ AuthenticationEmail email = {
+ address->email,
+ confirmationUrl(challenge_token->value),
+ std::chrono::system_clock::time_point(
+ std::chrono::seconds(now + CHALLENGE_LIFETIME_SECONDS))};
+ auto sent = email_sender_.send(email);
+ transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ if(!sent)
+ {
+ auto deleted = (*transaction)->deleteAuthenticationChallenge(
+ *challenge_id);
+ if(!deleted)
+ {
+ return std::unexpected(std::move(deleted.error()));
+ }
+ auto deletion_commit = (*transaction)->commit();
+ if(!deletion_commit)
+ {
+ return std::unexpected(std::move(deletion_commit.error()));
+ }
+ return std::unexpected(mw::httpError(
+ 502, "Authentication email delivery failed"));
+ }
+ auto delivered = (*transaction)->markAuthenticationChallengeDelivered(
+ *challenge_id, nowSeconds());
+ if(!delivered)
+ {
+ return std::unexpected(std::move(delivered.error()));
+ }
+ if(!*delivered)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Authentication challenge disappeared after delivery"));
+ }
+ auto delivery_commit = (*transaction)->commit();
+ if(!delivery_commit)
+ {
+ return std::unexpected(std::move(delivery_commit.error()));
+ }
+ return {};
+}
+
+mw::E<std::optional<AuthenticationChallenge>>
+AuthenticationService::validate(const std::string& token) const
+{
+ auto hash = hashSecretToken(token);
+ if(!hash)
+ {
+ return std::unexpected(mw::httpError(400, "Invalid link"));
+ }
+ return data_source_.getAuthenticationChallenge(*hash, nowSeconds());
+}
+
+mw::E<EstablishedSession> AuthenticationService::confirm(
+ const std::string& token,
+ const std::optional<std::string>& current_session_token)
+{
+ auto challenge_hash = hashSecretToken(token);
+ if(!challenge_hash)
+ {
+ return std::unexpected(mw::httpError(400, "Invalid link"));
+ }
+ std::optional<TokenHash> current_session_hash;
+ if(current_session_token)
+ {
+ auto hash = hashSecretToken(*current_session_token);
+ if(hash)
+ {
+ current_session_hash = std::move(*hash);
+ }
+ }
+ const std::int64_t now = nowSeconds();
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto challenge = (*transaction)->consumeAuthenticationChallenge(
+ *challenge_hash, now);
+ if(!challenge)
+ {
+ return std::unexpected(std::move(challenge.error()));
+ }
+ if(!*challenge)
+ {
+ return std::unexpected(mw::httpError(400, "Invalid or expired link"));
+ }
+ auto user = (*transaction)->getUserByEmailKeyForUpdate(
+ (**challenge).email_key);
+ if(!user)
+ {
+ return std::unexpected(std::move(user.error()));
+ }
+ if(!*user)
+ {
+ User new_user = {
+ 0,
+ (**challenge).email,
+ (**challenge).email_key,
+ std::nullopt,
+ UserRole::PLAYER,
+ 1,
+ utcDay(clock_.now()),
+ now};
+ auto user_id = (*transaction)->insertUser(new_user);
+ if(!user_id)
+ {
+ return std::unexpected(std::move(user_id.error()));
+ }
+ new_user.id = *user_id;
+ user = std::optional<User>(std::move(new_user));
+ }
+ auto invalidated = (*transaction)->invalidateAuthenticationChallenges(
+ (**challenge).email_key, (**challenge).id, now);
+ if(!invalidated)
+ {
+ return std::unexpected(std::move(invalidated.error()));
+ }
+ if(current_session_hash)
+ {
+ auto deleted = (*transaction)->deleteSession(*current_session_hash);
+ if(!deleted)
+ {
+ return std::unexpected(std::move(deleted.error()));
+ }
+ }
+ const std::int64_t expires_at = now + SESSION_LIFETIME_SECONDS;
+ std::optional<SecretToken> session_token;
+ std::optional<SecretToken> csrf_token;
+ for(int attempt = 0; attempt < TOKEN_ATTEMPTS; ++attempt)
+ {
+ auto generated_session = generateSecretToken(crypto_);
+ auto generated_csrf = generateSecretToken(crypto_);
+ if(!generated_session || !generated_csrf)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to generate session credentials"));
+ }
+ auto inserted = (*transaction)->insertSession(
+ (**user).id,
+ generated_session->hash,
+ generated_csrf->value,
+ now,
+ expires_at);
+ if(inserted)
+ {
+ session_token = std::move(*generated_session);
+ csrf_token = std::move(*generated_csrf);
+ break;
+ }
+ }
+ if(!session_token || !csrf_token)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to allocate unique session credentials"));
+ }
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ return std::unexpected(std::move(committed.error()));
+ }
+ return EstablishedSession{
+ std::move(**user),
+ std::move(session_token->value),
+ std::move(csrf_token->value),
+ expires_at};
+}
+
+mw::E<std::optional<SessionContext>> AuthenticationService::session(
+ const std::string& token) const
+{
+ auto hash = hashSecretToken(token);
+ if(!hash)
+ {
+ return std::optional<SessionContext>{};
+ }
+ return data_source_.getSession(*hash, nowSeconds());
+}
+
+mw::E<void> AuthenticationService::logout(const std::string& token)
+{
+ auto hash = hashSecretToken(token);
+ if(!hash)
+ {
+ return {};
+ }
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto deleted = (*transaction)->deleteSession(*hash);
+ if(!deleted)
+ {
+ return std::unexpected(std::move(deleted.error()));
+ }
+ return (*transaction)->commit();
+}
diff --git a/src/authentication.h b/src/authentication.h
new file mode 100644
index 0000000..cf34a2a
--- /dev/null
+++ b/src/authentication.h
@@ -0,0 +1,87 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+#include <mw/crypto.hpp>
+#include <mw/error.hpp>
+#include <mw/url.hpp>
+
+#include "clock.h"
+#include "data.h"
+#include "email_sender.h"
+
+/// Authentication-email throttle result with an HTTP retry delay.
+struct AuthenticationRateLimitError
+{
+ /// Non-secret user-facing diagnostic.
+ std::string msg;
+
+ /// Whole seconds until another reservation may succeed.
+ std::int64_t retry_after;
+};
+
+/// Raw session credential returned once after successful confirmation.
+struct EstablishedSession
+{
+ /// Current authenticated user.
+ User user;
+
+ /// Raw session token placed only in the browser cookie.
+ std::string token;
+
+ /// Raw CSRF token included in authenticated mutation forms.
+ std::string csrf_token;
+
+ /// Non-sliding Unix session expiry.
+ std::int64_t expires_at;
+};
+
+/// Own passwordless challenges, delivery, sessions, and logout.
+class AuthenticationService
+{
+public:
+ /// Construct authentication over injected persistence and boundaries.
+ AuthenticationService(
+ DataSourceInterface& data_source,
+ EmailSenderInterface& email_sender,
+ ClockInterface& clock,
+ mw::CryptoInterface& crypto,
+ mw::URL base_url,
+ std::uint32_t daily_attempt_limit);
+
+ /// Reserve capacity, persist a challenge, and deliver its link.
+ mw::E<void> requestEmail(const std::string& submitted_email);
+
+ /// Read a valid delivered challenge without consuming it.
+ mw::E<std::optional<AuthenticationChallenge>> validate(
+ const std::string& token) const;
+
+ /// Consume a challenge, create or load its user, and start a session.
+ mw::E<EstablishedSession> confirm(
+ const std::string& token,
+ const std::optional<std::string>& current_session_token =
+ std::nullopt);
+
+ /// Load a valid non-sliding session from a raw cookie value.
+ mw::E<std::optional<SessionContext>> session(
+ const std::string& token) const;
+
+ /// Revoke only the presented current session.
+ mw::E<void> logout(const std::string& token);
+
+private:
+ /// Return current signed Unix seconds.
+ std::int64_t nowSeconds() const;
+
+ /// Build the absolute confirmation URL for one raw token.
+ mw::URL confirmationUrl(const std::string& token) const;
+
+ DataSourceInterface& data_source_;
+ EmailSenderInterface& email_sender_;
+ ClockInterface& clock_;
+ mw::CryptoInterface& crypto_;
+ mw::URL base_url_;
+ std::uint32_t daily_attempt_limit_;
+};
diff --git a/src/authorization.cpp b/src/authorization.cpp
new file mode 100644
index 0000000..9a61c30
--- /dev/null
+++ b/src/authorization.cpp
@@ -0,0 +1,38 @@
+#include "authorization.h"
+
+bool AuthorizationService::canViewCard(
+ const User& actor, const Card& card, bool actor_owns_card) const
+{
+ return actor_owns_card || actor.role == UserRole::ADMINISTRATOR ||
+ (actor.role == UserRole::CREATOR &&
+ card.creator_user_id == actor.id);
+}
+
+bool AuthorizationService::canCreateCard(const User& actor) const
+{
+ return actor.role == UserRole::CREATOR ||
+ actor.role == UserRole::ADMINISTRATOR;
+}
+
+bool AuthorizationService::canEditCard(
+ const User& actor, const Card& card) const
+{
+ return actor.role == UserRole::ADMINISTRATOR ||
+ (actor.role == UserRole::CREATOR &&
+ card.creator_user_id == actor.id);
+}
+
+bool AuthorizationService::canDeleteCard(const User& actor) const
+{
+ return actor.role == UserRole::ADMINISTRATOR;
+}
+
+bool AuthorizationService::canSetRarity(const User& actor) const
+{
+ return actor.role == UserRole::ADMINISTRATOR;
+}
+
+bool AuthorizationService::canAdminister(const User& actor) const
+{
+ return actor.role == UserRole::ADMINISTRATOR;
+}
diff --git a/src/authorization.h b/src/authorization.h
new file mode 100644
index 0000000..1129971
--- /dev/null
+++ b/src/authorization.h
@@ -0,0 +1,28 @@
+#pragma once
+
+#include "card.h"
+#include "user.h"
+
+/// Side-effect-free application permission policy.
+class AuthorizationService
+{
+public:
+ /// Return whether an actor may view a card through the WebGL page.
+ bool canViewCard(const User& actor, const Card& card,
+ bool actor_owns_card) const;
+
+ /// Return whether an actor may create a card.
+ bool canCreateCard(const User& actor) const;
+
+ /// Return whether an actor may edit a card.
+ bool canEditCard(const User& actor, const Card& card) const;
+
+ /// Return whether an actor may delete cards.
+ bool canDeleteCard(const User& actor) const;
+
+ /// Return whether an actor may set card rarity.
+ bool canSetRarity(const User& actor) const;
+
+ /// Return whether an actor may manage series and users.
+ bool canAdminister(const User& actor) const;
+};
diff --git a/src/card.h b/src/card.h
index a6cc3e2..5b81b44 100644
--- a/src/card.h
+++ b/src/card.h
@@ -46,6 +46,9 @@ struct Card
/// Optimistic-concurrency and asset-cache revision.
std::int64_t revision;
+
+ /// Internal user ID of the account that created this card.
+ std::int64_t creator_user_id = 0;
};
/// Logical card image used consistently by validation, storage, and URLs.
diff --git a/src/card_pool.cpp b/src/card_pool.cpp
new file mode 100644
index 0000000..f275f7a
--- /dev/null
+++ b/src/card_pool.cpp
@@ -0,0 +1,117 @@
+#include "card_pool.h"
+
+#include <algorithm>
+#include <cmath>
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+#include <iterator>
+#include <iomanip>
+#include <sstream>
+#include <string>
+#include <vector>
+
+std::vector<CardPoolEntry> CardPoolService::calculate(
+ const std::vector<Card>& cards) const
+{
+ std::vector<Card> eligible;
+ std::ranges::copy_if(cards, std::back_inserter(eligible),
+ [](const Card& card)
+ {
+ return card.rarity > 0;
+ });
+ std::ranges::sort(eligible, {}, &Card::id);
+ if(eligible.empty())
+ {
+ return {};
+ }
+ const std::int64_t minimum = std::ranges::min(
+ eligible, {}, &Card::rarity).rarity;
+ double total = 0;
+ std::vector<CardPoolEntry> result;
+ result.reserve(eligible.size());
+ for(const Card& card : eligible)
+ {
+ const double weight = std::exp2(
+ static_cast<double>(minimum) -
+ static_cast<double>(card.rarity));
+ total += weight;
+ result.push_back({card, weight, 0});
+ }
+ for(CardPoolEntry& entry : result)
+ {
+ entry.probability = entry.scaled_weight / total;
+ }
+ return result;
+}
+
+mw::E<CardPoolEntry> CardPoolService::select(
+ const std::vector<CardPoolEntry>& entries,
+ mw::CryptoInterface& crypto) const
+{
+ if(entries.empty())
+ {
+ return std::unexpected(mw::runtimeError("Card pool is empty"));
+ }
+ auto bytes = crypto.randomBytes(8);
+ if(!bytes)
+ {
+ return std::unexpected(std::move(bytes.error()));
+ }
+ std::uint64_t value = 0;
+ for(std::byte byte : *bytes)
+ {
+ value = (value << 8) | std::to_integer<std::uint64_t>(byte);
+ }
+ const std::uint64_t high_53 = value >> 11;
+ const double fraction = std::ldexp(static_cast<double>(high_53), -53);
+ double total = 0;
+ for(const CardPoolEntry& entry : entries)
+ {
+ total += entry.scaled_weight;
+ }
+ const double target = fraction * total;
+ double cumulative = 0;
+ const CardPoolEntry* last_positive = nullptr;
+ for(const CardPoolEntry& entry : entries)
+ {
+ if(entry.scaled_weight == 0)
+ {
+ continue;
+ }
+ last_positive = &entry;
+ cumulative += entry.scaled_weight;
+ if(target < cumulative)
+ {
+ return entry;
+ }
+ }
+ if(last_positive != nullptr)
+ {
+ return *last_positive;
+ }
+ return std::unexpected(mw::runtimeError(
+ "Card pool has no represented weight"));
+}
+
+std::string formatProbability(double probability)
+{
+ if(probability <= 0)
+ {
+ return "0%";
+ }
+ const double percentage = probability * 100;
+ if(percentage < 0.000001)
+ {
+ return "<0.000001%";
+ }
+ std::ostringstream output;
+ output << std::fixed << std::setprecision(6) << percentage;
+ std::string result = output.str();
+ result.erase(result.find_last_not_of('0') + 1);
+ if(result.ends_with('.'))
+ {
+ result.pop_back();
+ }
+ return result + '%';
+}
diff --git a/src/card_pool.h b/src/card_pool.h
new file mode 100644
index 0000000..c095cec
--- /dev/null
+++ b/src/card_pool.h
@@ -0,0 +1,40 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include <mw/crypto.hpp>
+#include <mw/error.hpp>
+
+#include "card.h"
+
+/// Current pool weight and normalized probability for one card.
+struct CardPoolEntry
+{
+ /// Eligible card.
+ Card card;
+
+ /// Weight scaled so the largest represented weight is one.
+ double scaled_weight;
+
+ /// Weight divided by the represented total.
+ double probability;
+};
+
+/// Calculate and sample the current positive-rarity card pool.
+class CardPoolService
+{
+public:
+ /// Build probability entries in card-ID order.
+ std::vector<CardPoolEntry> calculate(
+ const std::vector<Card>& cards) const;
+
+ /// Select one entry using eight cryptographically random bytes.
+ mw::E<CardPoolEntry> select(
+ const std::vector<CardPoolEntry>& entries,
+ mw::CryptoInterface& crypto) const;
+};
+
+/// Format a represented pool probability for a card page.
+std::string formatProbability(double probability);
diff --git a/src/card_service.cpp b/src/card_service.cpp
index 408a364..7edfe13 100644
--- a/src/card_service.cpp
+++ b/src/card_service.cpp
@@ -48,22 +48,29 @@ CardService::CardService(
{}
mw::E<std::string> CardService::createLooseCard(
- CreateLooseCardInput input)
+ std::int64_t actor_user_id, CreateLooseCardInput input)
{
- return createCard(std::move(input), nullptr, nullptr, {});
+ return createCard(
+ actor_user_id, std::move(input), nullptr, nullptr, {});
}
mw::E<std::string> CardService::createGameCard(
+ std::int64_t actor_user_id,
CreateCardInput input,
const GameDefinition& game,
const GameCardMetadata& metadata,
const std::vector<std::int64_t>& series_ids)
{
return createCard(
- std::move(input), &game, &metadata, series_ids);
+ actor_user_id,
+ std::move(input),
+ &game,
+ &metadata,
+ series_ids);
}
mw::E<std::string> CardService::createCard(
+ std::int64_t actor_user_id,
CreateCardInput input,
const GameDefinition* game,
const GameCardMetadata* metadata,
@@ -144,6 +151,25 @@ mw::E<std::string> CardService::createCard(
{
return std::unexpected(std::move(transaction.error()));
}
+ auto actor = (*transaction)->getUserForUpdate(actor_user_id);
+ if(!actor)
+ {
+ return std::unexpected(std::move(actor.error()));
+ }
+ if(!*actor || !authorization_.canCreateCard(**actor))
+ {
+ return std::unexpected(mw::httpError(403, "Forbidden"));
+ }
+ if(!(**actor).username)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "Username onboarding is required"));
+ }
+ if((**actor).role == UserRole::CREATOR && input.rarity_was_submitted)
+ {
+ return std::unexpected(mw::httpError(
+ 400, "Creators cannot submit card rarity"));
+ }
std::uint64_t number = 0;
if(game != nullptr)
@@ -190,11 +216,12 @@ mw::E<std::string> CardService::createCard(
std::move(input.name),
std::move(input.short_description),
std::move(input.long_description),
- input.rarity,
+ (**actor).role == UserRole::CREATOR ? 0 : input.rarity,
front->extension,
foil ? std::optional<std::string>(foil->extension) : std::nullopt,
thumbnail.extension,
1,
+ actor_user_id,
};
auto inserted = (*transaction)->insertCard(
card, game, metadata, series_ids);
@@ -225,28 +252,44 @@ mw::E<std::string> CardService::createCard(
}
mw::E<std::string> CardService::updateLooseCard(
- UpdateLooseCardInput input)
+ std::int64_t actor_user_id, UpdateLooseCardInput input)
{
- return updateCard(std::move(input), nullptr, nullptr, {});
+ return updateCard(
+ actor_user_id, std::move(input), nullptr, nullptr, {});
}
mw::E<std::string> CardService::updateGameCard(
+ std::int64_t actor_user_id,
UpdateLooseCardInput input,
const GameDefinition& game,
const GameCardMetadata& metadata,
const std::vector<std::int64_t>& series_ids)
{
return updateCard(
- std::move(input), &game, &metadata, series_ids);
+ actor_user_id,
+ std::move(input),
+ &game,
+ &metadata,
+ series_ids);
}
mw::E<std::string> CardService::updateCard(
+ std::int64_t actor_user_id,
UpdateLooseCardInput input,
const GameDefinition* game,
const GameCardMetadata* metadata,
const std::vector<std::int64_t>& series_ids)
{
- Card& card = input.current_card;
+ auto stored = data_source_.getCard(input.current_card.identity);
+ if(!stored)
+ {
+ return std::unexpected(std::move(stored.error()));
+ }
+ if(!*stored)
+ {
+ return std::unexpected(mw::httpError(404, "Card not found"));
+ }
+ Card card = std::move(**stored);
if(input.short_description)
{
auto rendered = markdown_renderer_.render(*input.short_description);
@@ -413,7 +456,6 @@ mw::E<std::string> CardService::updateCard(
card.name = std::move(input.name);
card.short_description = std::move(input.short_description);
card.long_description = std::move(input.long_description);
- card.rarity = input.rarity;
++card.revision;
auto transaction = data_source_.beginTransaction();
@@ -435,6 +477,31 @@ mw::E<std::string> CardService::updateCard(
return std::unexpected(mw::httpError(
409, "The card was changed in another request"));
}
+ auto actor = (*transaction)->getUserForUpdate(actor_user_id);
+ if(!actor)
+ {
+ return std::unexpected(std::move(actor.error()));
+ }
+ if(!*actor || !authorization_.canEditCard(**actor, **current))
+ {
+ return std::unexpected(mw::httpError(404, "Card not found"));
+ }
+ if(!(**actor).username)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "Username onboarding is required"));
+ }
+ if((**actor).role == UserRole::CREATOR && input.rarity_was_submitted)
+ {
+ return std::unexpected(mw::httpError(
+ 400, "Creators cannot submit card rarity"));
+ }
+ card.id = (**current).id;
+ card.identity = (**current).identity;
+ card.creator_user_id = (**current).creator_user_id;
+ card.rarity = (**actor).role == UserRole::CREATOR
+ ? (**current).rarity
+ : input.rarity;
auto updated = (*transaction)->updateCard(
card, game, metadata, series_ids);
@@ -471,13 +538,9 @@ mw::E<std::string> CardService::updateCard(
return *public_id;
}
-mw::E<void> CardService::deleteCard(const Card& card)
+mw::E<void> CardService::deleteCard(
+ std::int64_t actor_user_id, const Card& card)
{
- auto public_id = formatPublicId(card.identity);
- if(!public_id)
- {
- return std::unexpected(std::move(public_id.error()));
- }
auto transaction = data_source_.beginTransaction();
if(!transaction)
{
@@ -492,13 +555,32 @@ mw::E<void> CardService::deleteCard(const Card& card)
{
return std::unexpected(mw::httpError(404, "Card not found"));
}
+ auto actor = (*transaction)->getUserForUpdate(actor_user_id);
+ if(!actor)
+ {
+ return std::unexpected(std::move(actor.error()));
+ }
+ if(!*actor || !authorization_.canDeleteCard(**actor))
+ {
+ return std::unexpected(mw::httpError(403, "Forbidden"));
+ }
+ if(!(**actor).username)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "Username onboarding is required"));
+ }
+ auto public_id = formatPublicId((**current).identity);
+ if(!public_id)
+ {
+ return std::unexpected(std::move(public_id.error()));
+ }
auto trashed = asset_store_.trash(
*public_id, (**current).revision, random_.hex(16));
if(!trashed)
{
return std::unexpected(std::move(trashed.error()));
}
- auto deleted = (*transaction)->deleteCard(card.id);
+ auto deleted = (*transaction)->deleteCard((**current).id);
if(!deleted)
{
asset_store_.restore(*trashed);
diff --git a/src/card_service.h b/src/card_service.h
index 7ea4ea1..b7ac375 100644
--- a/src/card_service.h
+++ b/src/card_service.h
@@ -9,6 +9,7 @@
#include <mw/error.hpp>
#include "asset_store.h"
+#include "authorization.h"
#include "data.h"
#include "image_processor.h"
#include "markdown_renderer.h"
@@ -40,6 +41,9 @@ struct CreateCardInput
/// Optional browser-rendered thumbnail for a foil card.
std::optional<std::filesystem::path> thumbnail;
+
+ /// Whether the request explicitly supplied a rarity field.
+ bool rarity_was_submitted = true;
};
/// Create-card input retained as the loose-card handler's explicit name.
@@ -98,6 +102,9 @@ struct UpdateLooseCardInput
/// Browser-rendered thumbnail for a changed resulting foil card.
std::optional<std::filesystem::path> thumbnail;
+
+ /// Whether the request explicitly supplied a rarity field.
+ bool rarity_was_submitted = true;
};
/// Coordinate image processing, persistence, and asset publication.
@@ -111,32 +118,38 @@ public:
ImageProcessor image_processor,
AssetStore asset_store);
- /// Create a loose card and return its canonical public ID.
- mw::E<std::string> createLooseCard(CreateLooseCardInput input);
+ /// Create a loose card after re-reading and authorizing its actor.
+ mw::E<std::string> createLooseCard(
+ std::int64_t actor_user_id, CreateLooseCardInput input);
- /// Create a compiled-game card with validated metadata and memberships.
+ /// Create a game card after re-reading and authorizing its actor.
mw::E<std::string> createGameCard(
+ std::int64_t actor_user_id,
CreateCardInput input,
const GameDefinition& game,
const GameCardMetadata& metadata,
const std::vector<std::int64_t>& series_ids);
- /// Update a loose card and return its unchanged canonical public ID.
- mw::E<std::string> updateLooseCard(UpdateLooseCardInput input);
+ /// Update a loose card after re-reading role and authorship.
+ mw::E<std::string> updateLooseCard(
+ std::int64_t actor_user_id, UpdateLooseCardInput input);
- /// Update a compiled-game card and its validated metadata/memberships.
+ /// Update a game card after re-reading role and authorship.
mw::E<std::string> updateGameCard(
+ std::int64_t actor_user_id,
UpdateLooseCardInput input,
const GameDefinition& game,
const GameCardMetadata& metadata,
const std::vector<std::int64_t>& series_ids);
- /// Delete a card and its published assets.
- mw::E<void> deleteCard(const Card& card);
+ /// Delete a card only after re-reading an administrator actor.
+ mw::E<void> deleteCard(
+ std::int64_t actor_user_id, const Card& card);
private:
/// Create a loose or compiled-game card through the common pipeline.
mw::E<std::string> createCard(
+ std::int64_t actor_user_id,
CreateCardInput input,
const GameDefinition* game,
const GameCardMetadata* metadata,
@@ -144,6 +157,7 @@ private:
/// Update a loose or compiled-game card through the common pipeline.
mw::E<std::string> updateCard(
+ std::int64_t actor_user_id,
UpdateLooseCardInput input,
const GameDefinition* game,
const GameCardMetadata* metadata,
@@ -154,4 +168,5 @@ private:
ImageProcessor image_processor_;
AssetStore asset_store_;
MarkdownRenderer markdown_renderer_;
+ AuthorizationService authorization_;
};
diff --git a/src/clock.cpp b/src/clock.cpp
new file mode 100644
index 0000000..c0fa0e4
--- /dev/null
+++ b/src/clock.cpp
@@ -0,0 +1,14 @@
+#include "clock.h"
+
+#include <chrono>
+
+std::chrono::system_clock::time_point SystemClock::now() const
+{
+ return std::chrono::system_clock::now();
+}
+
+std::int64_t utcDay(std::chrono::system_clock::time_point time)
+{
+ return std::chrono::floor<std::chrono::days>(time)
+ .time_since_epoch().count();
+}
diff --git a/src/clock.h b/src/clock.h
new file mode 100644
index 0000000..b4ef0c6
--- /dev/null
+++ b/src/clock.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#include <chrono>
+#include <cstdint>
+
+/// Injectable source of wall-clock time.
+class ClockInterface
+{
+public:
+ virtual ~ClockInterface() = default;
+
+ /// Return the current system-clock instant.
+ virtual std::chrono::system_clock::time_point now() const = 0;
+};
+
+/// Production system wall clock.
+class SystemClock final : public ClockInterface
+{
+public:
+ /// Return the current system-clock instant.
+ std::chrono::system_clock::time_point now() const override;
+};
+
+/// Return the signed UTC epoch day containing an instant.
+std::int64_t utcDay(std::chrono::system_clock::time_point time);
diff --git a/src/collection.cpp b/src/collection.cpp
new file mode 100644
index 0000000..997f4da
--- /dev/null
+++ b/src/collection.cpp
@@ -0,0 +1,153 @@
+#include "collection.h"
+
+#include <algorithm>
+#include <chrono>
+#include <cstdint>
+#include <limits>
+#include <utility>
+
+CollectionService::CollectionService(
+ DataSourceInterface& data_source,
+ ClockInterface& clock,
+ mw::CryptoInterface& crypto,
+ std::uint32_t maximum_accumulated_pulls)
+ : data_source_(data_source),
+ clock_(clock),
+ crypto_(crypto),
+ maximum_accumulated_pulls_(maximum_accumulated_pulls)
+{}
+
+std::pair<std::uint32_t, std::int64_t>
+CollectionService::effectivePullState(
+ const User& user, std::int64_t current_day) const
+{
+ const std::int64_t elapsed = std::max<std::int64_t>(
+ 0, current_day - user.pull_refresh_day);
+ const std::uint64_t available = std::min<std::uint64_t>(
+ maximum_accumulated_pulls_,
+ static_cast<std::uint64_t>(user.stored_pulls) +
+ static_cast<std::uint64_t>(elapsed));
+ return {
+ static_cast<std::uint32_t>(available),
+ std::max(current_day, user.pull_refresh_day)};
+}
+
+mw::E<User> CollectionService::refresh(std::int64_t user_id)
+{
+ const std::int64_t current_day = utcDay(clock_.now());
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto user = (*transaction)->getUserForUpdate(user_id);
+ if(!user)
+ {
+ return std::unexpected(std::move(user.error()));
+ }
+ if(!*user)
+ {
+ return std::unexpected(mw::httpError(404, "User not found"));
+ }
+ if(!(**user).username)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "Username onboarding is required"));
+ }
+ const auto [available, refresh_day] = effectivePullState(
+ **user, current_day);
+ auto updated = (*transaction)->updatePullState(
+ user_id, available, refresh_day);
+ if(!updated)
+ {
+ return std::unexpected(std::move(updated.error()));
+ }
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ return std::unexpected(std::move(committed.error()));
+ }
+ (**user).stored_pulls = available;
+ (**user).pull_refresh_day = refresh_day;
+ return std::move(**user);
+}
+
+mw::E<PullResult> CollectionService::pull(std::int64_t user_id)
+{
+ const std::int64_t current_day = utcDay(clock_.now());
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto user = (*transaction)->getUserForUpdate(user_id);
+ if(!user)
+ {
+ return std::unexpected(std::move(user.error()));
+ }
+ if(!*user)
+ {
+ return std::unexpected(mw::httpError(404, "User not found"));
+ }
+ if(!(**user).username)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "Username onboarding is required"));
+ }
+ const auto [available, refresh_day] = effectivePullState(
+ **user, current_day);
+ auto refreshed = (*transaction)->updatePullState(
+ user_id, available, refresh_day);
+ if(!refreshed)
+ {
+ return std::unexpected(std::move(refreshed.error()));
+ }
+ if(available == 0)
+ {
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ return std::unexpected(std::move(committed.error()));
+ }
+ return std::unexpected(mw::httpError(
+ 409, "No pulls are available"));
+ }
+ auto cards = (*transaction)->getPoolCardsForUpdate();
+ if(!cards)
+ {
+ return std::unexpected(std::move(cards.error()));
+ }
+ auto entries = card_pool_.calculate(*cards);
+ if(entries.empty())
+ {
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ return std::unexpected(std::move(committed.error()));
+ }
+ return std::unexpected(mw::httpError(409, "Card pool is empty"));
+ }
+ auto selected = card_pool_.select(entries, crypto_);
+ if(!selected)
+ {
+ return std::unexpected(std::move(selected.error()));
+ }
+ auto decremented = (*transaction)->updatePullState(
+ user_id, available - 1, refresh_day);
+ if(!decremented)
+ {
+ return std::unexpected(std::move(decremented.error()));
+ }
+ auto quantity = (*transaction)->incrementHolding(
+ user_id, selected->card.id);
+ if(!quantity)
+ {
+ return std::unexpected(std::move(quantity.error()));
+ }
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ return std::unexpected(std::move(committed.error()));
+ }
+ return PullResult{std::move(selected->card), *quantity};
+}
diff --git a/src/collection.h b/src/collection.h
new file mode 100644
index 0000000..e9a37b4
--- /dev/null
+++ b/src/collection.h
@@ -0,0 +1,49 @@
+#pragma once
+
+#include <cstdint>
+
+#include <mw/crypto.hpp>
+#include <mw/error.hpp>
+
+#include "card_pool.h"
+#include "clock.h"
+#include "data.h"
+
+/// Result of one successful atomic card pull.
+struct PullResult
+{
+ /// Selected current card.
+ Card card;
+
+ /// User's quantity after the award.
+ std::int64_t quantity;
+};
+
+/// Own lazy pull accrual and atomic collection awards.
+class CollectionService
+{
+public:
+ /// Construct the service over injected time, randomness, and persistence.
+ CollectionService(
+ DataSourceInterface& data_source,
+ ClockInterface& clock,
+ mw::CryptoInterface& crypto,
+ std::uint32_t maximum_accumulated_pulls);
+
+ /// Refresh and persist one user's available pulls.
+ mw::E<User> refresh(std::int64_t user_id);
+
+ /// Consume one pull and increment the selected holding atomically.
+ mw::E<PullResult> pull(std::int64_t user_id);
+
+private:
+ /// Calculate availability and nondecreasing refresh day.
+ std::pair<std::uint32_t, std::int64_t> effectivePullState(
+ const User& user, std::int64_t current_day) const;
+
+ DataSourceInterface& data_source_;
+ ClockInterface& clock_;
+ mw::CryptoInterface& crypto_;
+ std::uint32_t maximum_accumulated_pulls_;
+ CardPoolService card_pool_;
+};
diff --git a/src/config.cpp b/src/config.cpp
index c06b8ac..c8ed81c 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -1,6 +1,7 @@
#include "config.h"
#include <array>
+#include <cstdlib>
#include <cstdint>
#include <filesystem>
#include <optional>
@@ -9,23 +10,39 @@
#include <string_view>
#include <system_error>
#include <utility>
+#include <variant>
#include <toml++/toml.hpp>
+#include "email_address.h"
+
namespace
{
const std::set<std::string> CONFIG_KEYS = {
+ "administrator_email",
"avif_quality",
"base_url",
"card_storage_root",
"database_path",
+ "email",
"listen_address",
"listen_port",
+ "maximum_accumulated_pulls",
"static_root",
"thumbnail_long_side",
};
+const std::set<std::string> EMAIL_CONFIG_KEYS = {
+ "daily_attempt_limit",
+ "from_address",
+ "from_name",
+ "link_file",
+ "mailjet_api_key_environment",
+ "mailjet_secret_key_environment",
+ "transport",
+};
+
mw::E<std::string> requiredString(
const toml::table& table,
std::string_view name)
@@ -124,6 +141,23 @@ mw::E<Config> loadConfig(const std::filesystem::path& config_path)
}
}
+ const toml::table* email_table = table["email"].as_table();
+ if(email_table == nullptr)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Configuration key 'email' must be a table"));
+ }
+ for(const auto& [key, value] : *email_table)
+ {
+ [[maybe_unused]] const toml::node& node = value;
+ if(!EMAIL_CONFIG_KEYS.contains(std::string(key.str())))
+ {
+ return std::unexpected(mw::runtimeError(
+ "Unknown email configuration key '" +
+ std::string(key.str()) + "'"));
+ }
+ }
+
auto base_url_text = requiredString(table, "base_url");
auto listen_address_text = requiredString(table, "listen_address");
auto static_root_text = requiredString(table, "static_root");
@@ -132,9 +166,14 @@ mw::E<Config> loadConfig(const std::filesystem::path& config_path)
auto avif_quality = requiredInteger(table, "avif_quality");
auto thumbnail_long_side = requiredInteger(
table, "thumbnail_long_side");
+ auto administrator_email_text = requiredString(
+ table, "administrator_email");
+ auto maximum_accumulated_pulls = requiredInteger(
+ table, "maximum_accumulated_pulls");
if(!base_url_text || !listen_address_text || !static_root_text ||
!database_path_text || !card_storage_text || !avif_quality ||
- !thumbnail_long_side)
+ !thumbnail_long_side || !administrator_email_text ||
+ !maximum_accumulated_pulls)
{
if(!base_url_text)
{
@@ -160,7 +199,18 @@ mw::E<Config> loadConfig(const std::filesystem::path& config_path)
{
return std::unexpected(std::move(avif_quality.error()));
}
- return std::unexpected(std::move(thumbnail_long_side.error()));
+ if(!thumbnail_long_side)
+ {
+ return std::unexpected(std::move(
+ thumbnail_long_side.error()));
+ }
+ if(!administrator_email_text)
+ {
+ return std::unexpected(std::move(
+ administrator_email_text.error()));
+ }
+ return std::unexpected(std::move(
+ maximum_accumulated_pulls.error()));
}
auto base_url = mw::URL::fromStr(*base_url_text);
@@ -219,6 +269,18 @@ mw::E<Config> loadConfig(const std::filesystem::path& config_path)
return std::unexpected(mw::runtimeError(
"thumbnail_long_side must be a positive 32-bit integer"));
}
+ if(*maximum_accumulated_pulls < 1 ||
+ *maximum_accumulated_pulls > UINT32_MAX)
+ {
+ return std::unexpected(mw::runtimeError(
+ "maximum_accumulated_pulls must be a positive 32-bit integer"));
+ }
+ auto administrator_email = normalizeEmail(*administrator_email_text);
+ if(!administrator_email)
+ {
+ return std::unexpected(mw::runtimeError(
+ "administrator_email is invalid"));
+ }
const std::filesystem::path config_directory =
std::filesystem::absolute(config_path).parent_path();
@@ -228,6 +290,106 @@ mw::E<Config> loadConfig(const std::filesystem::path& config_path)
config_directory, *database_path_text);
const std::filesystem::path card_storage_root = normalizedPath(
config_directory, *card_storage_text);
+
+ auto transport_text = requiredString(*email_table, "transport");
+ if(!transport_text)
+ {
+ return std::unexpected(std::move(transport_text.error()));
+ }
+ EmailConfig email;
+ if(*transport_text == "file")
+ {
+ auto link_file = requiredString(*email_table, "link_file");
+ if(!link_file)
+ {
+ return std::unexpected(std::move(link_file.error()));
+ }
+ email.transport = EmailTransport::FILE;
+ email.link_file = std::filesystem::path(*link_file);
+ if(!email.link_file.is_absolute())
+ {
+ return std::unexpected(mw::runtimeError(
+ "email.link_file must be absolute with an existing parent"));
+ }
+ email.link_file = std::filesystem::absolute(
+ email.link_file).lexically_normal();
+ if(!std::filesystem::is_directory(email.link_file.parent_path()))
+ {
+ return std::unexpected(mw::runtimeError(
+ "email.link_file must be absolute with an existing parent"));
+ }
+ if(pathContains(static_root, email.link_file) ||
+ pathContains(card_storage_root, email.link_file))
+ {
+ return std::unexpected(mw::runtimeError(
+ "email.link_file must be outside public asset roots"));
+ }
+ if(email_table->contains("mailjet_api_key_environment") ||
+ email_table->contains("mailjet_secret_key_environment") ||
+ email_table->contains("daily_attempt_limit") ||
+ email_table->contains("from_address") ||
+ email_table->contains("from_name"))
+ {
+ return std::unexpected(mw::runtimeError(
+ "File email transport rejects Mailjet settings"));
+ }
+ if(base_url->scheme() == "http" &&
+ std::holds_alternative<mw::IPSocketInfo>(*listen_address))
+ {
+ const std::string& address =
+ std::get<mw::IPSocketInfo>(*listen_address).address;
+ if(address != "127.0.0.1" && address != "::1" &&
+ address != "localhost")
+ {
+ return std::unexpected(mw::runtimeError(
+ "HTTP file transport requires a loopback listener"));
+ }
+ }
+ }
+ else if(*transport_text == "mailjet")
+ {
+ auto from_address = requiredString(*email_table, "from_address");
+ auto from_name = requiredString(*email_table, "from_name");
+ auto api_environment = requiredString(
+ *email_table, "mailjet_api_key_environment");
+ auto secret_environment = requiredString(
+ *email_table, "mailjet_secret_key_environment");
+ auto daily_limit = requiredInteger(
+ *email_table, "daily_attempt_limit");
+ if(!from_address || !from_name || !api_environment ||
+ !secret_environment || !daily_limit || from_name->empty() ||
+ api_environment->empty() || secret_environment->empty() ||
+ *daily_limit < 1 ||
+ *daily_limit > UINT32_MAX || base_url->scheme() != "https")
+ {
+ return std::unexpected(mw::runtimeError(
+ "Mailjet email configuration is invalid"));
+ }
+ if(!normalizeEmail(*from_address))
+ {
+ return std::unexpected(mw::runtimeError(
+ "email.from_address is invalid"));
+ }
+ const char* api_key = std::getenv(api_environment->c_str());
+ const char* secret_key = std::getenv(secret_environment->c_str());
+ if(api_key == nullptr || *api_key == '\0' ||
+ secret_key == nullptr || *secret_key == '\0')
+ {
+ return std::unexpected(mw::runtimeError(
+ "Mailjet credential environment variables are missing"));
+ }
+ email.transport = EmailTransport::MAILJET;
+ email.from_address = std::move(*from_address);
+ email.from_name = std::move(*from_name);
+ email.mailjet_api_key_environment = std::move(*api_environment);
+ email.mailjet_secret_key_environment = std::move(*secret_environment);
+ email.daily_attempt_limit = static_cast<std::uint32_t>(*daily_limit);
+ }
+ else
+ {
+ return std::unexpected(mw::runtimeError(
+ "email.transport must be 'mailjet' or 'file'"));
+ }
std::error_code filesystem_error;
if(!std::filesystem::is_directory(static_root, filesystem_error) ||
filesystem_error)
@@ -247,6 +409,13 @@ mw::E<Config> loadConfig(const std::filesystem::path& config_path)
return std::unexpected(mw::runtimeError(
"database_path must not be inside a mounted asset root"));
}
+ if(email.transport == EmailTransport::FILE &&
+ (pathContains(static_root, email.link_file) ||
+ pathContains(card_storage_root, email.link_file)))
+ {
+ return std::unexpected(mw::runtimeError(
+ "email.link_file must not be inside an asset root"));
+ }
auto database_directory = createDirectory(
database_path.parent_path(), "the database directory");
@@ -275,5 +444,9 @@ mw::E<Config> loadConfig(const std::filesystem::path& config_path)
card_storage_root,
static_cast<int>(*avif_quality),
static_cast<std::uint32_t>(*thumbnail_long_side),
+ std::move(administrator_email->email),
+ std::move(administrator_email->key),
+ static_cast<std::uint32_t>(*maximum_accumulated_pulls),
+ std::move(email),
};
}
diff --git a/src/config.h b/src/config.h
index 89174fd..f7c371e 100644
--- a/src/config.h
+++ b/src/config.h
@@ -2,11 +2,44 @@
#include <cstdint>
#include <filesystem>
+#include <string>
#include <mw/http_server.hpp>
#include <mw/error.hpp>
#include <mw/url.hpp>
+/// Supported authentication email delivery mechanisms.
+enum class EmailTransport
+{
+ MAILJET,
+ FILE
+};
+
+/// Validated authentication email configuration.
+struct EmailConfig
+{
+ /// Selected delivery mechanism.
+ EmailTransport transport = EmailTransport::FILE;
+
+ /// Sender address used by Mailjet.
+ std::string from_address;
+
+ /// Human-readable sender name used by Mailjet.
+ std::string from_name;
+
+ /// Environment variable containing the Mailjet API key.
+ std::string mailjet_api_key_environment;
+
+ /// Environment variable containing the Mailjet secret key.
+ std::string mailjet_secret_key_environment;
+
+ /// Persistent daily ceiling for attempted Mailjet calls.
+ std::uint32_t daily_attempt_limit = 180;
+
+ /// Private file receiving the latest development link.
+ std::filesystem::path link_file;
+};
+
/// Validated process configuration.
struct Config
{
@@ -30,6 +63,18 @@ struct Config
/// Long-side pixel count used for generated thumbnails.
std::uint32_t thumbnail_long_side;
+
+ /// Validated administrator email spelling.
+ std::string administrator_email = "admin@example.com";
+
+ /// Normalized immutable administrator identity key.
+ std::string administrator_email_key = "admin@example.com";
+
+ /// Maximum lazily accumulated pulls; lowering it can discard accrual.
+ std::uint32_t maximum_accumulated_pulls = 3;
+
+ /// Authentication email delivery configuration.
+ EmailConfig email;
};
/// Load, validate, and normalize one TOML process configuration.
diff --git a/src/data.cpp b/src/data.cpp
index 5a343c0..b1c97f5 100644
--- a/src/data.cpp
+++ b/src/data.cpp
@@ -26,6 +26,232 @@ mw::Error migrationVersionError(
} // namespace
+mw::E<std::optional<User>>
+DataSourceTransactionInterface::getUserForUpdate(
+ [[maybe_unused]] std::int64_t user_id)
+{
+ return std::unexpected(mw::runtimeError(
+ "User transactions are unavailable"));
+}
+
+mw::E<std::optional<User>>
+DataSourceTransactionInterface::getUserByEmailKeyForUpdate(
+ [[maybe_unused]] const std::string& email_key)
+{
+ return std::unexpected(mw::runtimeError(
+ "User transactions are unavailable"));
+}
+
+mw::E<bool> DataSourceTransactionInterface::userOwnsCardForUpdate(
+ [[maybe_unused]] std::int64_t user_id,
+ [[maybe_unused]] std::int64_t card_id)
+{
+ return std::unexpected(mw::runtimeError(
+ "Collection transactions are unavailable"));
+}
+
+mw::E<std::vector<Card>>
+DataSourceTransactionInterface::getPoolCardsForUpdate()
+{
+ return std::unexpected(mw::runtimeError(
+ "Collection transactions are unavailable"));
+}
+
+mw::E<std::int64_t> DataSourceTransactionInterface::insertUser(
+ [[maybe_unused]] const User& user)
+{
+ return std::unexpected(mw::runtimeError(
+ "User transactions are unavailable"));
+}
+
+mw::E<bool> DataSourceTransactionInterface::updateUsername(
+ [[maybe_unused]] std::int64_t user_id,
+ [[maybe_unused]] const std::string& username,
+ [[maybe_unused]] const std::string& username_key)
+{
+ return std::unexpected(mw::runtimeError(
+ "User transactions are unavailable"));
+}
+
+mw::E<bool> DataSourceTransactionInterface::promoteUser(
+ [[maybe_unused]] std::int64_t user_id)
+{
+ return std::unexpected(mw::runtimeError(
+ "User transactions are unavailable"));
+}
+
+mw::E<AuthenticationReservation>
+DataSourceTransactionInterface::reserveAuthenticationEmail(
+ [[maybe_unused]] const std::string& email_key,
+ [[maybe_unused]] std::int64_t now,
+ [[maybe_unused]] bool use_global_quota,
+ [[maybe_unused]] std::int64_t utc_day,
+ [[maybe_unused]] std::uint32_t daily_limit)
+{
+ return std::unexpected(mw::runtimeError(
+ "Authentication transactions are unavailable"));
+}
+
+mw::E<std::int64_t>
+DataSourceTransactionInterface::insertAuthenticationChallenge(
+ [[maybe_unused]] const std::string& email,
+ [[maybe_unused]] const std::string& email_key,
+ [[maybe_unused]] const TokenHash& token_hash,
+ [[maybe_unused]] std::int64_t created_at,
+ [[maybe_unused]] std::int64_t expires_at)
+{
+ return std::unexpected(mw::runtimeError(
+ "Authentication transactions are unavailable"));
+}
+
+mw::E<bool>
+DataSourceTransactionInterface::markAuthenticationChallengeDelivered(
+ [[maybe_unused]] std::int64_t challenge_id,
+ [[maybe_unused]] std::int64_t delivered_at)
+{
+ return std::unexpected(mw::runtimeError(
+ "Authentication transactions are unavailable"));
+}
+
+mw::E<void>
+DataSourceTransactionInterface::deleteAuthenticationChallenge(
+ [[maybe_unused]] std::int64_t challenge_id)
+{
+ return std::unexpected(mw::runtimeError(
+ "Authentication transactions are unavailable"));
+}
+
+mw::E<std::optional<AuthenticationChallenge>>
+DataSourceTransactionInterface::consumeAuthenticationChallenge(
+ [[maybe_unused]] const TokenHash& token_hash,
+ [[maybe_unused]] std::int64_t now)
+{
+ return std::unexpected(mw::runtimeError(
+ "Authentication transactions are unavailable"));
+}
+
+mw::E<void>
+DataSourceTransactionInterface::invalidateAuthenticationChallenges(
+ [[maybe_unused]] const std::string& email_key,
+ [[maybe_unused]] std::int64_t except_challenge_id,
+ [[maybe_unused]] std::int64_t now)
+{
+ return std::unexpected(mw::runtimeError(
+ "Authentication transactions are unavailable"));
+}
+
+mw::E<std::int64_t> DataSourceTransactionInterface::insertSession(
+ [[maybe_unused]] std::int64_t user_id,
+ [[maybe_unused]] const TokenHash& token_hash,
+ [[maybe_unused]] const std::string& csrf_token,
+ [[maybe_unused]] std::int64_t created_at,
+ [[maybe_unused]] std::int64_t expires_at)
+{
+ return std::unexpected(mw::runtimeError(
+ "Authentication transactions are unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::deleteSession(
+ [[maybe_unused]] const TokenHash& token_hash)
+{
+ return std::unexpected(mw::runtimeError(
+ "Authentication transactions are unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::updatePullState(
+ [[maybe_unused]] std::int64_t user_id,
+ [[maybe_unused]] std::uint32_t stored_pulls,
+ [[maybe_unused]] std::int64_t refresh_day)
+{
+ return std::unexpected(mw::runtimeError(
+ "Collection transactions are unavailable"));
+}
+
+mw::E<std::int64_t> DataSourceTransactionInterface::incrementHolding(
+ [[maybe_unused]] std::int64_t user_id,
+ [[maybe_unused]] std::int64_t card_id)
+{
+ return std::unexpected(mw::runtimeError(
+ "Collection transactions are unavailable"));
+}
+
+mw::E<std::vector<Card>> DataSourceInterface::getCardsByCreator(
+ [[maybe_unused]] std::int64_t creator_user_id) const
+{
+ return std::unexpected(mw::runtimeError("User data is unavailable"));
+}
+
+mw::E<std::vector<Card>> DataSourceInterface::getPoolCards() const
+{
+ return std::unexpected(mw::runtimeError(
+ "Collection data is unavailable"));
+}
+
+mw::E<std::optional<User>> DataSourceInterface::getUser(
+ [[maybe_unused]] std::int64_t user_id) const
+{
+ return std::unexpected(mw::runtimeError("User data is unavailable"));
+}
+
+mw::E<std::optional<User>> DataSourceInterface::getUserByEmailKey(
+ [[maybe_unused]] const std::string& email_key) const
+{
+ return std::unexpected(mw::runtimeError("User data is unavailable"));
+}
+
+mw::E<std::vector<User>> DataSourceInterface::getUsers() const
+{
+ return std::unexpected(mw::runtimeError("User data is unavailable"));
+}
+
+mw::E<std::optional<SessionContext>> DataSourceInterface::getSession(
+ [[maybe_unused]] const TokenHash& token_hash,
+ [[maybe_unused]] std::int64_t now) const
+{
+ return std::unexpected(mw::runtimeError(
+ "Authentication data is unavailable"));
+}
+
+mw::E<std::optional<AuthenticationChallenge>>
+DataSourceInterface::getAuthenticationChallenge(
+ [[maybe_unused]] const TokenHash& token_hash,
+ [[maybe_unused]] std::int64_t now) const
+{
+ return std::unexpected(mw::runtimeError(
+ "Authentication data is unavailable"));
+}
+
+mw::E<std::vector<CollectionEntry>> DataSourceInterface::getCollection(
+ [[maybe_unused]] std::int64_t user_id) const
+{
+ return std::unexpected(mw::runtimeError(
+ "Collection data is unavailable"));
+}
+
+mw::E<bool> DataSourceInterface::userOwnsCard(
+ [[maybe_unused]] std::int64_t user_id,
+ [[maybe_unused]] std::int64_t card_id) const
+{
+ return std::unexpected(mw::runtimeError(
+ "Collection data is unavailable"));
+}
+
+mw::E<User> DataSourceInterface::reconcileAdministrator(
+ [[maybe_unused]] const std::string& email,
+ [[maybe_unused]] const std::string& email_key,
+ [[maybe_unused]] std::int64_t created_at,
+ [[maybe_unused]] std::int64_t pull_refresh_day)
+{
+ return std::unexpected(mw::runtimeError("User data is unavailable"));
+}
+
+mw::E<void> DataSourceInterface::cleanupAuthentication(
+ [[maybe_unused]] std::int64_t now)
+{
+ return std::unexpected(mw::runtimeError(
+ "Authentication data is unavailable"));
+}
+
mw::E<void> DataSourceInterface::migrateToLatest(const GameRegistry& games)
{
auto version_result = getSchemaVersion();
diff --git a/src/data.h b/src/data.h
index bd397a5..110c8f6 100644
--- a/src/data.h
+++ b/src/data.h
@@ -11,6 +11,70 @@
#include "card.h"
#include "game.h"
#include "game_definition.h"
+#include "user.h"
+
+/// Raw SHA-256 credential digest stored as a 32-byte SQLite BLOB.
+using TokenHash = std::vector<unsigned char>;
+
+/// One distinct card and the quantity owned by a user.
+struct CollectionEntry
+{
+ /// Current card presentation.
+ Card card;
+
+ /// Positive quantity owned.
+ std::int64_t quantity;
+};
+
+/// Delivered, unconsumed authentication challenge metadata.
+struct AuthenticationChallenge
+{
+ /// Internal challenge identity.
+ std::int64_t id;
+
+ /// Validated delivery spelling.
+ std::string email;
+
+ /// Normalized account identity.
+ std::string email_key;
+
+ /// Unix expiry timestamp.
+ std::int64_t expires_at;
+};
+
+/// Valid session joined with its current user record.
+struct SessionContext
+{
+ /// Internal session identity.
+ std::int64_t session_id;
+
+ /// Current user record.
+ User user;
+
+ /// Independent authenticated-form CSRF credential.
+ std::string csrf_token;
+
+ /// Non-sliding Unix expiry timestamp.
+ std::int64_t expires_at;
+};
+
+/// Result of atomically reserving authentication email capacity.
+enum class AuthenticationReservationStatus
+{
+ RESERVED,
+ EMAIL_LIMITED,
+ GLOBAL_LIMITED
+};
+
+/// Reservation status and retry delay for a rejected request.
+struct AuthenticationReservation
+{
+ /// Atomic reservation result.
+ AuthenticationReservationStatus status;
+
+ /// Seconds until another attempt may succeed.
+ std::int64_t retry_after;
+};
/// Immutable collection of compiled game definitions.
class GameRegistry;
@@ -36,10 +100,93 @@ public:
/// Return whether a loose-card number already exists.
virtual mw::E<bool> looseNumberExists(std::uint32_t number) = 0;
+ /// Re-read a user while the transaction lock is held.
+ virtual mw::E<std::optional<User>>
+ getUserForUpdate(std::int64_t user_id);
+
+ /// Re-read a user by normalized email while the lock is held.
+ virtual mw::E<std::optional<User>> getUserByEmailKeyForUpdate(
+ const std::string& email_key);
+
/// Re-read a card while the transaction lock is held.
virtual mw::E<std::optional<Card>>
getCardForUpdate(std::int64_t card_id) = 0;
+ /// Return whether a user owns a card while the lock is held.
+ virtual mw::E<bool> userOwnsCardForUpdate(
+ std::int64_t user_id, std::int64_t card_id);
+
+ /// Return the positive-rarity pool in card-ID order under the lock.
+ virtual mw::E<std::vector<Card>> getPoolCardsForUpdate();
+
+ /// Insert a newly confirmed account and return its internal ID.
+ virtual mw::E<std::int64_t> insertUser(const User& user);
+
+ /// Atomically replace a user's normalized username pair.
+ virtual mw::E<bool> updateUsername(
+ std::int64_t user_id,
+ const std::string& username,
+ const std::string& username_key);
+
+ /// Conditionally promote one player to creator.
+ virtual mw::E<bool> promoteUser(std::int64_t user_id);
+
+ /// Reserve per-email and optional global delivery capacity.
+ virtual mw::E<AuthenticationReservation> reserveAuthenticationEmail(
+ const std::string& email_key,
+ std::int64_t now,
+ bool use_global_quota,
+ std::int64_t utc_day,
+ std::uint32_t daily_limit);
+
+ /// Insert a pending authentication challenge and return its ID.
+ virtual mw::E<std::int64_t> insertAuthenticationChallenge(
+ const std::string& email,
+ const std::string& email_key,
+ const TokenHash& token_hash,
+ std::int64_t created_at,
+ std::int64_t expires_at);
+
+ /// Activate a pending challenge after successful delivery.
+ virtual mw::E<bool> markAuthenticationChallengeDelivered(
+ std::int64_t challenge_id, std::int64_t delivered_at);
+
+ /// Delete one challenge after failed delivery.
+ virtual mw::E<void> deleteAuthenticationChallenge(
+ std::int64_t challenge_id);
+
+ /// Atomically consume one valid delivered challenge.
+ virtual mw::E<std::optional<AuthenticationChallenge>>
+ consumeAuthenticationChallenge(
+ const TokenHash& token_hash, std::int64_t now);
+
+ /// Invalidate every other outstanding challenge for an email key.
+ virtual mw::E<void> invalidateAuthenticationChallenges(
+ const std::string& email_key,
+ std::int64_t except_challenge_id,
+ std::int64_t now);
+
+ /// Insert a four-week session and return its internal ID.
+ virtual mw::E<std::int64_t> insertSession(
+ std::int64_t user_id,
+ const TokenHash& token_hash,
+ const std::string& csrf_token,
+ std::int64_t created_at,
+ std::int64_t expires_at);
+
+ /// Delete a session by its token digest.
+ virtual mw::E<void> deleteSession(const TokenHash& token_hash);
+
+ /// Persist a lazily refreshed pull state.
+ virtual mw::E<void> updatePullState(
+ std::int64_t user_id,
+ std::uint32_t stored_pulls,
+ std::int64_t refresh_day);
+
+ /// Insert or increment a holding and return its new quantity.
+ virtual mw::E<std::int64_t> incrementHolding(
+ std::int64_t user_id, std::int64_t card_id);
+
/// Insert common, game-specific, and series-membership card rows.
virtual mw::E<std::int64_t> insertCard(
const Card& card,
@@ -94,10 +241,55 @@ public:
/// Return all cards for the unpaginated index.
virtual mw::E<std::vector<Card>> getCards() const = 0;
+ /// Return all cards authored by one user.
+ virtual mw::E<std::vector<Card>> getCardsByCreator(
+ std::int64_t creator_user_id) const;
+
+ /// Return every current positive-rarity card in card-ID order.
+ virtual mw::E<std::vector<Card>> getPoolCards() const;
+
/// Return a card by its parsed identity.
virtual mw::E<std::optional<Card>>
getCard(const CardIdentity& identity) const = 0;
+ /// Return a user by internal identity.
+ virtual mw::E<std::optional<User>> getUser(
+ std::int64_t user_id) const;
+
+ /// Return a user by normalized immutable email identity.
+ virtual mw::E<std::optional<User>> getUserByEmailKey(
+ const std::string& email_key) const;
+
+ /// Return every user for administrator management.
+ virtual mw::E<std::vector<User>> getUsers() const;
+
+ /// Return a valid session joined with its current user.
+ virtual mw::E<std::optional<SessionContext>> getSession(
+ const TokenHash& token_hash, std::int64_t now) const;
+
+ /// Read one valid delivered challenge without consuming it.
+ virtual mw::E<std::optional<AuthenticationChallenge>>
+ getAuthenticationChallenge(
+ const TokenHash& token_hash, std::int64_t now) const;
+
+ /// Return a user's distinct collection entries.
+ virtual mw::E<std::vector<CollectionEntry>> getCollection(
+ std::int64_t user_id) const;
+
+ /// Return whether a user currently owns a card.
+ virtual mw::E<bool> userOwnsCard(
+ std::int64_t user_id, std::int64_t card_id) const;
+
+ /// Reconcile the one immutable configured administrator identity.
+ virtual mw::E<User> reconcileAdministrator(
+ const std::string& email,
+ const std::string& email_key,
+ std::int64_t created_at,
+ std::int64_t pull_refresh_day);
+
+ /// Best-effort removal of obsolete authentication persistence rows.
+ virtual mw::E<void> cleanupAuthentication(std::int64_t now);
+
/// Return a card's game-owned display fields.
virtual mw::E<std::vector<DisplayField>> getGameDisplayFields(
const GameDefinition& game,
diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
index ae48098..030165f 100644
--- a/src/data_sqlite.cpp
+++ b/src/data_sqlite.cpp
@@ -1,5 +1,6 @@
#include "data_sqlite.h"
+#include <algorithm>
#include <array>
#include <cstdint>
#include <limits>
@@ -33,10 +34,88 @@ mw::E<void> rollbackWithError(
return std::unexpected(std::move(error));
}
-const std::array<std::string_view, 7> SCHEMA_VERSION_1_STATEMENTS = {
+const std::array<std::string_view, 20> SCHEMA_VERSION_1_STATEMENTS = {
+ R"sql(
+ CREATE TABLE application_metadata(
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ ) STRICT;
+ )sql",
+ R"sql(
+ CREATE TABLE users(
+ id INTEGER PRIMARY KEY,
+ email TEXT NOT NULL,
+ email_key TEXT NOT NULL UNIQUE,
+ username TEXT,
+ username_key TEXT UNIQUE,
+ role INTEGER NOT NULL CHECK(role BETWEEN 0 AND 2),
+ stored_pulls INTEGER NOT NULL CHECK(stored_pulls >= 0),
+ pull_refresh_day INTEGER NOT NULL,
+ created_at INTEGER NOT NULL,
+ CHECK((username IS NULL) = (username_key IS NULL))
+ ) STRICT;
+ )sql",
+ R"sql(
+ CREATE UNIQUE INDEX users_one_administrator
+ ON users(role) WHERE role = 2;
+ )sql",
+ R"sql(
+ CREATE TABLE authentication_challenges(
+ id INTEGER PRIMARY KEY,
+ email TEXT NOT NULL,
+ email_key TEXT NOT NULL,
+ token_hash BLOB NOT NULL UNIQUE
+ CHECK(length(token_hash) = 32),
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ delivered_at INTEGER,
+ consumed_at INTEGER,
+ CHECK(expires_at > created_at),
+ CHECK(delivered_at IS NULL OR delivered_at >= created_at),
+ CHECK(consumed_at IS NULL OR delivered_at IS NOT NULL)
+ ) STRICT;
+ )sql",
+ R"sql(
+ CREATE INDEX authentication_challenges_email
+ ON authentication_challenges(email_key, expires_at);
+ )sql",
+ R"sql(
+ CREATE TABLE sessions(
+ id INTEGER PRIMARY KEY,
+ user_id INTEGER NOT NULL
+ REFERENCES users(id) ON DELETE CASCADE,
+ token_hash BLOB NOT NULL UNIQUE
+ CHECK(length(token_hash) = 32),
+ csrf_token TEXT NOT NULL UNIQUE,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ CHECK(expires_at > created_at)
+ ) STRICT;
+ )sql",
+ R"sql(
+ CREATE INDEX sessions_user ON sessions(user_id);
+ )sql",
+ R"sql(
+ CREATE INDEX sessions_expiry ON sessions(expires_at);
+ )sql",
+ R"sql(
+ CREATE TABLE authentication_email_limits(
+ email_key TEXT PRIMARY KEY,
+ next_allowed_at INTEGER NOT NULL
+ ) STRICT;
+ )sql",
+ R"sql(
+ CREATE TABLE authentication_quota(
+ utc_day INTEGER PRIMARY KEY,
+ attempted_sends INTEGER NOT NULL
+ CHECK(attempted_sends >= 0)
+ ) STRICT;
+ )sql",
R"sql(
CREATE TABLE cards (
id INTEGER PRIMARY KEY,
+ creator_user_id INTEGER NOT NULL
+ REFERENCES users(id) ON DELETE RESTRICT,
game_short_name TEXT,
card_number INTEGER NOT NULL,
name TEXT NOT NULL,
@@ -71,6 +150,12 @@ const std::array<std::string_view, 7> SCHEMA_VERSION_1_STATEMENTS = {
ON cards(card_number)
WHERE game_short_name IS NULL;
)sql",
+ R"sql(
+ CREATE INDEX cards_creator ON cards(creator_user_id, id);
+ )sql",
+ R"sql(
+ CREATE INDEX cards_pool ON cards(rarity, id) WHERE rarity > 0;
+ )sql",
R"sql(
CREATE TABLE game_sequences (
game_short_name TEXT PRIMARY KEY,
@@ -115,8 +200,135 @@ const std::array<std::string_view, 7> SCHEMA_VERSION_1_STATEMENTS = {
END;
END;
)sql",
+ R"sql(
+ CREATE TABLE card_holdings(
+ user_id INTEGER NOT NULL
+ REFERENCES users(id) ON DELETE CASCADE,
+ card_id INTEGER NOT NULL
+ REFERENCES cards(id) ON DELETE CASCADE,
+ quantity INTEGER NOT NULL CHECK(quantity > 0),
+ PRIMARY KEY(user_id, card_id)
+ ) WITHOUT ROWID, STRICT;
+ )sql",
};
+mw::E<std::string> tokenBytes(const TokenHash& token_hash)
+{
+ if(token_hash.size() != 32)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Credential digest must contain 32 bytes"));
+ }
+ return std::string(
+ reinterpret_cast<const char*>(token_hash.data()),
+ token_hash.size());
+}
+
+mw::E<UserRole> userRole(std::int64_t value)
+{
+ if(value < static_cast<std::int64_t>(UserRole::PLAYER) ||
+ value > static_cast<std::int64_t>(UserRole::ADMINISTRATOR))
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database contains an invalid user role"));
+ }
+ return static_cast<UserRole>(value);
+}
+
+using UserRow = std::tuple<
+ std::int64_t,
+ std::string,
+ std::string,
+ std::optional<std::string>,
+ std::int64_t,
+ std::int64_t,
+ std::int64_t,
+ std::int64_t>;
+
+mw::E<User> userFromRow(UserRow row)
+{
+ auto [id, email, email_key, username, role_value, stored_pulls,
+ refresh_day, created_at] = std::move(row);
+ auto role = userRole(role_value);
+ if(!role || stored_pulls < 0 ||
+ stored_pulls > std::numeric_limits<std::uint32_t>::max())
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database contains an invalid user record"));
+ }
+ return User{
+ id,
+ std::move(email),
+ std::move(email_key),
+ std::move(username),
+ *role,
+ static_cast<std::uint32_t>(stored_pulls),
+ refresh_day,
+ created_at};
+}
+
+using CardRow = std::tuple<
+ std::int64_t,
+ std::int64_t,
+ std::optional<std::string>,
+ std::int64_t,
+ std::string,
+ std::optional<std::string>,
+ std::optional<std::string>,
+ std::int64_t,
+ std::string,
+ std::optional<std::string>,
+ std::string,
+ std::int64_t>;
+
+mw::E<Card> cardFromRow(CardRow row)
+{
+ auto [id, creator_user_id, game_short_name, card_number, name,
+ short_description, long_description, rarity, front_extension,
+ foil_extension, thumbnail_extension, revision] = std::move(row);
+ const bool invalid_loose_number =
+ !game_short_name &&
+ card_number > std::numeric_limits<std::uint32_t>::max();
+ const bool invalid_game_number =
+ game_short_name &&
+ (game_short_name->empty() || card_number == 0);
+ if(creator_user_id <= 0 || card_number < 0 || invalid_loose_number ||
+ invalid_game_number || rarity < 0 || revision < 1)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database contains an invalid card record"));
+ }
+ return Card{
+ id,
+ {std::move(game_short_name),
+ static_cast<std::uint64_t>(card_number)},
+ std::move(name),
+ std::move(short_description),
+ std::move(long_description),
+ rarity,
+ std::move(front_extension),
+ std::move(foil_extension),
+ std::move(thumbnail_extension),
+ revision,
+ creator_user_id};
+}
+
+mw::E<std::vector<Card>> cardsFromRows(std::vector<CardRow> rows)
+{
+ std::vector<Card> result;
+ result.reserve(rows.size());
+ for(CardRow& row : rows)
+ {
+ auto card = cardFromRow(std::move(row));
+ if(!card)
+ {
+ return std::unexpected(std::move(card.error()));
+ }
+ result.push_back(std::move(*card));
+ }
+ return result;
+}
+
class DataSourceSQLiteTransaction final
: public DataSourceTransactionInterface
{
@@ -229,327 +441,391 @@ public:
return *result != 0;
}
- /// Re-read a card while the transaction lock is held.
- mw::E<std::optional<Card>> getCardForUpdate(
- std::int64_t card_id) override
+ /// Re-read a user while the transaction lock is held.
+ mw::E<std::optional<User>> getUserForUpdate(
+ std::int64_t user_id) override
{
auto statement = connection_.statementFromStr(
- "SELECT id, game_short_name, card_number, name, "
- "short_description, long_description, rarity, "
- "front_extension, foil_extension, thumbnail_extension, "
- "revision FROM cards WHERE id = ?;");
+ "SELECT id, email, email_key, username, role, stored_pulls, "
+ "pull_refresh_day, created_at FROM users WHERE id = ?;");
if(!statement)
{
return std::unexpected(std::move(statement.error()));
}
- auto bind = statement->bind<std::int64_t>(card_id);
+ auto bind = statement->bind<std::int64_t>(user_id);
if(!bind)
{
return std::unexpected(std::move(bind.error()));
}
- auto rows = connection_.eval<
- std::int64_t,
- std::optional<std::string>,
- std::int64_t,
- std::string,
- std::optional<std::string>,
- std::optional<std::string>,
- std::int64_t,
- std::string,
- std::optional<std::string>,
- std::string,
- std::int64_t>(std::move(*statement));
- if(!rows)
+ return readUser(std::move(*statement));
+ }
+
+ /// Re-read a user by normalized email while the lock is held.
+ mw::E<std::optional<User>> getUserByEmailKeyForUpdate(
+ const std::string& email_key) override
+ {
+ auto statement = connection_.statementFromStr(
+ "SELECT id, email, email_key, username, role, stored_pulls, "
+ "pull_refresh_day, created_at FROM users WHERE email_key = ?;");
+ if(!statement)
{
- return std::unexpected(std::move(rows.error()));
+ return std::unexpected(std::move(statement.error()));
}
- if(rows->empty())
+ auto bind = statement->bind<std::string>(email_key);
+ if(!bind)
{
- return std::optional<Card>{};
+ return std::unexpected(std::move(bind.error()));
}
+ return readUser(std::move(*statement));
+ }
- auto& [
- id,
- game_short_name,
- card_number,
- name,
- short_description,
- long_description,
- rarity,
- front_extension,
- foil_extension,
- thumbnail_extension,
- revision] = rows->front();
- Card card = {
- id,
- {std::move(game_short_name),
- static_cast<std::uint64_t>(card_number)},
- std::move(name),
- std::move(short_description),
- std::move(long_description),
- rarity,
- std::move(front_extension),
- std::move(foil_extension),
- std::move(thumbnail_extension),
- revision,
- };
- return std::optional<Card>(std::move(card));
+ /// Return whether a user owns a card while the lock is held.
+ mw::E<bool> userOwnsCardForUpdate(
+ std::int64_t user_id, std::int64_t card_id) override
+ {
+ auto statement = connection_.statementFromStr(
+ "SELECT EXISTS(SELECT 1 FROM card_holdings "
+ "WHERE user_id = ? AND card_id = ?);");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(user_id, card_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto result = connection_.evalToValue<int>(std::move(*statement));
+ if(!result)
+ {
+ return std::unexpected(std::move(result.error()));
+ }
+ return *result != 0;
}
- /// Insert common and series-membership rows for a loose card.
- mw::E<std::int64_t> insertCard(
- const Card& card,
- const GameDefinition* game,
- const GameCardMetadata* metadata,
- const std::vector<std::int64_t>& series_ids) override
+ /// Return the current positive-rarity pool under the lock.
+ mw::E<std::vector<Card>> getPoolCardsForUpdate() override
{
- if(card.id != 0)
+ return readCards(
+ "WHERE rarity > 0 ORDER BY id");
+ }
+
+ /// Insert a newly confirmed account and return its internal ID.
+ mw::E<std::int64_t> insertUser(const User& user) override
+ {
+ if(user.id != 0)
{
return std::unexpected(mw::runtimeError(
- "A new card cannot already have an internal ID"));
+ "A new user cannot already have an internal ID"));
}
- if((game == nullptr) != (metadata == nullptr))
+ auto statement = connection_.statementFromStr(
+ "INSERT INTO users(email, email_key, username, username_key, "
+ "role, stored_pulls, pull_refresh_day, created_at) "
+ "VALUES (?, ?, ?, NULL, ?, ?, ?, ?);");
+ if(!statement)
{
- return std::unexpected(mw::runtimeError(
- "Game definition and metadata must be provided together"));
+ return std::unexpected(std::move(statement.error()));
}
- if(card.identity.game_short_name)
+ auto bind = statement->bind(
+ user.email,
+ user.email_key,
+ user.username,
+ static_cast<std::int64_t>(user.role),
+ static_cast<std::int64_t>(user.stored_pulls),
+ user.pull_refresh_day,
+ user.created_at);
+ if(!bind)
{
- if(game == nullptr)
- {
- return std::unexpected(mw::runtimeError(
- "A game card requires compiled game metadata"));
- }
- if(game->shortName() != *card.identity.game_short_name)
- {
- return std::unexpected(mw::runtimeError(
- "Card and compiled game identities differ"));
- }
+ return std::unexpected(std::move(bind.error()));
}
- else if(game != nullptr || !series_ids.empty())
+ auto inserted = connection_.execute(std::move(*statement));
+ if(!inserted)
{
- return std::unexpected(mw::runtimeError(
- "A loose card cannot have game metadata or series"));
+ return std::unexpected(std::move(inserted.error()));
}
- if(!card.identity.game_short_name &&
- card.identity.card_number >
- std::numeric_limits<std::uint32_t>::max())
+ return connection_.lastInsertRowID();
+ }
+
+ /// Atomically replace a user's normalized username pair.
+ mw::E<bool> updateUsername(
+ std::int64_t user_id,
+ const std::string& username,
+ const std::string& username_key) override
+ {
+ auto statement = connection_.statementFromStr(
+ "UPDATE users SET username = ?, username_key = ? "
+ "WHERE id = ? RETURNING id;");
+ if(!statement)
{
- return std::unexpected(mw::runtimeError(
- "Loose card number exceeds the 32-bit namespace"));
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(username, username_key, user_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto rows = connection_.eval<std::int64_t>(std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
}
+ return !rows->empty();
+ }
- auto insert = connection_.statementFromStr(
- "INSERT INTO cards ("
- "game_short_name, card_number, name, short_description, "
- "long_description, rarity, front_extension, foil_extension, "
- "thumbnail_extension, revision) "
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);");
- if(!insert)
+ /// Conditionally promote one player to creator.
+ mw::E<bool> promoteUser(std::int64_t user_id) override
+ {
+ auto statement = connection_.statementFromStr(
+ "UPDATE users SET role = 1 WHERE id = ? AND role = 0 "
+ "RETURNING id;");
+ if(!statement)
{
- return std::unexpected(std::move(insert.error()));
+ return std::unexpected(std::move(statement.error()));
}
- auto bind = insert->bind(
- card.identity.game_short_name,
- static_cast<std::int64_t>(card.identity.card_number),
- card.name,
- card.short_description,
- card.long_description,
- card.rarity,
- card.front_extension,
- card.foil_extension,
- card.thumbnail_extension,
- card.revision);
+ auto bind = statement->bind<std::int64_t>(user_id);
if(!bind)
{
return std::unexpected(std::move(bind.error()));
}
- auto execute = connection_.execute(std::move(*insert));
- if(!execute)
+ auto rows = connection_.eval<std::int64_t>(std::move(*statement));
+ if(!rows)
{
- return std::unexpected(std::move(execute.error()));
+ return std::unexpected(std::move(rows.error()));
}
- const std::int64_t card_id = connection_.lastInsertRowID();
+ return !rows->empty();
+ }
- if(game != nullptr)
+ /// Reserve per-email and optional global delivery capacity.
+ mw::E<AuthenticationReservation> reserveAuthenticationEmail(
+ const std::string& email_key,
+ std::int64_t now,
+ bool use_global_quota,
+ std::int64_t utc_day,
+ std::uint32_t daily_limit) override
+ {
+ auto saved = connection_.execute("SAVEPOINT email_reservation;");
+ if(!saved)
{
- auto game_insert = game->insertMetadata(
- connection_, card_id, *metadata);
- if(!game_insert)
+ return std::unexpected(std::move(saved.error()));
+ }
+ auto email_statement = connection_.statementFromStr(
+ "INSERT INTO authentication_email_limits"
+ "(email_key, next_allowed_at) VALUES (?, ?) "
+ "ON CONFLICT(email_key) DO UPDATE SET "
+ "next_allowed_at = excluded.next_allowed_at "
+ "WHERE authentication_email_limits.next_allowed_at <= ? "
+ "RETURNING next_allowed_at;");
+ if(!email_statement)
+ {
+ return rollbackReservation(std::move(email_statement.error()));
+ }
+ auto email_bind = email_statement->bind(email_key, now + 60, now);
+ if(!email_bind)
+ {
+ return rollbackReservation(std::move(email_bind.error()));
+ }
+ auto email_rows = connection_.eval<std::int64_t>(
+ std::move(*email_statement));
+ if(!email_rows)
+ {
+ return rollbackReservation(std::move(email_rows.error()));
+ }
+ if(email_rows->empty())
+ {
+ auto retry = emailRetryAfter(email_key, now);
+ rollbackReservationState();
+ if(!retry)
{
- return std::unexpected(std::move(game_insert.error()));
+ return std::unexpected(std::move(retry.error()));
}
+ return AuthenticationReservation{
+ AuthenticationReservationStatus::EMAIL_LIMITED,
+ *retry};
}
- for(std::int64_t series_id : series_ids)
+ if(use_global_quota)
{
- auto membership = connection_.statementFromStr(
- "INSERT INTO card_series (card_id, series_id) "
- "VALUES (?, ?);");
- if(!membership)
+ auto quota = connection_.statementFromStr(
+ "INSERT INTO authentication_quota"
+ "(utc_day, attempted_sends) VALUES (?, 1) "
+ "ON CONFLICT(utc_day) DO UPDATE SET "
+ "attempted_sends = attempted_sends + 1 "
+ "WHERE attempted_sends < ? RETURNING attempted_sends;");
+ if(!quota)
{
- return std::unexpected(std::move(membership.error()));
+ return rollbackReservation(std::move(quota.error()));
}
- auto membership_bind = membership->bind<std::int64_t,
- std::int64_t>(
- card_id, series_id);
- if(!membership_bind)
+ auto quota_bind = quota->bind(
+ utc_day, static_cast<std::int64_t>(daily_limit));
+ if(!quota_bind)
{
- return std::unexpected(
- std::move(membership_bind.error()));
+ return rollbackReservation(std::move(quota_bind.error()));
}
- auto membership_execute = connection_.execute(
- std::move(*membership));
- if(!membership_execute)
+ auto quota_rows = connection_.eval<std::int64_t>(
+ std::move(*quota));
+ if(!quota_rows)
{
- return std::unexpected(
- std::move(membership_execute.error()));
+ return rollbackReservation(std::move(quota_rows.error()));
+ }
+ if(quota_rows->empty())
+ {
+ rollbackReservationState();
+ return AuthenticationReservation{
+ AuthenticationReservationStatus::GLOBAL_LIMITED,
+ std::max<std::int64_t>(
+ 1, (utc_day + 1) * 86400 - now)};
}
}
- return card_id;
+
+ auto released = connection_.execute(
+ "RELEASE email_reservation;");
+ if(!released)
+ {
+ return std::unexpected(std::move(released.error()));
+ }
+ return AuthenticationReservation{
+ AuthenticationReservationStatus::RESERVED, 0};
}
- /// Replace card rows after validation by the service layer.
- mw::E<void> updateCard(
- const Card& card,
- const GameDefinition* game,
- const GameCardMetadata* metadata,
- const std::vector<std::int64_t>& series_ids)
- override
+ /// Insert a pending authentication challenge and return its ID.
+ mw::E<std::int64_t> insertAuthenticationChallenge(
+ const std::string& email,
+ const std::string& email_key,
+ const TokenHash& token_hash,
+ std::int64_t created_at,
+ std::int64_t expires_at) override
{
- if(card.id <= 0)
+ auto bytes = tokenBytes(token_hash);
+ if(!bytes)
{
- return std::unexpected(mw::runtimeError(
- "An updated card requires an internal ID"));
+ return std::unexpected(std::move(bytes.error()));
}
- if((game == nullptr) != (metadata == nullptr))
+ auto statement = connection_.statementFromStr(
+ "INSERT INTO authentication_challenges"
+ "(email, email_key, token_hash, created_at, expires_at) "
+ "VALUES (?, ?, CAST(? AS BLOB), ?, ?);");
+ if(!statement)
{
- return std::unexpected(mw::runtimeError(
- "Game definition and metadata must be provided together"));
+ return std::unexpected(std::move(statement.error()));
}
- if(card.identity.game_short_name)
+ auto bind = statement->bind(
+ email, email_key, *bytes, created_at, expires_at);
+ if(!bind)
{
- if(game == nullptr ||
- game->shortName() != *card.identity.game_short_name)
- {
- return std::unexpected(mw::runtimeError(
- "A game card requires its compiled game metadata"));
- }
+ return std::unexpected(std::move(bind.error()));
}
- else if(game != nullptr || !series_ids.empty())
+ auto inserted = connection_.execute(std::move(*statement));
+ if(!inserted)
{
- return std::unexpected(mw::runtimeError(
- "A loose card cannot have game metadata or series"));
+ return std::unexpected(std::move(inserted.error()));
}
+ return connection_.lastInsertRowID();
+ }
+ /// Activate a pending challenge after successful delivery.
+ mw::E<bool> markAuthenticationChallengeDelivered(
+ std::int64_t challenge_id,
+ std::int64_t delivered_at) override
+ {
auto statement = connection_.statementFromStr(
- "UPDATE cards SET name = ?, short_description = ?, "
- "long_description = ?, rarity = ?, front_extension = ?, "
- "foil_extension = ?, thumbnail_extension = ?, revision = ? "
- "WHERE id = ? AND "
- "((? IS NULL AND game_short_name IS NULL) OR "
- "game_short_name = ?) AND card_number = ?;");
+ "UPDATE authentication_challenges SET delivered_at = ? "
+ "WHERE id = ? AND delivered_at IS NULL AND consumed_at IS NULL "
+ "RETURNING id;");
if(!statement)
{
return std::unexpected(std::move(statement.error()));
}
- auto bind = statement->bind(
- card.name,
- card.short_description,
- card.long_description,
- card.rarity,
- card.front_extension,
- card.foil_extension,
- card.thumbnail_extension,
- card.revision,
- card.id,
- card.identity.game_short_name,
- card.identity.game_short_name,
- static_cast<std::int64_t>(card.identity.card_number));
+ auto bind = statement->bind(delivered_at, challenge_id);
if(!bind)
{
return std::unexpected(std::move(bind.error()));
}
- auto update = connection_.execute(std::move(*statement));
- if(!update)
+ auto rows = connection_.eval<std::int64_t>(std::move(*statement));
+ if(!rows)
{
- return std::unexpected(std::move(update.error()));
+ return std::unexpected(std::move(rows.error()));
}
- auto changes = connection_.evalToValue<std::int64_t>(
- "SELECT changes();");
- if(!changes)
+ return !rows->empty();
+ }
+
+ /// Delete one challenge after failed delivery.
+ mw::E<void> deleteAuthenticationChallenge(
+ std::int64_t challenge_id) override
+ {
+ auto statement = connection_.statementFromStr(
+ "DELETE FROM authentication_challenges "
+ "WHERE id = ? AND delivered_at IS NULL;");
+ if(!statement)
{
- return std::unexpected(std::move(changes.error()));
+ return std::unexpected(std::move(statement.error()));
}
- if(*changes != 1)
+ auto bind = statement->bind<std::int64_t>(challenge_id);
+ if(!bind)
{
- return std::unexpected(mw::runtimeError(
- "The card disappeared while it was being updated"));
+ return std::unexpected(std::move(bind.error()));
}
- if(game != nullptr)
+ return connection_.execute(std::move(*statement));
+ }
+
+ /// Atomically consume one valid delivered challenge.
+ mw::E<std::optional<AuthenticationChallenge>>
+ consumeAuthenticationChallenge(
+ const TokenHash& token_hash, std::int64_t now) override
+ {
+ auto bytes = tokenBytes(token_hash);
+ if(!bytes)
{
- auto game_update = game->updateMetadata(
- connection_, card.id, *metadata);
- if(!game_update)
- {
- return std::unexpected(std::move(game_update.error()));
- }
+ return std::unexpected(std::move(bytes.error()));
}
- auto delete_memberships = connection_.statementFromStr(
- "DELETE FROM card_series WHERE card_id = ?;");
- if(!delete_memberships)
+ auto statement = connection_.statementFromStr(
+ "UPDATE authentication_challenges SET consumed_at = ? "
+ "WHERE token_hash = CAST(? AS BLOB) "
+ "AND delivered_at IS NOT NULL AND consumed_at IS NULL "
+ "AND expires_at > ? "
+ "RETURNING id, email, email_key, expires_at;");
+ if(!statement)
{
- return std::unexpected(std::move(delete_memberships.error()));
+ return std::unexpected(std::move(statement.error()));
}
- auto delete_bind = delete_memberships->bind<std::int64_t>(card.id);
- if(!delete_bind)
+ auto bind = statement->bind(now, *bytes, now);
+ if(!bind)
{
- return std::unexpected(std::move(delete_bind.error()));
+ return std::unexpected(std::move(bind.error()));
}
- auto deleted = connection_.execute(std::move(*delete_memberships));
- if(!deleted)
+ auto rows = connection_.eval<
+ std::int64_t,
+ std::string,
+ std::string,
+ std::int64_t>(std::move(*statement));
+ if(!rows)
{
- return std::unexpected(std::move(deleted.error()));
+ return std::unexpected(std::move(rows.error()));
}
- for(std::int64_t series_id : series_ids)
+ if(rows->empty())
{
- auto membership = connection_.statementFromStr(
- "INSERT INTO card_series (card_id, series_id) "
- "VALUES (?, ?);");
- if(!membership)
- {
- return std::unexpected(std::move(membership.error()));
- }
- auto membership_bind = membership->bind<std::int64_t,
- std::int64_t>(
- card.id, series_id);
- if(!membership_bind)
- {
- return std::unexpected(
- std::move(membership_bind.error()));
- }
- auto membership_insert = connection_.execute(
- std::move(*membership));
- if(!membership_insert)
- {
- return std::unexpected(
- std::move(membership_insert.error()));
- }
+ return std::optional<AuthenticationChallenge>{};
}
- return {};
+ auto& [id, email, email_key, expires_at] = rows->front();
+ return AuthenticationChallenge{
+ id, std::move(email), std::move(email_key), expires_at};
}
- /// Delete a card and its dependent database rows.
- mw::E<void> deleteCard(
- std::int64_t card_id) override
+ /// Invalidate every other outstanding challenge for an email key.
+ mw::E<void> invalidateAuthenticationChallenges(
+ const std::string& email_key,
+ std::int64_t except_challenge_id,
+ std::int64_t now) override
{
auto statement = connection_.statementFromStr(
- "DELETE FROM cards WHERE id = ?;");
+ "UPDATE authentication_challenges SET consumed_at = ? "
+ "WHERE email_key = ? AND id != ? "
+ "AND delivered_at IS NOT NULL AND consumed_at IS NULL;");
if(!statement)
{
return std::unexpected(std::move(statement.error()));
}
- auto bind = statement->bind<std::int64_t>(card_id);
+ auto bind = statement->bind(
+ now, email_key, except_challenge_id);
if(!bind)
{
return std::unexpected(std::move(bind.error()));
@@ -557,24 +833,29 @@ public:
return connection_.execute(std::move(*statement));
}
- /// Insert a series and return its internal ID.
- mw::E<std::int64_t> insertSeries(
- const Series& series) override
+ /// Insert a four-week session and return its internal ID.
+ mw::E<std::int64_t> insertSession(
+ std::int64_t user_id,
+ const TokenHash& token_hash,
+ const std::string& csrf_token,
+ std::int64_t created_at,
+ std::int64_t expires_at) override
{
- if(series.id != 0)
+ auto bytes = tokenBytes(token_hash);
+ if(!bytes)
{
- return std::unexpected(mw::runtimeError(
- "A new series cannot already have an internal ID"));
+ return std::unexpected(std::move(bytes.error()));
}
auto statement = connection_.statementFromStr(
- "INSERT INTO series (game_short_name, name, description) "
- "VALUES (?, ?, ?);");
+ "INSERT INTO sessions(user_id, token_hash, csrf_token, "
+ "created_at, expires_at) "
+ "VALUES (?, CAST(? AS BLOB), ?, ?, ?);");
if(!statement)
{
return std::unexpected(std::move(statement.error()));
}
auto bind = statement->bind(
- series.game_short_name, series.name, series.description);
+ user_id, *bytes, csrf_token, created_at, expires_at);
if(!bind)
{
return std::unexpected(std::move(bind.error()));
@@ -587,22 +868,21 @@ public:
return connection_.lastInsertRowID();
}
- /// Replace a series name and description without changing its game.
- mw::E<void> updateSeries(
- const Series& series) override
+ /// Delete a session by its token digest.
+ mw::E<void> deleteSession(const TokenHash& token_hash) override
{
+ auto bytes = tokenBytes(token_hash);
+ if(!bytes)
+ {
+ return std::unexpected(std::move(bytes.error()));
+ }
auto statement = connection_.statementFromStr(
- "UPDATE series SET name = ?, description = ? "
- "WHERE id = ? AND game_short_name = ?;");
+ "DELETE FROM sessions WHERE token_hash = CAST(? AS BLOB);");
if(!statement)
{
return std::unexpected(std::move(statement.error()));
}
- auto bind = statement->bind(
- series.name,
- series.description,
- series.id,
- series.game_short_name);
+ auto bind = statement->bind<std::string>(*bytes);
if(!bind)
{
return std::unexpected(std::move(bind.error()));
@@ -610,93 +890,693 @@ public:
return connection_.execute(std::move(*statement));
}
- /// Delete a series and its membership rows.
- mw::E<void> deleteSeries(
- std::int64_t series_id) override
+ /// Persist a lazily refreshed pull state.
+ mw::E<void> updatePullState(
+ std::int64_t user_id,
+ std::uint32_t stored_pulls,
+ std::int64_t refresh_day) override
{
auto statement = connection_.statementFromStr(
- "DELETE FROM series WHERE id = ?;");
+ "UPDATE users SET stored_pulls = ?, pull_refresh_day = ? "
+ "WHERE id = ?;");
if(!statement)
{
return std::unexpected(std::move(statement.error()));
}
- auto bind = statement->bind<std::int64_t>(series_id);
+ auto bind = statement->bind(
+ static_cast<std::int64_t>(stored_pulls),
+ refresh_day,
+ user_id);
if(!bind)
{
return std::unexpected(std::move(bind.error()));
}
- return connection_.execute(std::move(*statement));
- }
-
- /// Commit the transaction and release its connection lock.
- mw::E<void> commit() override
- {
- if(committed_)
+ auto updated = connection_.execute(std::move(*statement));
+ if(!updated)
{
- return std::unexpected(mw::runtimeError(
- "Transaction has already been committed"));
+ return std::unexpected(std::move(updated.error()));
}
- auto result = connection_.execute("COMMIT;");
- if(!result)
+ if(connection_.changedRowsCount() != 1)
{
- return std::unexpected(std::move(result.error()));
+ return std::unexpected(mw::runtimeError(
+ "User disappeared while updating pull state"));
}
- committed_ = true;
- lock_.unlock();
return {};
}
-private:
- mw::SQLite& connection_;
- std::unique_lock<std::mutex> lock_;
- bool committed_ = false;
-};
-
-} // namespace
-
-DataSourceSQLite::DataSourceSQLite(
- std::unique_ptr<mw::SQLite> connection)
- : connection_(std::move(connection))
-{}
-
-mw::E<std::unique_ptr<DataSourceSQLite>> DataSourceSQLite::fromFile(
- const std::filesystem::path& database_path)
-{
- auto connection = mw::SQLite::connectFile(database_path.string());
- if(!connection)
- {
- return std::unexpected(std::move(connection.error()));
- }
- auto synchronous_result =
- (*connection)->execute("PRAGMA synchronous = NORMAL;");
- if(!synchronous_result)
- {
- return std::unexpected(std::move(synchronous_result.error()));
- }
- const std::array<std::string_view, 3> pragmas = {
- "PRAGMA foreign_keys = ON;",
- "PRAGMA journal_mode = WAL;",
- "PRAGMA busy_timeout = 5000;",
- };
- for(std::string_view pragma : pragmas)
+ /// Insert or increment a holding and return its new quantity.
+ mw::E<std::int64_t> incrementHolding(
+ std::int64_t user_id, std::int64_t card_id) override
{
- auto result = (*connection)->execute(std::string(pragma));
- if(!result)
+ auto statement = connection_.statementFromStr(
+ "INSERT INTO card_holdings(user_id, card_id, quantity) "
+ "VALUES (?, ?, 1) ON CONFLICT(user_id, card_id) DO UPDATE "
+ "SET quantity = quantity + 1 "
+ "WHERE quantity < 9223372036854775807 RETURNING quantity;");
+ if(!statement)
{
- return std::unexpected(std::move(result.error()));
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(user_id, card_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
}
+ auto rows = connection_.eval<std::int64_t>(std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ if(rows->size() != 1)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Card quantity cannot be incremented"));
+ }
+ return std::get<0>(rows->front());
}
- return std::unique_ptr<DataSourceSQLite>(
- new DataSourceSQLite(std::move(*connection)));
-}
-
-mw::E<std::int64_t> DataSourceSQLite::getSchemaVersion() const
-{
- std::lock_guard lock(mutex_);
- return connection_->evalToValue<std::int64_t>("PRAGMA user_version;");
-}
-mw::E<void> DataSourceSQLite::migrateSchema0To1(
+ /// Re-read a card while the transaction lock is held.
+ mw::E<std::optional<Card>> getCardForUpdate(
+ std::int64_t card_id) override
+ {
+ auto statement = connection_.statementFromStr(
+ "SELECT id, creator_user_id, game_short_name, card_number, name, "
+ "short_description, long_description, rarity, "
+ "front_extension, foil_extension, thumbnail_extension, "
+ "revision FROM cards WHERE id = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::int64_t>(card_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto rows = connection_.eval<
+ std::int64_t,
+ std::int64_t,
+ std::optional<std::string>,
+ std::int64_t,
+ std::string,
+ std::optional<std::string>,
+ std::optional<std::string>,
+ std::int64_t,
+ std::string,
+ std::optional<std::string>,
+ std::string,
+ std::int64_t>(std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ if(rows->empty())
+ {
+ return std::optional<Card>{};
+ }
+
+ auto& [
+ id,
+ creator_user_id,
+ game_short_name,
+ card_number,
+ name,
+ short_description,
+ long_description,
+ rarity,
+ front_extension,
+ foil_extension,
+ thumbnail_extension,
+ revision] = rows->front();
+ Card card = {
+ id,
+ {std::move(game_short_name),
+ static_cast<std::uint64_t>(card_number)},
+ std::move(name),
+ std::move(short_description),
+ std::move(long_description),
+ rarity,
+ std::move(front_extension),
+ std::move(foil_extension),
+ std::move(thumbnail_extension),
+ revision,
+ creator_user_id,
+ };
+ return std::optional<Card>(std::move(card));
+ }
+
+ /// Insert common and series-membership rows for a loose card.
+ mw::E<std::int64_t> insertCard(
+ const Card& card,
+ const GameDefinition* game,
+ const GameCardMetadata* metadata,
+ const std::vector<std::int64_t>& series_ids) override
+ {
+ if(card.id != 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "A new card cannot already have an internal ID"));
+ }
+ if((game == nullptr) != (metadata == nullptr))
+ {
+ return std::unexpected(mw::runtimeError(
+ "Game definition and metadata must be provided together"));
+ }
+ if(card.identity.game_short_name)
+ {
+ if(game == nullptr)
+ {
+ return std::unexpected(mw::runtimeError(
+ "A game card requires compiled game metadata"));
+ }
+ if(game->shortName() != *card.identity.game_short_name)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Card and compiled game identities differ"));
+ }
+ }
+ else if(game != nullptr || !series_ids.empty())
+ {
+ return std::unexpected(mw::runtimeError(
+ "A loose card cannot have game metadata or series"));
+ }
+ if(!card.identity.game_short_name &&
+ card.identity.card_number >
+ std::numeric_limits<std::uint32_t>::max())
+ {
+ return std::unexpected(mw::runtimeError(
+ "Loose card number exceeds the 32-bit namespace"));
+ }
+
+ auto insert = connection_.statementFromStr(
+ "INSERT INTO cards ("
+ "creator_user_id, game_short_name, card_number, name, "
+ "short_description, "
+ "long_description, rarity, front_extension, foil_extension, "
+ "thumbnail_extension, revision) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);");
+ if(!insert)
+ {
+ return std::unexpected(std::move(insert.error()));
+ }
+ auto bind = insert->bind(
+ card.creator_user_id,
+ card.identity.game_short_name,
+ static_cast<std::int64_t>(card.identity.card_number),
+ card.name,
+ card.short_description,
+ card.long_description,
+ card.rarity,
+ card.front_extension,
+ card.foil_extension,
+ card.thumbnail_extension,
+ card.revision);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto execute = connection_.execute(std::move(*insert));
+ if(!execute)
+ {
+ return std::unexpected(std::move(execute.error()));
+ }
+ const std::int64_t card_id = connection_.lastInsertRowID();
+
+ if(game != nullptr)
+ {
+ auto game_insert = game->insertMetadata(
+ connection_, card_id, *metadata);
+ if(!game_insert)
+ {
+ return std::unexpected(std::move(game_insert.error()));
+ }
+ }
+
+ for(std::int64_t series_id : series_ids)
+ {
+ auto membership = connection_.statementFromStr(
+ "INSERT INTO card_series (card_id, series_id) "
+ "VALUES (?, ?);");
+ if(!membership)
+ {
+ return std::unexpected(std::move(membership.error()));
+ }
+ auto membership_bind = membership->bind<std::int64_t,
+ std::int64_t>(
+ card_id, series_id);
+ if(!membership_bind)
+ {
+ return std::unexpected(
+ std::move(membership_bind.error()));
+ }
+ auto membership_execute = connection_.execute(
+ std::move(*membership));
+ if(!membership_execute)
+ {
+ return std::unexpected(
+ std::move(membership_execute.error()));
+ }
+ }
+ return card_id;
+ }
+
+ /// Replace card rows after validation by the service layer.
+ mw::E<void> updateCard(
+ const Card& card,
+ const GameDefinition* game,
+ const GameCardMetadata* metadata,
+ const std::vector<std::int64_t>& series_ids)
+ override
+ {
+ if(card.id <= 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "An updated card requires an internal ID"));
+ }
+ if((game == nullptr) != (metadata == nullptr))
+ {
+ return std::unexpected(mw::runtimeError(
+ "Game definition and metadata must be provided together"));
+ }
+ if(card.identity.game_short_name)
+ {
+ if(game == nullptr ||
+ game->shortName() != *card.identity.game_short_name)
+ {
+ return std::unexpected(mw::runtimeError(
+ "A game card requires its compiled game metadata"));
+ }
+ }
+ else if(game != nullptr || !series_ids.empty())
+ {
+ return std::unexpected(mw::runtimeError(
+ "A loose card cannot have game metadata or series"));
+ }
+
+ auto statement = connection_.statementFromStr(
+ "UPDATE cards SET name = ?, short_description = ?, "
+ "long_description = ?, rarity = ?, front_extension = ?, "
+ "foil_extension = ?, thumbnail_extension = ?, revision = ? "
+ "WHERE id = ? AND "
+ "((? IS NULL AND game_short_name IS NULL) OR "
+ "game_short_name = ?) AND card_number = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(
+ card.name,
+ card.short_description,
+ card.long_description,
+ card.rarity,
+ card.front_extension,
+ card.foil_extension,
+ card.thumbnail_extension,
+ card.revision,
+ card.id,
+ card.identity.game_short_name,
+ card.identity.game_short_name,
+ static_cast<std::int64_t>(card.identity.card_number));
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto update = connection_.execute(std::move(*statement));
+ if(!update)
+ {
+ return std::unexpected(std::move(update.error()));
+ }
+ auto changes = connection_.evalToValue<std::int64_t>(
+ "SELECT changes();");
+ if(!changes)
+ {
+ return std::unexpected(std::move(changes.error()));
+ }
+ if(*changes != 1)
+ {
+ return std::unexpected(mw::runtimeError(
+ "The card disappeared while it was being updated"));
+ }
+ if(game != nullptr)
+ {
+ auto game_update = game->updateMetadata(
+ connection_, card.id, *metadata);
+ if(!game_update)
+ {
+ return std::unexpected(std::move(game_update.error()));
+ }
+ }
+ auto delete_memberships = connection_.statementFromStr(
+ "DELETE FROM card_series WHERE card_id = ?;");
+ if(!delete_memberships)
+ {
+ return std::unexpected(std::move(delete_memberships.error()));
+ }
+ auto delete_bind = delete_memberships->bind<std::int64_t>(card.id);
+ if(!delete_bind)
+ {
+ return std::unexpected(std::move(delete_bind.error()));
+ }
+ auto deleted = connection_.execute(std::move(*delete_memberships));
+ if(!deleted)
+ {
+ return std::unexpected(std::move(deleted.error()));
+ }
+ for(std::int64_t series_id : series_ids)
+ {
+ auto membership = connection_.statementFromStr(
+ "INSERT INTO card_series (card_id, series_id) "
+ "VALUES (?, ?);");
+ if(!membership)
+ {
+ return std::unexpected(std::move(membership.error()));
+ }
+ auto membership_bind = membership->bind<std::int64_t,
+ std::int64_t>(
+ card.id, series_id);
+ if(!membership_bind)
+ {
+ return std::unexpected(
+ std::move(membership_bind.error()));
+ }
+ auto membership_insert = connection_.execute(
+ std::move(*membership));
+ if(!membership_insert)
+ {
+ return std::unexpected(
+ std::move(membership_insert.error()));
+ }
+ }
+ return {};
+ }
+
+ /// Delete a card and its dependent database rows.
+ mw::E<void> deleteCard(
+ std::int64_t card_id) override
+ {
+ auto statement = connection_.statementFromStr(
+ "DELETE FROM cards WHERE id = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::int64_t>(card_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ return connection_.execute(std::move(*statement));
+ }
+
+ /// Insert a series and return its internal ID.
+ mw::E<std::int64_t> insertSeries(
+ const Series& series) override
+ {
+ if(series.id != 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "A new series cannot already have an internal ID"));
+ }
+ auto statement = connection_.statementFromStr(
+ "INSERT INTO series (game_short_name, name, description) "
+ "VALUES (?, ?, ?);");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(
+ series.game_short_name, series.name, series.description);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto inserted = connection_.execute(std::move(*statement));
+ if(!inserted)
+ {
+ return std::unexpected(std::move(inserted.error()));
+ }
+ return connection_.lastInsertRowID();
+ }
+
+ /// Replace a series name and description without changing its game.
+ mw::E<void> updateSeries(
+ const Series& series) override
+ {
+ auto statement = connection_.statementFromStr(
+ "UPDATE series SET name = ?, description = ? "
+ "WHERE id = ? AND game_short_name = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(
+ series.name,
+ series.description,
+ series.id,
+ series.game_short_name);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ return connection_.execute(std::move(*statement));
+ }
+
+ /// Delete a series and its membership rows.
+ mw::E<void> deleteSeries(
+ std::int64_t series_id) override
+ {
+ auto statement = connection_.statementFromStr(
+ "DELETE FROM series WHERE id = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::int64_t>(series_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ return connection_.execute(std::move(*statement));
+ }
+
+ /// Commit the transaction and release its connection lock.
+ mw::E<void> commit() override
+ {
+ if(committed_)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Transaction has already been committed"));
+ }
+ auto result = connection_.execute("COMMIT;");
+ if(!result)
+ {
+ return std::unexpected(std::move(result.error()));
+ }
+ committed_ = true;
+ lock_.unlock();
+ return {};
+ }
+
+private:
+ mw::E<std::optional<User>> readUser(mw::SQLiteStatement statement)
+ {
+ auto rows = connection_.eval<
+ std::int64_t,
+ std::string,
+ std::string,
+ std::optional<std::string>,
+ std::int64_t,
+ std::int64_t,
+ std::int64_t,
+ std::int64_t>(std::move(statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ if(rows->empty())
+ {
+ return std::optional<User>{};
+ }
+ auto& [id, email, email_key, username, role_value, stored_pulls,
+ refresh_day, created_at] = rows->front();
+ auto role = userRole(role_value);
+ if(!role || stored_pulls < 0 ||
+ stored_pulls > std::numeric_limits<std::uint32_t>::max())
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database contains an invalid user record"));
+ }
+ return User{
+ id,
+ std::move(email),
+ std::move(email_key),
+ std::move(username),
+ *role,
+ static_cast<std::uint32_t>(stored_pulls),
+ refresh_day,
+ created_at};
+ }
+
+ mw::E<std::vector<Card>> readCards(const std::string& suffix)
+ {
+ auto rows = connection_.eval<
+ std::int64_t,
+ std::int64_t,
+ std::optional<std::string>,
+ std::int64_t,
+ std::string,
+ std::optional<std::string>,
+ std::optional<std::string>,
+ std::int64_t,
+ std::string,
+ std::optional<std::string>,
+ std::string,
+ std::int64_t>(
+ "SELECT id, creator_user_id, game_short_name, card_number, "
+ "name, short_description, long_description, rarity, "
+ "front_extension, foil_extension, thumbnail_extension, "
+ "revision FROM cards " + suffix + ";");
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ std::vector<Card> result;
+ result.reserve(rows->size());
+ for(auto& [id, creator_user_id, game_short_name, card_number, name,
+ short_description, long_description, rarity,
+ front_extension, foil_extension, thumbnail_extension,
+ revision] : *rows)
+ {
+ result.push_back({
+ id,
+ {std::move(game_short_name),
+ static_cast<std::uint64_t>(card_number)},
+ std::move(name),
+ std::move(short_description),
+ std::move(long_description),
+ rarity,
+ std::move(front_extension),
+ std::move(foil_extension),
+ std::move(thumbnail_extension),
+ revision,
+ creator_user_id});
+ }
+ return result;
+ }
+
+ mw::E<std::int64_t> emailRetryAfter(
+ const std::string& email_key, std::int64_t now)
+ {
+ auto statement = connection_.statementFromStr(
+ "SELECT next_allowed_at FROM authentication_email_limits "
+ "WHERE email_key = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::string>(email_key);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto next = connection_.evalToValue<std::int64_t>(
+ std::move(*statement));
+ if(!next)
+ {
+ return std::unexpected(std::move(next.error()));
+ }
+ return std::max<std::int64_t>(1, *next - now);
+ }
+
+ void rollbackReservationState()
+ {
+ auto rolled_back = connection_.execute(
+ "ROLLBACK TO email_reservation;");
+ auto released = connection_.execute("RELEASE email_reservation;");
+ if(!rolled_back || !released)
+ {
+ spdlog::error("Failed to roll back email quota reservation");
+ }
+ }
+
+ mw::E<AuthenticationReservation> rollbackReservation(mw::Error error)
+ {
+ rollbackReservationState();
+ return std::unexpected(std::move(error));
+ }
+
+ mw::SQLite& connection_;
+ std::unique_lock<std::mutex> lock_;
+ bool committed_ = false;
+};
+
+} // namespace
+
+DataSourceSQLite::DataSourceSQLite(
+ std::unique_ptr<mw::SQLite> connection)
+ : connection_(std::move(connection))
+{}
+
+mw::E<std::unique_ptr<DataSourceSQLite>> DataSourceSQLite::fromFile(
+ const std::filesystem::path& database_path)
+{
+ auto connection = mw::SQLite::connectFile(database_path.string());
+ if(!connection)
+ {
+ return std::unexpected(std::move(connection.error()));
+ }
+ auto synchronous_result =
+ (*connection)->execute("PRAGMA synchronous = NORMAL;");
+ if(!synchronous_result)
+ {
+ return std::unexpected(std::move(synchronous_result.error()));
+ }
+ const std::array<std::string_view, 3> pragmas = {
+ "PRAGMA foreign_keys = ON;",
+ "PRAGMA journal_mode = WAL;",
+ "PRAGMA busy_timeout = 5000;",
+ };
+ for(std::string_view pragma : pragmas)
+ {
+ auto result = (*connection)->execute(std::string(pragma));
+ if(!result)
+ {
+ return std::unexpected(std::move(result.error()));
+ }
+ }
+ return std::unique_ptr<DataSourceSQLite>(
+ new DataSourceSQLite(std::move(*connection)));
+}
+
+mw::E<std::int64_t> DataSourceSQLite::getSchemaVersion() const
+{
+ std::lock_guard lock(mutex_);
+ auto version = connection_->evalToValue<std::int64_t>(
+ "PRAGMA user_version;");
+ if(!version)
+ {
+ return std::unexpected(std::move(version.error()));
+ }
+ if(*version == 1)
+ {
+ auto users = connection_->evalToValue<int>(
+ "SELECT EXISTS(SELECT 1 FROM sqlite_schema "
+ "WHERE type = 'table' AND name = 'users');");
+ if(!users)
+ {
+ return std::unexpected(std::move(users.error()));
+ }
+ if(*users == 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database uses the obsolete prototype schema version 1; "
+ "delete and recreate this unreleased development database"));
+ }
+ }
+ return *version;
+}
+
+mw::E<void> DataSourceSQLite::migrateSchema0To1(
const GameRegistry& games)
{
std::lock_guard lock(mutex_);
@@ -767,6 +1647,7 @@ mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
{
std::lock_guard lock(mutex_);
auto rows = connection_->eval<
+ std::int64_t,
std::int64_t,
std::optional<std::string>,
std::int64_t,
@@ -778,7 +1659,7 @@ mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
std::optional<std::string>,
std::string,
std::int64_t>(
- "SELECT id, game_short_name, card_number, name, "
+ "SELECT id, creator_user_id, game_short_name, card_number, name, "
"short_description, long_description, rarity, "
"front_extension, foil_extension, thumbnail_extension, "
"revision FROM cards ORDER BY id;");
@@ -793,6 +1674,7 @@ mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
{
auto& [
id,
+ creator_user_id,
game_short_name,
card_number,
name,
@@ -828,9 +1710,60 @@ mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
std::move(foil_extension),
std::move(thumbnail_extension),
revision,
+ creator_user_id,
});
}
- return cards;
+ return cards;
+}
+
+mw::E<std::vector<Card>> DataSourceSQLite::getCardsByCreator(
+ std::int64_t creator_user_id) const
+{
+ std::lock_guard lock(mutex_);
+ auto statement = connection_->statementFromStr(
+ "SELECT id, creator_user_id, game_short_name, card_number, name, "
+ "short_description, long_description, rarity, front_extension, "
+ "foil_extension, thumbnail_extension, revision FROM cards "
+ "WHERE creator_user_id = ? ORDER BY id;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::int64_t>(creator_user_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto rows = connection_->eval<
+ std::int64_t, std::int64_t, std::optional<std::string>,
+ std::int64_t, std::string, std::optional<std::string>,
+ std::optional<std::string>, std::int64_t, std::string,
+ std::optional<std::string>, std::string, std::int64_t>(
+ std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ return cardsFromRows(std::move(*rows));
+}
+
+mw::E<std::vector<Card>> DataSourceSQLite::getPoolCards() const
+{
+ std::lock_guard lock(mutex_);
+ auto rows = connection_->eval<
+ std::int64_t, std::int64_t, std::optional<std::string>,
+ std::int64_t, std::string, std::optional<std::string>,
+ std::optional<std::string>, std::int64_t, std::string,
+ std::optional<std::string>, std::string, std::int64_t>(
+ "SELECT id, creator_user_id, game_short_name, card_number, "
+ "name, short_description, long_description, rarity, "
+ "front_extension, foil_extension, thumbnail_extension, "
+ "revision FROM cards WHERE rarity > 0 ORDER BY id;");
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ return cardsFromRows(std::move(*rows));
}
mw::E<std::optional<Card>> DataSourceSQLite::getCard(
@@ -838,7 +1771,7 @@ mw::E<std::optional<Card>> DataSourceSQLite::getCard(
{
std::lock_guard lock(mutex_);
auto statement = connection_->statementFromStr(
- "SELECT id, game_short_name, card_number, name, "
+ "SELECT id, creator_user_id, game_short_name, card_number, name, "
"short_description, long_description, rarity, "
"front_extension, foil_extension, thumbnail_extension, "
"revision FROM cards WHERE "
@@ -857,6 +1790,7 @@ mw::E<std::optional<Card>> DataSourceSQLite::getCard(
return std::unexpected(std::move(bind.error()));
}
auto rows = connection_->eval<
+ std::int64_t,
std::int64_t,
std::optional<std::string>,
std::int64_t,
@@ -879,6 +1813,7 @@ mw::E<std::optional<Card>> DataSourceSQLite::getCard(
auto& [
id,
+ creator_user_id,
game_short_name,
card_number,
name,
@@ -901,10 +1836,490 @@ mw::E<std::optional<Card>> DataSourceSQLite::getCard(
std::move(foil_extension),
std::move(thumbnail_extension),
revision,
+ creator_user_id,
};
return std::optional<Card>(std::move(card));
}
+mw::E<std::optional<User>> DataSourceSQLite::getUser(
+ std::int64_t user_id) const
+{
+ std::lock_guard lock(mutex_);
+ auto statement = connection_->statementFromStr(
+ "SELECT id, email, email_key, username, role, stored_pulls, "
+ "pull_refresh_day, created_at FROM users WHERE id = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::int64_t>(user_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto rows = connection_->eval<
+ std::int64_t, std::string, std::string,
+ std::optional<std::string>, std::int64_t, std::int64_t,
+ std::int64_t, std::int64_t>(std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ if(rows->empty())
+ {
+ return std::optional<User>{};
+ }
+ auto user = userFromRow(std::move(rows->front()));
+ if(!user)
+ {
+ return std::unexpected(std::move(user.error()));
+ }
+ return std::optional<User>(std::move(*user));
+}
+
+mw::E<std::optional<User>> DataSourceSQLite::getUserByEmailKey(
+ const std::string& email_key) const
+{
+ std::lock_guard lock(mutex_);
+ auto statement = connection_->statementFromStr(
+ "SELECT id, email, email_key, username, role, stored_pulls, "
+ "pull_refresh_day, created_at FROM users WHERE email_key = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::string>(email_key);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto rows = connection_->eval<
+ std::int64_t, std::string, std::string,
+ std::optional<std::string>, std::int64_t, std::int64_t,
+ std::int64_t, std::int64_t>(std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ if(rows->empty())
+ {
+ return std::optional<User>{};
+ }
+ auto user = userFromRow(std::move(rows->front()));
+ if(!user)
+ {
+ return std::unexpected(std::move(user.error()));
+ }
+ return std::optional<User>(std::move(*user));
+}
+
+mw::E<std::vector<User>> DataSourceSQLite::getUsers() const
+{
+ std::lock_guard lock(mutex_);
+ auto rows = connection_->eval<
+ std::int64_t, std::string, std::string,
+ std::optional<std::string>, std::int64_t, std::int64_t,
+ std::int64_t, std::int64_t>(
+ "SELECT id, email, email_key, username, role, stored_pulls, "
+ "pull_refresh_day, created_at FROM users ORDER BY id;");
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ std::vector<User> users;
+ users.reserve(rows->size());
+ for(UserRow& row : *rows)
+ {
+ auto user = userFromRow(std::move(row));
+ if(!user)
+ {
+ return std::unexpected(std::move(user.error()));
+ }
+ users.push_back(std::move(*user));
+ }
+ return users;
+}
+
+mw::E<std::optional<SessionContext>> DataSourceSQLite::getSession(
+ const TokenHash& token_hash, std::int64_t now) const
+{
+ auto bytes = tokenBytes(token_hash);
+ if(!bytes)
+ {
+ return std::unexpected(std::move(bytes.error()));
+ }
+ std::lock_guard lock(mutex_);
+ auto statement = connection_->statementFromStr(
+ "SELECT s.id, u.id, u.email, u.email_key, u.username, u.role, "
+ "u.stored_pulls, u.pull_refresh_day, u.created_at, s.csrf_token, "
+ "s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id "
+ "WHERE s.token_hash = CAST(? AS BLOB) AND s.expires_at > ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(*bytes, now);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto rows = connection_->eval<
+ std::int64_t, std::int64_t, std::string, std::string,
+ std::optional<std::string>, std::int64_t, std::int64_t,
+ std::int64_t, std::int64_t, std::string, std::int64_t>(
+ std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ if(rows->empty())
+ {
+ return std::optional<SessionContext>{};
+ }
+ auto& [session_id, user_id, email, email_key, username, role_value,
+ stored_pulls, refresh_day, created_at, csrf_token,
+ expires_at] = rows->front();
+ auto user = userFromRow(UserRow{
+ user_id,
+ std::move(email),
+ std::move(email_key),
+ std::move(username),
+ role_value,
+ stored_pulls,
+ refresh_day,
+ created_at});
+ if(!user)
+ {
+ return std::unexpected(std::move(user.error()));
+ }
+ return SessionContext{
+ session_id, std::move(*user), std::move(csrf_token), expires_at};
+}
+
+mw::E<std::optional<AuthenticationChallenge>>
+DataSourceSQLite::getAuthenticationChallenge(
+ const TokenHash& token_hash, std::int64_t now) const
+{
+ auto bytes = tokenBytes(token_hash);
+ if(!bytes)
+ {
+ return std::unexpected(std::move(bytes.error()));
+ }
+ std::lock_guard lock(mutex_);
+ auto statement = connection_->statementFromStr(
+ "SELECT id, email, email_key, expires_at "
+ "FROM authentication_challenges "
+ "WHERE token_hash = CAST(? AS BLOB) "
+ "AND delivered_at IS NOT NULL AND consumed_at IS NULL "
+ "AND expires_at > ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(*bytes, now);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto rows = connection_->eval<
+ std::int64_t, std::string, std::string, std::int64_t>(
+ std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ if(rows->empty())
+ {
+ return std::optional<AuthenticationChallenge>{};
+ }
+ auto& [id, email, email_key, expires_at] = rows->front();
+ return AuthenticationChallenge{
+ id, std::move(email), std::move(email_key), expires_at};
+}
+
+mw::E<std::vector<CollectionEntry>> DataSourceSQLite::getCollection(
+ std::int64_t user_id) const
+{
+ std::lock_guard lock(mutex_);
+ auto statement = connection_->statementFromStr(
+ "SELECT c.id, c.creator_user_id, c.game_short_name, c.card_number, "
+ "c.name, c.short_description, c.long_description, c.rarity, "
+ "c.front_extension, c.foil_extension, c.thumbnail_extension, "
+ "c.revision, h.quantity FROM card_holdings h "
+ "JOIN cards c ON c.id = h.card_id WHERE h.user_id = ? "
+ "ORDER BY c.id;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::int64_t>(user_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto rows = connection_->eval<
+ std::int64_t, std::int64_t, std::optional<std::string>,
+ std::int64_t, std::string, std::optional<std::string>,
+ std::optional<std::string>, std::int64_t, std::string,
+ std::optional<std::string>, std::string, std::int64_t,
+ std::int64_t>(std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ std::vector<CollectionEntry> result;
+ result.reserve(rows->size());
+ for(auto& [id, creator_user_id, game_short_name, card_number, name,
+ short_description, long_description, rarity, front_extension,
+ foil_extension, thumbnail_extension, revision,
+ quantity] : *rows)
+ {
+ if(quantity <= 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database contains an invalid holding"));
+ }
+ auto card = cardFromRow(CardRow{
+ id,
+ creator_user_id,
+ std::move(game_short_name),
+ card_number,
+ std::move(name),
+ std::move(short_description),
+ std::move(long_description),
+ rarity,
+ std::move(front_extension),
+ std::move(foil_extension),
+ std::move(thumbnail_extension),
+ revision});
+ if(!card)
+ {
+ return std::unexpected(std::move(card.error()));
+ }
+ result.push_back({std::move(*card), quantity});
+ }
+ return result;
+}
+
+mw::E<bool> DataSourceSQLite::userOwnsCard(
+ std::int64_t user_id, std::int64_t card_id) const
+{
+ std::lock_guard lock(mutex_);
+ auto statement = connection_->statementFromStr(
+ "SELECT EXISTS(SELECT 1 FROM card_holdings "
+ "WHERE user_id = ? AND card_id = ?);");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(user_id, card_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto result = connection_->evalToValue<int>(std::move(*statement));
+ if(!result)
+ {
+ return std::unexpected(std::move(result.error()));
+ }
+ return *result != 0;
+}
+
+mw::E<User> DataSourceSQLite::reconcileAdministrator(
+ const std::string& email,
+ const std::string& email_key,
+ std::int64_t created_at,
+ std::int64_t pull_refresh_day)
+{
+ std::lock_guard lock(mutex_);
+ const auto rollback = [this](mw::Error error) -> mw::E<User>
+ {
+ auto result = connection_->execute("ROLLBACK;");
+ if(!result)
+ {
+ spdlog::error(
+ "Failed to roll back administrator reconciliation: {}",
+ result.error().msg());
+ }
+ return std::unexpected(std::move(error));
+ };
+ auto begin = connection_->execute("BEGIN IMMEDIATE;");
+ if(!begin)
+ {
+ return std::unexpected(std::move(begin.error()));
+ }
+ auto metadata = connection_->eval<std::string>(
+ "SELECT value FROM application_metadata "
+ "WHERE key = 'administrator_email_key';");
+ if(!metadata)
+ {
+ return rollback(std::move(metadata.error()));
+ }
+ if(!metadata->empty() &&
+ std::get<0>(metadata->front()) != email_key)
+ {
+ return rollback(mw::runtimeError(
+ "Configured administrator email differs from the "
+ "initialized database"));
+ }
+ if(metadata->empty())
+ {
+ auto statement = connection_->statementFromStr(
+ "INSERT INTO application_metadata(key, value) "
+ "VALUES ('administrator_email_key', ?);");
+ if(!statement)
+ {
+ return rollback(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::string>(email_key);
+ if(!bind)
+ {
+ return rollback(std::move(bind.error()));
+ }
+ auto inserted = connection_->execute(std::move(*statement));
+ if(!inserted)
+ {
+ return rollback(std::move(inserted.error()));
+ }
+ }
+
+ auto administrator_rows = connection_->eval<
+ std::int64_t, std::string, std::string,
+ std::optional<std::string>, std::int64_t, std::int64_t,
+ std::int64_t, std::int64_t>(
+ "SELECT id, email, email_key, username, role, stored_pulls, "
+ "pull_refresh_day, created_at FROM users WHERE role = 2;");
+ if(!administrator_rows)
+ {
+ return rollback(std::move(administrator_rows.error()));
+ }
+ if(!administrator_rows->empty() &&
+ std::get<2>(administrator_rows->front()) != email_key)
+ {
+ return rollback(mw::runtimeError(
+ "Database administrator account has an unexpected email"));
+ }
+ if(administrator_rows->empty())
+ {
+ auto insert = connection_->statementFromStr(
+ "INSERT INTO users(email, email_key, username, username_key, "
+ "role, stored_pulls, pull_refresh_day, created_at) "
+ "VALUES (?, ?, NULL, NULL, 2, 1, ?, ?);");
+ if(!insert)
+ {
+ return rollback(std::move(insert.error()));
+ }
+ auto bind = insert->bind(
+ email, email_key, pull_refresh_day, created_at);
+ if(!bind)
+ {
+ return rollback(std::move(bind.error()));
+ }
+ auto inserted = connection_->execute(std::move(*insert));
+ if(!inserted)
+ {
+ return rollback(std::move(inserted.error()));
+ }
+ administrator_rows = connection_->eval<
+ std::int64_t, std::string, std::string,
+ std::optional<std::string>, std::int64_t, std::int64_t,
+ std::int64_t, std::int64_t>(
+ "SELECT id, email, email_key, username, role, stored_pulls, "
+ "pull_refresh_day, created_at FROM users WHERE role = 2;");
+ if(!administrator_rows)
+ {
+ return rollback(std::move(administrator_rows.error()));
+ }
+ }
+ if(administrator_rows->size() != 1)
+ {
+ return rollback(mw::runtimeError(
+ "Database must contain one administrator"));
+ }
+ auto administrator = userFromRow(
+ std::move(administrator_rows->front()));
+ if(!administrator)
+ {
+ return rollback(std::move(administrator.error()));
+ }
+ auto commit = connection_->execute("COMMIT;");
+ if(!commit)
+ {
+ return rollback(std::move(commit.error()));
+ }
+ return std::move(*administrator);
+}
+
+mw::E<void> DataSourceSQLite::cleanupAuthentication(std::int64_t now)
+{
+ std::lock_guard lock(mutex_);
+ auto statement = connection_->statementFromStr(
+ "DELETE FROM sessions WHERE expires_at <= ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::int64_t>(now);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto sessions = connection_->execute(std::move(*statement));
+ if(!sessions)
+ {
+ return std::unexpected(std::move(sessions.error()));
+ }
+ const std::int64_t cutoff = now - 86400;
+ auto challenges = connection_->statementFromStr(
+ "DELETE FROM authentication_challenges "
+ "WHERE (consumed_at IS NOT NULL AND consumed_at < ?) "
+ "OR (consumed_at IS NULL AND expires_at < ?);");
+ if(!challenges)
+ {
+ return std::unexpected(std::move(challenges.error()));
+ }
+ auto challenge_bind = challenges->bind(cutoff, cutoff);
+ if(!challenge_bind)
+ {
+ return std::unexpected(std::move(challenge_bind.error()));
+ }
+ auto challenge_delete = connection_->execute(std::move(*challenges));
+ if(!challenge_delete)
+ {
+ return std::unexpected(std::move(challenge_delete.error()));
+ }
+ auto limits = connection_->statementFromStr(
+ "DELETE FROM authentication_email_limits "
+ "WHERE next_allowed_at < ?;");
+ if(!limits)
+ {
+ return std::unexpected(std::move(limits.error()));
+ }
+ auto limit_bind = limits->bind<std::int64_t>(cutoff);
+ if(!limit_bind)
+ {
+ return std::unexpected(std::move(limit_bind.error()));
+ }
+ auto limit_delete = connection_->execute(std::move(*limits));
+ if(!limit_delete)
+ {
+ return std::unexpected(std::move(limit_delete.error()));
+ }
+ const std::int64_t day = now >= 0 ? now / 86400 : 0;
+ auto quota = connection_->statementFromStr(
+ "DELETE FROM authentication_quota WHERE utc_day < ?;");
+ if(!quota)
+ {
+ return std::unexpected(std::move(quota.error()));
+ }
+ auto quota_bind = quota->bind<std::int64_t>(day - 1);
+ if(!quota_bind)
+ {
+ return std::unexpected(std::move(quota_bind.error()));
+ }
+ return connection_->execute(std::move(*quota));
+}
+
mw::E<std::vector<DisplayField>>
DataSourceSQLite::getGameDisplayFields(
const GameDefinition& game,
diff --git a/src/data_sqlite.h b/src/data_sqlite.h
index aca4c42..0b51464 100644
--- a/src/data_sqlite.h
+++ b/src/data_sqlite.h
@@ -39,10 +39,55 @@ public:
/// Return all cards for the unpaginated index.
mw::E<std::vector<Card>> getCards() const override;
+ /// Return all cards authored by one user.
+ mw::E<std::vector<Card>> getCardsByCreator(
+ std::int64_t creator_user_id) const override;
+
+ /// Return every positive-rarity card in card-ID order.
+ mw::E<std::vector<Card>> getPoolCards() const override;
+
/// Return a card by its parsed identity.
mw::E<std::optional<Card>>
getCard(const CardIdentity& identity) const override;
+ /// Return a user by internal identity.
+ mw::E<std::optional<User>> getUser(
+ std::int64_t user_id) const override;
+
+ /// Return a user by normalized immutable email identity.
+ mw::E<std::optional<User>> getUserByEmailKey(
+ const std::string& email_key) const override;
+
+ /// Return every user for administrator management.
+ mw::E<std::vector<User>> getUsers() const override;
+
+ /// Return a valid session joined with its current user.
+ mw::E<std::optional<SessionContext>> getSession(
+ const TokenHash& token_hash, std::int64_t now) const override;
+
+ /// Read one valid delivered challenge without consuming it.
+ mw::E<std::optional<AuthenticationChallenge>>
+ getAuthenticationChallenge(
+ const TokenHash& token_hash, std::int64_t now) const override;
+
+ /// Return a user's distinct collection entries.
+ mw::E<std::vector<CollectionEntry>> getCollection(
+ std::int64_t user_id) const override;
+
+ /// Return whether a user currently owns a card.
+ mw::E<bool> userOwnsCard(
+ std::int64_t user_id, std::int64_t card_id) const override;
+
+ /// Reconcile the one immutable configured administrator identity.
+ mw::E<User> reconcileAdministrator(
+ const std::string& email,
+ const std::string& email_key,
+ std::int64_t created_at,
+ std::int64_t pull_refresh_day) override;
+
+ /// Best-effort removal of obsolete authentication rows.
+ mw::E<void> cleanupAuthentication(std::int64_t now) override;
+
/// Return a card's game-owned display fields.
mw::E<std::vector<DisplayField>> getGameDisplayFields(
const GameDefinition& game,
diff --git a/src/email_address.cpp b/src/email_address.cpp
new file mode 100644
index 0000000..f8e5663
--- /dev/null
+++ b/src/email_address.cpp
@@ -0,0 +1,101 @@
+#include "email_address.h"
+
+#include <algorithm>
+#include <cctype>
+#include <ranges>
+#include <string>
+#include <string_view>
+
+namespace
+{
+
+bool isAsciiWhitespace(unsigned char value)
+{
+ return value == ' ' || value == '\t' || value == '\n' ||
+ value == '\r' || value == '\f' || value == '\v';
+}
+
+bool isLocalAtom(unsigned char value)
+{
+ return std::isalnum(value) != 0 ||
+ std::string_view("!#$%&'*+-/=?^_`{|}~").find(
+ static_cast<char>(value)) != std::string_view::npos;
+}
+
+bool isDomainLabel(const std::string& label)
+{
+ if(label.empty() || label.size() > 63 || label.front() == '-' ||
+ label.back() == '-')
+ {
+ return false;
+ }
+ return std::ranges::all_of(label, [](unsigned char value)
+ {
+ return std::isalnum(value) != 0 || value == '-';
+ });
+}
+
+} // namespace
+
+mw::E<EmailAddress> normalizeEmail(const std::string& input)
+{
+ const auto first = std::ranges::find_if_not(input, isAsciiWhitespace);
+ const auto last = std::ranges::find_if_not(
+ input | std::views::reverse, isAsciiWhitespace).base();
+ const std::string email = first < last ? std::string(first, last) : "";
+ if(email.empty() || email.size() > 254)
+ {
+ return std::unexpected(mw::runtimeError("Invalid email address"));
+ }
+ for(unsigned char value : email)
+ {
+ if(value > 0x7f || value == 0 || value < 0x20 || value == 0x7f)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Invalid email address"));
+ }
+ }
+
+ const std::size_t at = email.find('@');
+ if(at == std::string::npos || at == 0 ||
+ at != email.rfind('@') || at > 64 || at + 1 == email.size())
+ {
+ return std::unexpected(mw::runtimeError("Invalid email address"));
+ }
+ const std::string local = email.substr(0, at);
+ const std::string domain = email.substr(at + 1);
+ if(domain.size() > 253 || local.front() == '.' || local.back() == '.' ||
+ local.find("..") != std::string::npos ||
+ !std::ranges::all_of(local, [](unsigned char value)
+ {
+ return value == '.' || isLocalAtom(value);
+ }))
+ {
+ return std::unexpected(mw::runtimeError("Invalid email address"));
+ }
+
+ std::size_t begin = 0;
+ while(begin <= domain.size())
+ {
+ const std::size_t end = domain.find('.', begin);
+ const std::string label = domain.substr(
+ begin, end == std::string::npos ? end : end - begin);
+ if(!isDomainLabel(label))
+ {
+ return std::unexpected(mw::runtimeError(
+ "Invalid email address"));
+ }
+ if(end == std::string::npos)
+ {
+ break;
+ }
+ begin = end + 1;
+ }
+
+ std::string key = email;
+ std::ranges::transform(key, key.begin(), [](unsigned char value)
+ {
+ return static_cast<char>(std::tolower(value));
+ });
+ return EmailAddress{email, std::move(key)};
+}
diff --git a/src/email_address.h b/src/email_address.h
new file mode 100644
index 0000000..c60db90
--- /dev/null
+++ b/src/email_address.h
@@ -0,0 +1,18 @@
+#pragma once
+
+#include <string>
+
+#include <mw/error.hpp>
+
+/// Validated email spelling and normalized identity key.
+struct EmailAddress
+{
+ /// Trimmed ASCII address retained for delivery.
+ std::string email;
+
+ /// Lowercase identity used for uniqueness and rate limiting.
+ std::string key;
+};
+
+/// Validate and normalize an MVP ASCII email address.
+mw::E<EmailAddress> normalizeEmail(const std::string& input);
diff --git a/src/email_sender.h b/src/email_sender.h
new file mode 100644
index 0000000..8488e83
--- /dev/null
+++ b/src/email_sender.h
@@ -0,0 +1,33 @@
+#pragma once
+
+#include <chrono>
+#include <string>
+
+#include <mw/error.hpp>
+#include <mw/url.hpp>
+
+/// Contents needed to send one passwordless authentication email.
+struct AuthenticationEmail
+{
+ /// Validated recipient address.
+ std::string recipient;
+
+ /// Absolute single-use confirmation URL.
+ mw::URL confirmation_url;
+
+ /// Challenge expiration instant.
+ std::chrono::system_clock::time_point expires_at;
+};
+
+/// Configured delivery mechanism for passwordless authentication links.
+class EmailSenderInterface
+{
+public:
+ virtual ~EmailSenderInterface() = default;
+
+ /// Deliver one authentication link or return a non-secret error.
+ virtual mw::E<void> send(const AuthenticationEmail& email) = 0;
+
+ /// Return whether sends must reserve the global production quota.
+ virtual bool usesGlobalQuota() const = 0;
+};
diff --git a/src/email_sender_file.cpp b/src/email_sender_file.cpp
new file mode 100644
index 0000000..f6b60bc
--- /dev/null
+++ b/src/email_sender_file.cpp
@@ -0,0 +1,76 @@
+#include "email_sender_file.h"
+
+#include <atomic>
+#include <cerrno>
+#include <cstdint>
+#include <cstring>
+#include <filesystem>
+#include <fcntl.h>
+#include <string>
+#include <system_error>
+#include <unistd.h>
+
+FileEmailSender::FileEmailSender(std::filesystem::path link_file)
+ : link_file_(std::move(link_file))
+{}
+
+mw::E<void> FileEmailSender::send(const AuthenticationEmail& email)
+{
+ static std::atomic<std::uint64_t> next_temporary_id = 0;
+ const std::uint64_t suffix = next_temporary_id.fetch_add(
+ 1, std::memory_order_relaxed);
+ std::filesystem::path temporary = link_file_;
+ temporary += ".tmp-" + std::to_string(::getpid()) + '-' +
+ std::to_string(suffix);
+
+ const int file = ::open(
+ temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0600);
+ if(file < 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to write authentication link"));
+ }
+ const std::string contents = email.confirmation_url.str() + "\n";
+ std::size_t written = 0;
+ while(written < contents.size())
+ {
+ const ssize_t count = ::write(
+ file, contents.data() + written, contents.size() - written);
+ if(count < 0 && errno == EINTR)
+ {
+ continue;
+ }
+ if(count <= 0)
+ {
+ ::close(file);
+ std::error_code ignored;
+ std::filesystem::remove(temporary, ignored);
+ return std::unexpected(mw::runtimeError(
+ "Failed to write authentication link"));
+ }
+ written += static_cast<std::size_t>(count);
+ }
+ if(::fsync(file) != 0 || ::close(file) != 0)
+ {
+ std::error_code ignored;
+ std::filesystem::remove(temporary, ignored);
+ return std::unexpected(mw::runtimeError(
+ "Failed to write authentication link"));
+ }
+
+ std::error_code error;
+ std::filesystem::rename(temporary, link_file_, error);
+ if(error)
+ {
+ std::error_code ignored;
+ std::filesystem::remove(temporary, ignored);
+ return std::unexpected(mw::runtimeError(
+ "Failed to publish authentication link"));
+ }
+ return {};
+}
+
+bool FileEmailSender::usesGlobalQuota() const
+{
+ return false;
+}
diff --git a/src/email_sender_file.h b/src/email_sender_file.h
new file mode 100644
index 0000000..2b342ed
--- /dev/null
+++ b/src/email_sender_file.h
@@ -0,0 +1,22 @@
+#pragma once
+
+#include <filesystem>
+
+#include "email_sender.h"
+
+/// Development sender that atomically publishes the latest link to a file.
+class FileEmailSender final : public EmailSenderInterface
+{
+public:
+ /// Store the absolute target whose existing parent receives temp files.
+ explicit FileEmailSender(std::filesystem::path link_file);
+
+ /// Atomically write the latest absolute confirmation URL.
+ mw::E<void> send(const AuthenticationEmail& email) override;
+
+ /// File delivery does not consume the Mailjet quota.
+ bool usesGlobalQuota() const override;
+
+private:
+ std::filesystem::path link_file_;
+};
diff --git a/src/email_sender_mailjet.cpp b/src/email_sender_mailjet.cpp
new file mode 100644
index 0000000..3a595ad
--- /dev/null
+++ b/src/email_sender_mailjet.cpp
@@ -0,0 +1,176 @@
+#include "email_sender_mailjet.h"
+
+#include <chrono>
+#include <cstddef>
+#include <memory>
+#include <span>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+#include <mw/http_client.hpp>
+#include <mw/utils.hpp>
+
+namespace
+{
+
+constexpr std::string_view MAILJET_SEND_URL =
+ "https://api.mailjet.com/v3.1/send";
+
+std::string htmlEscape(const std::string& input)
+{
+ std::string result;
+ result.reserve(input.size());
+ for(char value : input)
+ {
+ switch(value)
+ {
+ case '&':
+ result += "&";
+ break;
+ case '<':
+ result += "<";
+ break;
+ case '>':
+ result += ">";
+ break;
+ case '"':
+ result += """;
+ break;
+ case '\'':
+ result += "'";
+ break;
+ default:
+ result.push_back(value);
+ }
+ }
+ return result;
+}
+
+std::string basicAuthorization(
+ const std::string& api_key, const std::string& secret_key)
+{
+ const std::string credentials = api_key + ':' + secret_key;
+ std::vector<unsigned char> bytes(
+ credentials.begin(), credentials.end());
+ return "Basic " + mw::base64Encode(
+ std::span<unsigned char>(bytes), false, true);
+}
+
+} // namespace
+
+MailjetEmailSender::MailjetEmailSender(
+ std::unique_ptr<mw::HTTPSessionInterface> session,
+ std::string from_address,
+ std::string from_name,
+ std::string api_key,
+ std::string secret_key)
+ : session_(std::move(session)),
+ from_address_(std::move(from_address)),
+ from_name_(std::move(from_name)),
+ api_key_(std::move(api_key)),
+ secret_key_(std::move(secret_key))
+{}
+
+mw::E<void> MailjetEmailSender::configure()
+{
+ auto protocols = session_->allowedProtocols("https");
+ if(!protocols)
+ {
+ return std::unexpected(std::move(protocols.error()));
+ }
+ auto redirect_protocols = session_->allowedRedirectProtocols("https");
+ if(!redirect_protocols)
+ {
+ return std::unexpected(std::move(redirect_protocols.error()));
+ }
+ session_->followRedirects(false);
+ auto redirections = session_->maxRedirections(0);
+ if(!redirections)
+ {
+ return std::unexpected(std::move(redirections.error()));
+ }
+ auto maximum = session_->maxSize(64 * 1024);
+ if(!maximum)
+ {
+ return std::unexpected(std::move(maximum.error()));
+ }
+ auto connection_timeout = session_->connectionTimeout(
+ std::chrono::seconds(5));
+ if(!connection_timeout)
+ {
+ return std::unexpected(std::move(connection_timeout.error()));
+ }
+ return session_->transferTimeout(std::chrono::seconds(15));
+}
+
+mw::E<void> MailjetEmailSender::send(const AuthenticationEmail& email)
+{
+ const std::string link = email.confirmation_url.str();
+ const std::string text =
+ "Use this link to sign in to Card Collection. The link expires in "
+ "ten minutes:\n\n" + link +
+ "\n\nIf you did not request this message, ignore it.";
+ const std::string html =
+ "<p>Use this link to sign in to Card Collection. The link expires "
+ "in ten minutes:</p><p><a href=\"" + htmlEscape(link) +
+ "\">Sign in</a></p><p>If you did not request this message, "
+ "ignore it.</p>";
+ nlohmann::json body = {
+ {"Messages", nlohmann::json::array({
+ {
+ {"From", {
+ {"Email", from_address_},
+ {"Name", from_name_},
+ }},
+ {"To", nlohmann::json::array({{
+ {"Email", email.recipient},
+ }})},
+ {"Subject", "Sign in to Card Collection"},
+ {"TextPart", text},
+ {"HTMLPart", html},
+ },
+ })},
+ };
+ mw::HTTPRequest request(MAILJET_SEND_URL);
+ request.setContentType("application/json");
+ request.setPayload(body.dump());
+ request.addHeader(
+ "Authorization", basicAuthorization(api_key_, secret_key_));
+ auto response = session_->post(request);
+ if(!response)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Mailjet request failed"));
+ }
+ if((**response).status < 200 || (**response).status >= 300)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Mailjet rejected the authentication email"));
+ }
+ try
+ {
+ const nlohmann::json parsed = nlohmann::json::parse(
+ (**response).payloadAsStr());
+ if(!parsed.contains("Messages") ||
+ !parsed["Messages"].is_array() ||
+ parsed["Messages"].size() != 1 ||
+ parsed["Messages"][0].value("Status", "") != "success")
+ {
+ return std::unexpected(mw::runtimeError(
+ "Mailjet returned an unsuccessful message status"));
+ }
+ }
+ catch(const nlohmann::json::exception&)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Mailjet returned an invalid response"));
+ }
+ return {};
+}
+
+bool MailjetEmailSender::usesGlobalQuota() const
+{
+ return true;
+}
diff --git a/src/email_sender_mailjet.h b/src/email_sender_mailjet.h
new file mode 100644
index 0000000..e6e4db0
--- /dev/null
+++ b/src/email_sender_mailjet.h
@@ -0,0 +1,38 @@
+#pragma once
+
+#include <memory>
+#include <string>
+
+#include <mw/error.hpp>
+#include <mw/http_client.hpp>
+
+#include "email_sender.h"
+
+/// Production authentication email sender using Mailjet Send API v3.1.
+class MailjetEmailSender final : public EmailSenderInterface
+{
+public:
+ /// Adopt an HTTP session and copy configured sender credentials.
+ MailjetEmailSender(
+ std::unique_ptr<mw::HTTPSessionInterface> session,
+ std::string from_address,
+ std::string from_name,
+ std::string api_key,
+ std::string secret_key);
+
+ /// Apply HTTPS-only, redirect, size, and timeout restrictions.
+ mw::E<void> configure();
+
+ /// Deliver one authentication message through Mailjet.
+ mw::E<void> send(const AuthenticationEmail& email) override;
+
+ /// Mailjet calls consume the persistent global quota.
+ bool usesGlobalQuota() const override;
+
+private:
+ std::unique_ptr<mw::HTTPSessionInterface> session_;
+ std::string from_address_;
+ std::string from_name_;
+ std::string api_key_;
+ std::string secret_key_;
+};
diff --git a/src/main.cpp b/src/main.cpp
index f5de9a2..18ce9e3 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -9,8 +9,10 @@
#include <Magick++.h>
#include <spdlog/spdlog.h>
+#include <uni_algo/version.h>
#include "app.h"
+#include "clock.h"
#include "game_registry.h"
#include "startup.h"
@@ -98,9 +100,27 @@ int main(int argc, char** argv)
image_magick.error().msg());
return 1;
}
+ spdlog::info(
+ "Username normalization uses uni-algo {}.{}.{} and Unicode {}.{}.{}",
+ una::version::library.major(),
+ una::version::library.minor(),
+ una::version::library.patch(),
+ una::version::unicode.major(),
+ una::version::unicode.minor(),
+ una::version::unicode.update());
auto games = std::make_unique<GameRegistry>();
- auto data_source = prepareDataSource(config->database_path, *games);
+ const auto startup_time = std::chrono::system_clock::now();
+ const std::int64_t startup_seconds =
+ std::chrono::duration_cast<std::chrono::seconds>(
+ startup_time.time_since_epoch()).count();
+ auto data_source = prepareDataSource(
+ config->database_path,
+ *games,
+ config->administrator_email,
+ config->administrator_email_key,
+ startup_seconds,
+ utcDay(startup_time));
if(!data_source)
{
spdlog::error(
@@ -119,31 +139,40 @@ int main(int argc, char** argv)
return 1;
}
- App app(
- *config,
- std::move(*data_source),
- std::move(games),
- std::make_unique<NonSecretRandom>());
+ try
+ {
+ App app(
+ *config,
+ std::move(*data_source),
+ std::move(games),
+ std::make_unique<NonSecretRandom>());
- std::signal(SIGINT, handleSignal);
- std::signal(SIGTERM, handleSignal);
+ std::signal(SIGINT, handleSignal);
+ std::signal(SIGTERM, handleSignal);
- auto start_result = app.start();
- if(!start_result)
+ auto start_result = app.start();
+ if(!start_result)
+ {
+ spdlog::error(
+ "Failed to start the server: {}",
+ start_result.error().msg());
+ return 1;
+ }
+
+ spdlog::info("Server listening at {}", config->base_url.str());
+ while(STOP_REQUESTED == 0)
+ {
+ std::this_thread::sleep_for(std::chrono::milliseconds(100));
+ }
+
+ app.stop();
+ app.wait();
+ }
+ catch(const std::exception& error)
{
spdlog::error(
- "Failed to start the server: {}",
- start_result.error().msg());
+ "Failed to construct application services: {}", error.what());
return 1;
}
-
- spdlog::info("Server listening at {}", config->base_url.str());
- while(STOP_REQUESTED == 0)
- {
- std::this_thread::sleep_for(std::chrono::milliseconds(100));
- }
-
- app.stop();
- app.wait();
return 0;
}
diff --git a/src/multipart_reader.cpp b/src/multipart_reader.cpp
index c5cdb10..4744426 100644
--- a/src/multipart_reader.cpp
+++ b/src/multipart_reader.cpp
@@ -41,6 +41,7 @@ bool isTextField(std::string_view name)
"foil_action",
"revision",
"series_id",
+ "csrf_token",
};
return names.contains(std::string(name));
}
diff --git a/src/secret_token.cpp b/src/secret_token.cpp
new file mode 100644
index 0000000..647f82d
--- /dev/null
+++ b/src/secret_token.cpp
@@ -0,0 +1,96 @@
+#include "secret_token.h"
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <string>
+
+namespace
+{
+
+constexpr std::array<char, 16> HEX = {
+ '0', '1', '2', '3', '4', '5', '6', '7',
+ '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
+
+mw::E<std::string> decodeToken(const std::string& token)
+{
+ if(token.size() != 64)
+ {
+ return std::unexpected(mw::runtimeError("Invalid token"));
+ }
+ std::string bytes(32, '\0');
+ for(std::size_t index = 0; index < bytes.size(); ++index)
+ {
+ const auto nibble = [](char value) -> int
+ {
+ if(value >= '0' && value <= '9')
+ {
+ return value - '0';
+ }
+ if(value >= 'a' && value <= 'f')
+ {
+ return value - 'a' + 10;
+ }
+ return -1;
+ };
+ const int high = nibble(token[index * 2]);
+ const int low = nibble(token[index * 2 + 1]);
+ if(high < 0 || low < 0)
+ {
+ return std::unexpected(mw::runtimeError("Invalid token"));
+ }
+ bytes[index] = static_cast<char>((high << 4) | low);
+ }
+ return bytes;
+}
+
+} // namespace
+
+mw::E<SecretToken> generateSecretToken(mw::CryptoInterface& crypto)
+{
+ auto random = crypto.randomBytes(32);
+ if(!random)
+ {
+ return std::unexpected(std::move(random.error()));
+ }
+ std::string bytes;
+ bytes.reserve(random->size());
+ std::string value;
+ value.reserve(random->size() * 2);
+ for(std::byte byte : *random)
+ {
+ const unsigned int number = std::to_integer<unsigned int>(byte);
+ bytes.push_back(static_cast<char>(number));
+ value.push_back(HEX[number >> 4]);
+ value.push_back(HEX[number & 0x0f]);
+ }
+ auto hash = mw::SHA256Hasher().hashToBytes(bytes);
+ if(!hash)
+ {
+ return std::unexpected(std::move(hash.error()));
+ }
+ return SecretToken{std::move(value), std::move(*hash)};
+}
+
+mw::E<std::vector<unsigned char>> hashSecretToken(const std::string& token)
+{
+ auto bytes = decodeToken(token);
+ if(!bytes)
+ {
+ return std::unexpected(std::move(bytes.error()));
+ }
+ return mw::SHA256Hasher().hashToBytes(*bytes);
+}
+
+bool constantTimeEqual(const std::string& first, const std::string& second)
+{
+ const std::size_t length = std::max(first.size(), second.size());
+ std::size_t difference = first.size() ^ second.size();
+ for(std::size_t index = 0; index < length; ++index)
+ {
+ const unsigned char left = index < first.size() ? first[index] : 0;
+ const unsigned char right = index < second.size() ? second[index] : 0;
+ difference |= static_cast<std::size_t>(left ^ right);
+ }
+ return difference == 0;
+}
diff --git a/src/secret_token.h b/src/secret_token.h
new file mode 100644
index 0000000..924a57f
--- /dev/null
+++ b/src/secret_token.h
@@ -0,0 +1,28 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <string>
+#include <vector>
+
+#include <mw/crypto.hpp>
+#include <mw/error.hpp>
+
+/// Raw credential returned once and its SHA-256 digest for persistence.
+struct SecretToken
+{
+ /// Lowercase hexadecimal credential for a URL or cookie.
+ std::string value;
+
+ /// SHA-256 digest of the original random bytes.
+ std::vector<unsigned char> hash;
+};
+
+/// Generate one 256-bit credential and persistence digest.
+mw::E<SecretToken> generateSecretToken(mw::CryptoInterface& crypto);
+
+/// Validate, decode, and hash a presented credential.
+mw::E<std::vector<unsigned char>> hashSecretToken(const std::string& token);
+
+/// Compare equally sized secret strings without early exit.
+bool constantTimeEqual(const std::string& first, const std::string& second);
diff --git a/src/series_service.cpp b/src/series_service.cpp
index 61e277a..50fd71c 100644
--- a/src/series_service.cpp
+++ b/src/series_service.cpp
@@ -35,6 +35,24 @@ mw::E<void> rejectDuplicate(
return {};
}
+mw::E<void> authorizeAdministrator(
+ DataSourceTransactionInterface& transaction,
+ const AuthorizationService& authorization,
+ std::int64_t actor_user_id)
+{
+ auto actor = transaction.getUserForUpdate(actor_user_id);
+ if(!actor)
+ {
+ return std::unexpected(std::move(actor.error()));
+ }
+ if(!*actor || !(*actor)->username ||
+ !authorization.canAdminister(**actor))
+ {
+ return std::unexpected(mw::httpError(403, "Administrator required"));
+ }
+ return {};
+}
+
} // namespace
SeriesService::SeriesService(
@@ -45,6 +63,7 @@ SeriesService::SeriesService(
{}
mw::E<std::int64_t> SeriesService::create(
+ std::int64_t actor_user_id,
std::string game_short_name,
std::string name,
std::string description)
@@ -83,6 +102,12 @@ mw::E<std::int64_t> SeriesService::create(
{
return std::unexpected(std::move(transaction.error()));
}
+ auto authorized = authorizeAdministrator(
+ **transaction, authorization_, actor_user_id);
+ if(!authorized)
+ {
+ return std::unexpected(std::move(authorized.error()));
+ }
auto id = (*transaction)->insertSeries(series);
if(!id)
{
@@ -97,6 +122,7 @@ mw::E<std::int64_t> SeriesService::create(
}
mw::E<void> SeriesService::update(
+ std::int64_t actor_user_id,
std::int64_t series_id,
std::string name,
std::string description)
@@ -136,6 +162,12 @@ mw::E<void> SeriesService::update(
{
return std::unexpected(std::move(transaction.error()));
}
+ auto authorized = authorizeAdministrator(
+ **transaction, authorization_, actor_user_id);
+ if(!authorized)
+ {
+ return std::unexpected(std::move(authorized.error()));
+ }
auto updated = (*transaction)->updateSeries(series);
if(!updated)
{
@@ -144,7 +176,9 @@ mw::E<void> SeriesService::update(
return (*transaction)->commit();
}
-mw::E<void> SeriesService::remove(std::int64_t series_id)
+mw::E<void> SeriesService::remove(
+ std::int64_t actor_user_id,
+ std::int64_t series_id)
{
auto current = data_source_.getSeries(series_id);
if(!current)
@@ -160,6 +194,12 @@ mw::E<void> SeriesService::remove(std::int64_t series_id)
{
return std::unexpected(std::move(transaction.error()));
}
+ auto authorized = authorizeAdministrator(
+ **transaction, authorization_, actor_user_id);
+ if(!authorized)
+ {
+ return std::unexpected(std::move(authorized.error()));
+ }
auto deleted = (*transaction)->deleteSeries(series_id);
if(!deleted)
{
diff --git a/src/series_service.h b/src/series_service.h
index 562cab7..c05da3c 100644
--- a/src/series_service.h
+++ b/src/series_service.h
@@ -5,6 +5,7 @@
#include <mw/error.hpp>
+#include "authorization.h"
#include "data.h"
#include "game_registry.h"
#include "markdown_renderer.h"
@@ -20,21 +21,26 @@ public:
/// Create a series and return its internal ID.
mw::E<std::int64_t> create(
+ std::int64_t actor_user_id,
std::string game_short_name,
std::string name,
std::string description);
/// Edit a series without changing its owning game.
mw::E<void> update(
+ std::int64_t actor_user_id,
std::int64_t series_id,
std::string name,
std::string description);
/// Delete a series and its membership rows.
- mw::E<void> remove(std::int64_t series_id);
+ mw::E<void> remove(
+ std::int64_t actor_user_id,
+ std::int64_t series_id);
private:
DataSourceInterface& data_source_;
const GameRegistry& games_;
+ AuthorizationService authorization_;
MarkdownRenderer markdown_renderer_;
};
diff --git a/src/startup.cpp b/src/startup.cpp
index ba820f0..e66d64c 100644
--- a/src/startup.cpp
+++ b/src/startup.cpp
@@ -2,13 +2,19 @@
#include <utility>
+#include <spdlog/spdlog.h>
+
#include "data_sqlite.h"
#include "game_definition.h"
#include "game_registry.h"
mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
const std::filesystem::path& database_path,
- const GameRegistry& games)
+ const GameRegistry& games,
+ const std::string& administrator_email,
+ const std::string& administrator_email_key,
+ std::int64_t now,
+ std::int64_t utc_day)
{
auto data_source = DataSourceSQLite::fromFile(database_path);
if(!data_source)
@@ -21,6 +27,23 @@ mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
return std::unexpected(std::move(migration_result.error()));
}
+ auto administrator = (*data_source)->reconcileAdministrator(
+ administrator_email,
+ administrator_email_key,
+ now,
+ utc_day);
+ if(!administrator)
+ {
+ return std::unexpected(std::move(administrator.error()));
+ }
+ auto cleaned = (*data_source)->cleanupAuthentication(now);
+ if(!cleaned)
+ {
+ spdlog::warn(
+ "Best-effort authentication cleanup failed: {}",
+ cleaned.error().msg());
+ }
+
auto transaction = (*data_source)->beginTransaction();
if(!transaction)
{
diff --git a/src/startup.h b/src/startup.h
index d6d46cb..2f20502 100644
--- a/src/startup.h
+++ b/src/startup.h
@@ -1,7 +1,9 @@
#pragma once
#include <filesystem>
+#include <cstdint>
#include <memory>
+#include <string>
#include <mw/error.hpp>
@@ -10,4 +12,8 @@
/// Open and migrate the application data source before server startup.
mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
const std::filesystem::path& database_path,
- const GameRegistry& games);
+ const GameRegistry& games,
+ const std::string& administrator_email = "admin@example.com",
+ const std::string& administrator_email_key = "admin@example.com",
+ std::int64_t now = 0,
+ std::int64_t utc_day = 0);
diff --git a/src/user.h b/src/user.h
new file mode 100644
index 0000000..a665074
--- /dev/null
+++ b/src/user.h
@@ -0,0 +1,41 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+/// Permission level assigned to one user account.
+enum class UserRole
+{
+ PLAYER = 0,
+ CREATOR = 1,
+ ADMINISTRATOR = 2
+};
+
+/// Persisted application user and lazily accrued pull state.
+struct User
+{
+ /// Internal SQLite identity.
+ std::int64_t id;
+
+ /// Immutable validated delivery address.
+ std::string email;
+
+ /// Normalized unique email identity.
+ std::string email_key;
+
+ /// NFC username, or null until onboarding completes.
+ std::optional<std::string> username;
+
+ /// Current permission level.
+ UserRole role;
+
+ /// Pulls retained at the last refresh.
+ std::uint32_t stored_pulls;
+
+ /// UTC epoch day at the last refresh.
+ std::int64_t pull_refresh_day;
+
+ /// Unix timestamp at account creation.
+ std::int64_t created_at;
+};
diff --git a/src/user_service.cpp b/src/user_service.cpp
new file mode 100644
index 0000000..ad698ba
--- /dev/null
+++ b/src/user_service.cpp
@@ -0,0 +1,106 @@
+#include "user_service.h"
+
+#include <utility>
+
+#include "username.h"
+
+UserService::UserService(DataSourceInterface& data_source)
+ : data_source_(data_source)
+{}
+
+mw::E<User> UserService::setUsername(
+ std::int64_t user_id, const std::string& submitted_username)
+{
+ auto normalized = normalizeUsername(submitted_username);
+ if(!normalized)
+ {
+ return std::unexpected(mw::httpError(400, "Invalid username"));
+ }
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto user = (*transaction)->getUserForUpdate(user_id);
+ if(!user)
+ {
+ return std::unexpected(std::move(user.error()));
+ }
+ if(!*user)
+ {
+ return std::unexpected(mw::httpError(404, "User not found"));
+ }
+ auto updated = (*transaction)->updateUsername(
+ user_id, normalized->username, normalized->key);
+ if(!updated)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "Username is already in use"));
+ }
+ if(!*updated)
+ {
+ return std::unexpected(mw::httpError(404, "User not found"));
+ }
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ return std::unexpected(std::move(committed.error()));
+ }
+ (**user).username = std::move(normalized->username);
+ return std::move(**user);
+}
+
+mw::E<User> UserService::promote(
+ std::int64_t administrator_user_id,
+ std::int64_t target_user_id)
+{
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto administrator = (*transaction)->getUserForUpdate(
+ administrator_user_id);
+ if(!administrator)
+ {
+ return std::unexpected(std::move(administrator.error()));
+ }
+ if(!*administrator ||
+ (**administrator).role != UserRole::ADMINISTRATOR)
+ {
+ return std::unexpected(mw::httpError(403, "Forbidden"));
+ }
+ auto target = (*transaction)->getUserForUpdate(target_user_id);
+ if(!target)
+ {
+ return std::unexpected(std::move(target.error()));
+ }
+ if(!*target)
+ {
+ return std::unexpected(mw::httpError(404, "User not found"));
+ }
+ if((**target).role == UserRole::ADMINISTRATOR)
+ {
+ return std::unexpected(mw::httpError(409, "Role cannot be changed"));
+ }
+ if((**target).role == UserRole::PLAYER)
+ {
+ auto promoted = (*transaction)->promoteUser(target_user_id);
+ if(!promoted)
+ {
+ return std::unexpected(std::move(promoted.error()));
+ }
+ if(!*promoted)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "User role changed in another request"));
+ }
+ (**target).role = UserRole::CREATOR;
+ }
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ return std::unexpected(std::move(committed.error()));
+ }
+ return std::move(**target);
+}
diff --git a/src/user_service.h b/src/user_service.h
new file mode 100644
index 0000000..2490cdf
--- /dev/null
+++ b/src/user_service.h
@@ -0,0 +1,28 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+
+#include <mw/error.hpp>
+
+#include "data.h"
+
+/// Own username onboarding, username changes, and creator promotion.
+class UserService
+{
+public:
+ /// Construct a service over the application persistence boundary.
+ explicit UserService(DataSourceInterface& data_source);
+
+ /// Set or change one user's validated Unicode username.
+ mw::E<User> setUsername(
+ std::int64_t user_id, const std::string& submitted_username);
+
+ /// Permanently promote one player after re-reading the administrator.
+ mw::E<User> promote(
+ std::int64_t administrator_user_id,
+ std::int64_t target_user_id);
+
+private:
+ DataSourceInterface& data_source_;
+};
diff --git a/src/username.cpp b/src/username.cpp
new file mode 100644
index 0000000..1adfd33
--- /dev/null
+++ b/src/username.cpp
@@ -0,0 +1,39 @@
+#include "username.h"
+
+#include <string>
+
+#include <uni_algo/case.h>
+#include <uni_algo/conv.h>
+#include <uni_algo/norm.h>
+#include <uni_algo/prop.h>
+
+mw::E<NormalizedUsername> normalizeUsername(const std::string& input)
+{
+ if(!una::is_valid_utf8(input))
+ {
+ return std::unexpected(mw::runtimeError("Invalid username"));
+ }
+ std::string username = una::norm::to_nfc_utf8(input);
+ if(username.empty() || username.size() > 32)
+ {
+ return std::unexpected(mw::runtimeError("Invalid username"));
+ }
+
+ const std::u32string codepoints = una::utf8to32u(username);
+ for(char32_t codepoint : codepoints)
+ {
+ if(una::codepoint::prop(codepoint).General_Category_Cc())
+ {
+ return std::unexpected(mw::runtimeError("Invalid username"));
+ }
+ }
+ if(una::codepoint::prop(codepoints.front()).White_Space() ||
+ una::codepoint::prop(codepoints.back()).White_Space())
+ {
+ return std::unexpected(mw::runtimeError("Invalid username"));
+ }
+
+ std::string key = una::cases::to_casefold_utf8(username);
+ key = una::norm::to_nfc_utf8(key);
+ return NormalizedUsername{std::move(username), std::move(key)};
+}
diff --git a/src/username.h b/src/username.h
new file mode 100644
index 0000000..7089b0c
--- /dev/null
+++ b/src/username.h
@@ -0,0 +1,18 @@
+#pragma once
+
+#include <string>
+
+#include <mw/error.hpp>
+
+/// Validated NFC username and normalized uniqueness key.
+struct NormalizedUsername
+{
+ /// NFC display value.
+ std::string username;
+
+ /// Full-case-folded NFC uniqueness key.
+ std::string key;
+};
+
+/// Validate and normalize a submitted UTF-8 username.
+mw::E<NormalizedUsername> normalizeUsername(const std::string& input);
diff --git a/static/css/styles.css b/static/css/styles.css
index 1323c00..6f0ad7a 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -1013,3 +1013,31 @@ h1 {
transition-duration: 0.01ms !important;
}
}
+
+.collection-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
+ gap: 1rem;
+ padding: 0;
+ list-style: none;
+}
+
+.collection-card a {
+ position: relative;
+ display: grid;
+ gap: 0.5rem;
+}
+
+.collection-card img {
+ width: 100%;
+}
+
+.collection-quantity {
+ position: absolute;
+ right: 0.5rem;
+ bottom: 2rem;
+ padding: 0.2rem 0.5rem;
+ border-radius: 999px;
+ color: white;
+ background: rgb(0 0 0 / 75%);
+}
diff --git a/templates/account.html b/templates/account.html
new file mode 100644
index 0000000..6a81447
--- /dev/null
+++ b/templates/account.html
@@ -0,0 +1,13 @@
+{% extends "layout.html" %}
+{% block content %}
+<section class="page-shell form-page">
+ <h1>Account</h1>
+ <dl><dt>Email</dt><dd>{{ email }}</dd>
+ <dt>Username</dt><dd>{{ username }}</dd></dl>
+ <p><a href="{{ username_url }}">Change username</a></p>
+ <form method="post" action="{{ logout_url }}">
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+ <button>Sign out</button>
+ </form>
+</section>
+{% endblock %}
diff --git a/templates/authentication.html b/templates/authentication.html
new file mode 100644
index 0000000..bddafcb
--- /dev/null
+++ b/templates/authentication.html
@@ -0,0 +1,9 @@
+{% extends "public_layout.html" %}
+{% block content %}
+<h1>Sign in or register</h1>
+<form method="post" action="{{ action_url }}">
+ <label>Email <input type="email" name="email" required></label>
+ <input type="hidden" name="form_nonce" value="{{ form_nonce }}">
+ <button>Send sign-in link</button>
+</form>
+{% endblock %}
diff --git a/templates/authentication_confirm.html b/templates/authentication_confirm.html
new file mode 100644
index 0000000..a6dd214
--- /dev/null
+++ b/templates/authentication_confirm.html
@@ -0,0 +1,9 @@
+{% extends "public_layout.html" %}
+{% block content %}
+<h1>Confirm sign in</h1>
+<p>Continue as {{ email }}?</p>
+<form method="post" action="{{ action_url }}">
+ <input type="hidden" name="form_nonce" value="{{ form_nonce }}">
+ <button>Continue</button>
+</form>
+{% endblock %}
diff --git a/templates/authentication_sent.html b/templates/authentication_sent.html
new file mode 100644
index 0000000..fee6e10
--- /dev/null
+++ b/templates/authentication_sent.html
@@ -0,0 +1,5 @@
+{% extends "public_layout.html" %}
+{% block content %}
+<h1>Check your email</h1>
+<p>If the address can receive mail, a sign-in link is on its way.</p>
+{% endblock %}
diff --git a/templates/card_delete.html b/templates/card_delete.html
index 582929f..e45be73 100644
--- a/templates/card_delete.html
+++ b/templates/card_delete.html
@@ -7,6 +7,7 @@
<h1 id="DeleteCardHeading">Delete {{ name }}?</h1>
<p>This permanently removes the card and its published images.</p>
<form class="standalone-form" method="post" action="{{ action_url }}">
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="form-submit destructive-submit" type="submit">
Delete card
</button>
diff --git a/templates/card_form.html b/templates/card_form.html
index 3618fee..b9d9d0d 100644
--- a/templates/card_form.html
+++ b/templates/card_form.html
@@ -20,6 +20,7 @@
<form id="CardForm" class="card-form" method="post"
action="{{ action_url }}" enctype="multipart/form-data">
+ <input name="csrf_token" type="hidden" value="{{ csrf_token }}">
<section class="form-section" aria-labelledby="IdentityHeading">
<h2 id="IdentityHeading">Identity</h2>
{% if mode == "edit" %}
@@ -46,11 +47,16 @@
maxlength="200" autocomplete="off"
value="{{ name }}" required>
</label>
+ {% if show_rarity %}
<label class="form-field" for="CardRarity">
<span>Rarity</span>
<input id="CardRarity" name="rarity" type="number"
min="0" step="1" value="{{ rarity }}" required>
</label>
+ {% else %}
+ <p>Rarity is assigned by an administrator before this card
+ enters the pull pool.</p>
+ {% endif %}
</section>
{% for game in games %}
diff --git a/templates/card_index.html b/templates/card_index.html
index 0376090..be3741e 100644
--- a/templates/card_index.html
+++ b/templates/card_index.html
@@ -15,6 +15,12 @@
</div>
</div>
+<p><a href="{{ create_url }}">Create card</a>
+{% if administrator %}
+ · <a href="{{ series_url }}">Manage series</a>
+ · <a href="{{ users_url }}">Manage users</a>
+{% endif %}</p>
+
{% if length(cards) == 0 %}
<p class="empty-state">No cards yet.</p>
{% endif %}
diff --git a/templates/card_view.html b/templates/card_view.html
index 32889d2..786cc28 100644
--- a/templates/card_view.html
+++ b/templates/card_view.html
@@ -43,6 +43,10 @@
<dt>Rarity</dt>
<dd>{{ rarity }}</dd>
</div>
+ <div>
+ <dt>Pull probability</dt>
+ <dd>{{ probability }}</dd>
+ </div>
<div>
<dt>Finish</dt>
<dd>{% if has_foil %}Foil{% else %}Standard{% endif %}</dd>
diff --git a/templates/collection.html b/templates/collection.html
new file mode 100644
index 0000000..6ea6440
--- /dev/null
+++ b/templates/collection.html
@@ -0,0 +1,21 @@
+{% extends "layout.html" %}
+{% block content %}
+<section class="page-shell">
+ <h1>Your collection</h1>
+ <p>Available pulls: {{ available_pulls }}</p>
+ <form method="post" action="{{ pull_url }}">
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+ <button {% if pull_disabled %}disabled{% endif %}>Pull a card</button>
+ </form>
+ {% if pool_empty %}<p>The card pool is currently empty.</p>{% endif %}
+ <ul class="collection-grid">
+ {% for entry in entries %}
+ <li class="collection-card"><a href="{{ entry.url }}">
+ <img src="{{ entry.thumbnail_url }}" alt="">
+ <span>{{ entry.name }}</span>
+ <strong class="collection-quantity">{{ entry.quantity }}</strong>
+ </a></li>
+ {% endfor %}
+ </ul>
+</section>
+{% endblock %}
diff --git a/templates/error.html b/templates/error.html
new file mode 100644
index 0000000..6d03cea
--- /dev/null
+++ b/templates/error.html
@@ -0,0 +1,2 @@
+{% extends "public_layout.html" %}
+{% block content %}<h1>{{ heading }}</h1><p>{{ message }}</p>{% endblock %}
diff --git a/templates/layout.html b/templates/layout.html
index c7f824a..b34b2e4 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -4,14 +4,6 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
- <link rel="preconnect" href="https://fonts.googleapis.com">
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
- <link
- href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400..700"
- rel="stylesheet">
- <link
- href="https://fonts.googleapis.com/css2?family=Nunito:wght@700..900"
- rel="stylesheet">
<link rel="stylesheet" href="{{ url_for("static", "css/styles.css") }}">
</head>
<body>
@@ -21,16 +13,14 @@
<div class="ambient-blob ambient-blob-blue"></div>
</div>
<header class="site-header">
- <a class="site-title" href="{{ url_for("card-index") }}">
+ <a class="site-title" href="{{ url_for("collection") }}">
Card Collection
</a>
<nav aria-label="Primary navigation">
- <a class="nav-link" href="{{ url_for("card-index") }}">Cards</a>
- <a class="nav-link" href="{{ url_for("series-index") }}">
- Series
+ <a class="nav-link" href="{{ url_for("collection") }}">
+ Collection
</a>
- <a class="nav-link nav-link-primary"
- href="{{ url_for("card-new") }}">Create card</a>
+ <a class="nav-link" href="{{ url_for("account") }}">Account</a>
</nav>
</header>
<main>
diff --git a/templates/onboarding_username.html b/templates/onboarding_username.html
new file mode 100644
index 0000000..f0c1bca
--- /dev/null
+++ b/templates/onboarding_username.html
@@ -0,0 +1,9 @@
+{% extends "public_layout.html" %}
+{% block content %}
+<h1>{{ heading }}</h1>
+<form method="post" action="{{ action_url }}">
+ <input name="username" required maxlength="32" value="{{ username }}">
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+ <button>{{ submit_label }}</button>
+</form>
+{% endblock %}
diff --git a/templates/public_layout.html b/templates/public_layout.html
new file mode 100644
index 0000000..ed55a44
--- /dev/null
+++ b/templates/public_layout.html
@@ -0,0 +1,14 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>{{ title }}</title>
+ <link rel="stylesheet" href="{{ url_for("static", "css/styles.css") }}">
+</head>
+<body>
+ <main class="page-shell form-page">
+ {% block content %}{% endblock %}
+ </main>
+</body>
+</html>
diff --git a/templates/series_delete.html b/templates/series_delete.html
index f4b025b..d22e355 100644
--- a/templates/series_delete.html
+++ b/templates/series_delete.html
@@ -7,6 +7,7 @@
<h1 id="DeleteSeriesHeading">Delete {{ name }}?</h1>
<p>Cards in this series will remain in the collection.</p>
<form class="standalone-form" method="post" action="{{ action_url }}">
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="form-submit destructive-submit" type="submit">
Delete series
</button>
diff --git a/templates/series_form.html b/templates/series_form.html
index 2fc31de..20f6c53 100644
--- a/templates/series_form.html
+++ b/templates/series_form.html
@@ -7,6 +7,7 @@
<h1 id="SeriesFormHeading">{{ heading }}</h1>
<form class="standalone-form" method="post" action="{{ action_url }}">
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="form-field" for="SeriesGame">
<span>Game</span>
{% if mode == "create" %}
diff --git a/templates/user_admin.html b/templates/user_admin.html
new file mode 100644
index 0000000..bf473d0
--- /dev/null
+++ b/templates/user_admin.html
@@ -0,0 +1,20 @@
+{% extends "layout.html" %}
+{% block content %}
+<section class="page-shell">
+ <h1>Users</h1>
+ <table><thead><tr><th>Username</th><th>Email</th><th>Role</th><th></th>
+ </tr></thead><tbody>
+ {% for user in users %}
+ <tr><td>{{ user.username }}</td><td>{{ user.email }}</td>
+ <td>{{ user.role }}</td><td>
+ {% if user.can_promote %}
+ <form method="post" action="{{ user.promote_url }}">
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+ <button>Promote</button>
+ </form>
+ {% endif %}
+ </td></tr>
+ {% endfor %}
+ </tbody></table>
+</section>
+{% endblock %}
diff --git a/templates/welcome.html b/templates/welcome.html
new file mode 100644
index 0000000..5aece9b
--- /dev/null
+++ b/templates/welcome.html
@@ -0,0 +1,7 @@
+{% extends "public_layout.html" %}
+{% block content %}
+<h1>Card Collection</h1>
+<p>Build your collection one daily pull at a time.</p>
+<img src="{{ example_url }}" alt="Example card">
+<p><a href="{{ authentication_url }}">Sign in or register</a></p>
+{% endblock %}
diff --git a/tests/app_integration_test.cpp b/tests/app_integration_test.cpp
index 86bd9b6..67f5632 100644
--- a/tests/app_integration_test.cpp
+++ b/tests/app_integration_test.cpp
@@ -6,6 +6,7 @@
#include <iterator>
#include <memory>
#include <string>
+#include <string_view>
#include <Magick++.h>
#include <gtest/gtest.h>
@@ -27,6 +28,58 @@ std::string readFile(const std::filesystem::path& path)
std::istreambuf_iterator<char>());
}
+std::string hiddenValue(const std::string& body, std::string_view name)
+{
+ std::string marker = "name=\"" + std::string(name) + "\" value=\"";
+ std::size_t begin = body.find(marker);
+ if(begin == std::string::npos)
+ {
+ marker = "name=" + std::string(name) + " value=\"";
+ begin = body.find(marker);
+ }
+ if(begin == std::string::npos)
+ {
+ return {};
+ }
+ const std::size_t value_begin = begin + marker.size();
+ const std::size_t end = body.find('"', value_begin);
+ return end == std::string::npos
+ ? std::string()
+ : body.substr(value_begin, end - value_begin);
+}
+
+std::string responseCookie(
+ const httplib::Response& response, std::string_view name)
+{
+ const std::string prefix = std::string(name) + '=';
+ for(std::size_t index = 0;
+ index < response.get_header_value_count("Set-Cookie");
+ ++index)
+ {
+ const std::string value = response.get_header_value(
+ "Set-Cookie", nullptr, index);
+ if(value.starts_with(prefix))
+ {
+ return value.substr(0, value.find(';'));
+ }
+ }
+ return {};
+}
+
+std::string urlRequestPath(const std::string& url)
+{
+ const std::size_t scheme = url.find("://");
+ const std::size_t path = scheme == std::string::npos
+ ? std::string::npos
+ : url.find('/', scheme + 3);
+ if(path == std::string::npos)
+ {
+ return {};
+ }
+ const std::size_t end = url.find_first_of("\r\n\t ", path);
+ return url.substr(path, end - path);
+}
+
class TemporaryServerRoot
{
public:
@@ -82,6 +135,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
75,
256,
};
+ config.email.link_file = temporary.path() / "latest-link.txt";
auto games = std::make_unique<GameRegistry>();
ASSERT_TRUE(games->add(std::make_unique<TestGame>()));
auto data_source = prepareDataSource(config.database_path, *games);
@@ -109,6 +163,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
std::nullopt,
"avif",
1,
+ 1,
},
game,
metadata->get(),
@@ -138,35 +193,109 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
ASSERT_NE(index, nullptr);
EXPECT_EQ(index->status, 200);
EXPECT_NE(index->body.find("Card Collection"), std::string::npos);
- auto create = client.Get("/collection/cards/new");
- ASSERT_NE(create, nullptr);
- EXPECT_EQ(create->status, 200);
- auto card = client.Get("/collection/cards/test-1");
+ auto anonymous_card = client.Get("/collection/cards/test-1");
+ ASSERT_NE(anonymous_card, nullptr);
+ EXPECT_EQ(anonymous_card->status, 303);
+
+ auto authentication = client.Get("/collection/authentication");
+ ASSERT_NE(authentication, nullptr);
+ ASSERT_EQ(authentication->status, 200);
+ const std::string authentication_cookie = responseCookie(
+ *authentication, "card_collection_auth_form");
+ const std::string authentication_nonce = hiddenValue(
+ authentication->body, "form_nonce");
+ ASSERT_FALSE(authentication_cookie.empty());
+ ASSERT_FALSE(authentication_nonce.empty());
+ const httplib::Headers authentication_headers = {
+ {"Cookie", authentication_cookie}};
+ const httplib::Params email_fields = {
+ {"email", "admin@example.com"},
+ {"form_nonce", authentication_nonce}};
+ auto email = client.Post(
+ "/collection/authentication/email",
+ authentication_headers,
+ email_fields);
+ ASSERT_NE(email, nullptr);
+ ASSERT_EQ(email->status, 303) << email->body;
+ const std::string confirmation_path = urlRequestPath(
+ readFile(config.email.link_file));
+ ASSERT_FALSE(confirmation_path.empty());
+ auto limited_form = client.Get("/collection/authentication");
+ ASSERT_NE(limited_form, nullptr);
+ const httplib::Headers limited_headers = {{
+ "Cookie",
+ responseCookie(*limited_form, "card_collection_auth_form")}};
+ const httplib::Params limited_fields = {
+ {"email", "ADMIN@example.com"},
+ {"form_nonce", hiddenValue(limited_form->body, "form_nonce")}};
+ auto limited = client.Post(
+ "/collection/authentication/email",
+ limited_headers,
+ limited_fields);
+ ASSERT_NE(limited, nullptr);
+ EXPECT_EQ(limited->status, 429);
+ EXPECT_FALSE(limited->get_header_value("Retry-After").empty());
+ auto confirmation = client.Get(confirmation_path);
+ ASSERT_NE(confirmation, nullptr);
+ ASSERT_EQ(confirmation->status, 200) << confirmation->body;
+ EXPECT_EQ(
+ confirmation->get_header_value("Referrer-Policy"), "no-referrer");
+ const std::string confirmation_cookie = responseCookie(
+ *confirmation, "card_collection_confirm_form");
+ const std::string confirmation_nonce = hiddenValue(
+ confirmation->body, "form_nonce");
+ const httplib::Headers confirmation_headers = {
+ {"Cookie", confirmation_cookie}};
+ const httplib::Params confirmation_fields = {
+ {"form_nonce", confirmation_nonce}};
+ auto confirmed = client.Post(
+ confirmation_path,
+ confirmation_headers,
+ confirmation_fields);
+ ASSERT_NE(confirmed, nullptr);
+ ASSERT_EQ(confirmed->status, 303) << confirmed->body;
+ const std::string session_cookie = responseCookie(
+ *confirmed, "card_collection_session");
+ ASSERT_FALSE(session_cookie.empty());
+ EXPECT_NE(
+ confirmed->get_header_value("Set-Cookie").find("HttpOnly"),
+ std::string::npos);
+
+ const httplib::Headers session_headers = {{"Cookie", session_cookie}};
+ auto onboarding = client.Get(
+ "/collection/onboarding/username", session_headers);
+ ASSERT_NE(onboarding, nullptr);
+ ASSERT_EQ(onboarding->status, 200) << onboarding->body;
+ const std::string csrf_token = hiddenValue(
+ onboarding->body, "csrf_token");
+ ASSERT_FALSE(csrf_token.empty());
+ const httplib::Params onboarding_fields = {
+ {"username", "Administrator"}, {"csrf_token", csrf_token}};
+ auto onboarded = client.Post(
+ "/collection/onboarding/username",
+ session_headers,
+ onboarding_fields);
+ ASSERT_NE(onboarded, nullptr);
+ ASSERT_EQ(onboarded->status, 303) << onboarded->body;
+
+ auto card = client.Get("/collection/cards/test-1", session_headers);
ASSERT_NE(card, nullptr);
- EXPECT_EQ(card->status, 200);
+ EXPECT_EQ(card->status, 200) << card->body;
EXPECT_NE(card->body.find("Card <One>"), std::string::npos);
- auto edit = client.Get("/collection/cards/test-1/edit");
+ EXPECT_NE(card->body.find("Not currently"), std::string::npos);
+ auto create = client.Get("/collection/cards/new", session_headers);
+ ASSERT_NE(create, nullptr);
+ EXPECT_EQ(create->status, 200) << create->body;
+ auto edit = client.Get(
+ "/collection/cards/test-1/edit", session_headers);
ASSERT_NE(edit, nullptr);
- EXPECT_EQ(edit->status, 200);
+ EXPECT_EQ(edit->status, 200) << edit->body;
EXPECT_NE(edit->body.find("value=\"20\""), std::string::npos);
- auto card_delete = client.Get("/collection/cards/test-1/delete");
- ASSERT_NE(card_delete, nullptr);
- EXPECT_EQ(card_delete->status, 200);
- auto series = client.Get("/collection/series");
+ auto series = client.Get(
+ "/collection/admin/series", session_headers);
ASSERT_NE(series, nullptr);
- EXPECT_EQ(series->status, 200);
+ EXPECT_EQ(series->status, 200) << series->body;
EXPECT_NE(series->body.find("Core <Set>"), std::string::npos);
- auto series_new = client.Get("/collection/series/new");
- ASSERT_NE(series_new, nullptr);
- EXPECT_EQ(series_new->status, 200);
- auto series_edit = client.Get(
- "/collection/series/" + std::to_string(*series_id) + "/edit");
- ASSERT_NE(series_edit, nullptr);
- EXPECT_EQ(series_edit->status, 200);
- auto series_delete = client.Get(
- "/collection/series/" + std::to_string(*series_id) + "/delete");
- ASSERT_NE(series_delete, nullptr);
- EXPECT_EQ(series_delete->status, 200);
auto stylesheet = client.Get("/collection/static/css/styles.css");
ASSERT_NE(stylesheet, nullptr);
EXPECT_EQ(stylesheet->status, 200);
@@ -181,13 +310,15 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
EXPECT_EQ(traversal->status, 404);
const httplib::UploadFormDataItems create_fields = {
+ {"csrf_token", csrf_token, "", ""},
{"game", "", "", ""},
{"name", "Uploaded card", "", ""},
{"rarity", "3", "", ""},
{"source_mode", "files", "", ""},
{"front", upload_bytes, "card.png", "image/png"},
};
- auto created = client.Post("/collection/cards", create_fields);
+ auto created = client.Post(
+ "/collection/cards", session_headers, create_fields);
ASSERT_NE(created, nullptr);
ASSERT_EQ(created->status, 303) << created->body;
const std::string location = created->get_header_value("Location");
@@ -200,6 +331,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
config.card_storage_root / "published" / public_id));
const httplib::UploadFormDataItems edit_fields = {
+ {"csrf_token", csrf_token, "", ""},
{"name", "Edited upload", "", ""},
{"rarity", "4", "", ""},
{"revision", "1", "", ""},
@@ -207,41 +339,174 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
{"front_action", "keep", "", ""},
{"foil_action", "keep", "", ""},
};
- auto edited = client.Post(created_path, edit_fields);
+ auto edited = client.Post(
+ created_path + "/edit", session_headers, edit_fields);
ASSERT_NE(edited, nullptr);
EXPECT_EQ(edited->status, 303) << edited->body;
- auto edited_page = client.Get(created_path);
+ auto edited_page = client.Get(created_path, session_headers);
ASSERT_NE(edited_page, nullptr);
EXPECT_NE(edited_page->body.find("Edited upload"), std::string::npos);
- auto deleted = client.Post(created_path + "/delete", "", "text/plain");
+ const httplib::Params csrf_fields = {{"csrf_token", csrf_token}};
+ auto deleted = client.Post(
+ created_path + "/delete",
+ session_headers,
+ csrf_fields);
ASSERT_NE(deleted, nullptr);
EXPECT_EQ(deleted->status, 303) << deleted->body;
EXPECT_FALSE(std::filesystem::exists(
config.card_storage_root / "published" / public_id));
httplib::Params create_series_fields = {
+ {"csrf_token", csrf_token},
{"game", "test"},
{"name", "Uploaded series"},
{"description", "Description"},
};
auto created_series = client.Post(
- "/collection/series", create_series_fields);
+ "/collection/admin/series", session_headers, create_series_fields);
ASSERT_NE(created_series, nullptr);
EXPECT_EQ(created_series->status, 303) << created_series->body;
httplib::Params edit_series_fields = {
+ {"csrf_token", csrf_token},
{"name", "Edited series"},
{"description", "Changed"},
};
auto edited_series = client.Post(
- "/collection/series/2", edit_series_fields);
+ "/collection/admin/series/2", session_headers, edit_series_fields);
ASSERT_NE(edited_series, nullptr);
EXPECT_EQ(edited_series->status, 303) << edited_series->body;
auto deleted_series = client.Post(
- "/collection/series/2/delete", "", "text/plain");
+ "/collection/admin/series/2/delete",
+ session_headers,
+ csrf_fields);
ASSERT_NE(deleted_series, nullptr);
EXPECT_EQ(deleted_series->status, 303) << deleted_series->body;
+ auto player_authentication = client.Get("/collection/authentication");
+ ASSERT_NE(player_authentication, nullptr);
+ const std::string player_auth_cookie = responseCookie(
+ *player_authentication, "card_collection_auth_form");
+ const std::string player_auth_nonce = hiddenValue(
+ player_authentication->body, "form_nonce");
+ const httplib::Headers player_auth_headers = {
+ {"Cookie", player_auth_cookie}};
+ const httplib::Params player_email_fields = {
+ {"email", "player@example.com"},
+ {"form_nonce", player_auth_nonce}};
+ auto player_email = client.Post(
+ "/collection/authentication/email",
+ player_auth_headers,
+ player_email_fields);
+ ASSERT_NE(player_email, nullptr);
+ ASSERT_EQ(player_email->status, 303) << player_email->body;
+ const std::string player_confirmation_path = urlRequestPath(
+ readFile(config.email.link_file));
+ auto player_confirmation = client.Get(player_confirmation_path);
+ ASSERT_NE(player_confirmation, nullptr);
+ const std::string player_confirmation_cookie = responseCookie(
+ *player_confirmation, "card_collection_confirm_form");
+ const httplib::Headers player_confirmation_headers = {
+ {"Cookie", player_confirmation_cookie}};
+ const httplib::Params player_confirmation_fields = {
+ {"form_nonce", hiddenValue(
+ player_confirmation->body, "form_nonce")}};
+ auto player_confirmed = client.Post(
+ player_confirmation_path,
+ player_confirmation_headers,
+ player_confirmation_fields);
+ ASSERT_NE(player_confirmed, nullptr);
+ ASSERT_EQ(player_confirmed->status, 303) << player_confirmed->body;
+ const std::string player_session_cookie = responseCookie(
+ *player_confirmed, "card_collection_session");
+ const httplib::Headers player_headers = {
+ {"Cookie", player_session_cookie}};
+ auto player_onboarding = client.Get(
+ "/collection/onboarding/username", player_headers);
+ ASSERT_NE(player_onboarding, nullptr);
+ const std::string player_csrf = hiddenValue(
+ player_onboarding->body, "csrf_token");
+ const httplib::Params player_onboarding_fields = {
+ {"username", "Player"}, {"csrf_token", player_csrf}};
+ auto player_onboarded = client.Post(
+ "/collection/onboarding/username",
+ player_headers,
+ player_onboarding_fields);
+ ASSERT_NE(player_onboarded, nullptr);
+ ASSERT_EQ(player_onboarded->status, 303) << player_onboarded->body;
+ auto player_card_new = client.Get(
+ "/collection/cards/new", player_headers);
+ ASSERT_NE(player_card_new, nullptr);
+ EXPECT_EQ(player_card_new->status, 403);
+ auto player_admin = client.Get(
+ "/collection/admin/users", player_headers);
+ ASSERT_NE(player_admin, nullptr);
+ EXPECT_EQ(player_admin->status, 403);
+
+ auto promoted = client.Post(
+ "/collection/admin/users/2/promote",
+ session_headers,
+ csrf_fields);
+ ASSERT_NE(promoted, nullptr);
+ ASSERT_EQ(promoted->status, 303) << promoted->body;
+ auto creator_new = client.Get("/collection/cards/new", player_headers);
+ ASSERT_NE(creator_new, nullptr);
+ ASSERT_EQ(creator_new->status, 200) << creator_new->body;
+ EXPECT_EQ(creator_new->body.find("name=\"rarity\""), std::string::npos);
+ const httplib::UploadFormDataItems creator_card_fields = {
+ {"csrf_token", player_csrf, "", ""},
+ {"game", "", "", ""},
+ {"name", "Creator draft", "", ""},
+ {"source_mode", "files", "", ""},
+ {"front", upload_bytes, "card.png", "image/png"},
+ };
+ auto creator_card = client.Post(
+ "/collection/cards", player_headers, creator_card_fields);
+ ASSERT_NE(creator_card, nullptr);
+ ASSERT_EQ(creator_card->status, 303) << creator_card->body;
+ const std::string creator_card_path = urlRequestPath(
+ creator_card->get_header_value("Location"));
+ auto creator_cards = client.Get(
+ "/collection/creator/cards", player_headers);
+ ASSERT_NE(creator_cards, nullptr);
+ EXPECT_EQ(creator_cards->status, 200) << creator_cards->body;
+ EXPECT_NE(creator_cards->body.find("Creator draft"), std::string::npos);
+ const httplib::UploadFormDataItems pool_edit_fields = {
+ {"csrf_token", csrf_token, "", ""},
+ {"name", "Creator draft", "", ""},
+ {"rarity", "2", "", ""},
+ {"revision", "1", "", ""},
+ {"source_mode", "files", "", ""},
+ {"front_action", "keep", "", ""},
+ {"foil_action", "keep", "", ""},
+ };
+ auto pooled = client.Post(
+ creator_card_path + "/edit", session_headers, pool_edit_fields);
+ ASSERT_NE(pooled, nullptr);
+ ASSERT_EQ(pooled->status, 303) << pooled->body;
+ const httplib::Params player_pull_fields = {
+ {"csrf_token", player_csrf}};
+ auto player_pull = client.Post(
+ "/collection/collection/pull", player_headers, player_pull_fields);
+ ASSERT_NE(player_pull, nullptr);
+ ASSERT_EQ(player_pull->status, 303) << player_pull->body;
+ EXPECT_EQ(
+ urlRequestPath(player_pull->get_header_value("Location")),
+ creator_card_path);
+ auto owned_card = client.Get(creator_card_path, player_headers);
+ ASSERT_NE(owned_card, nullptr);
+ EXPECT_EQ(owned_card->status, 200) << owned_card->body;
+ EXPECT_NE(owned_card->body.find("100%"), std::string::npos);
+ const httplib::Params player_logout_fields = {
+ {"csrf_token", player_csrf}};
+ auto logged_out = client.Post(
+ "/collection/logout", player_headers, player_logout_fields);
+ ASSERT_NE(logged_out, nullptr);
+ EXPECT_EQ(logged_out->status, 303) << logged_out->body;
+ auto after_logout = client.Get("/collection/collection", player_headers);
+ ASSERT_NE(after_logout, nullptr);
+ EXPECT_EQ(after_logout->status, 303);
+
app.stop();
app.wait();
}
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index 6f6be51..c572a3d 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -79,6 +79,47 @@ Card makeCard(std::int64_t id, std::uint64_t number, std::string name)
};
}
+class AuthenticatedDataSource : public DataSourceFake
+{
+public:
+ using DataSourceFake::DataSourceFake;
+
+ /// Return one deterministic onboarded administrator session.
+ mw::E<std::optional<SessionContext>> getSession(
+ [[maybe_unused]] const TokenHash& token_hash,
+ [[maybe_unused]] std::int64_t now) const override
+ {
+ return SessionContext{
+ 1,
+ {1, "admin@example.com", "admin@example.com", "Admin",
+ UserRole::ADMINISTRATOR, 1, 0, 0},
+ std::string(64, 'c'),
+ 9999999999};
+ }
+
+ /// Treat configured cards as owned for focused rendering tests.
+ mw::E<bool> userOwnsCard(
+ [[maybe_unused]] std::int64_t user_id,
+ [[maybe_unused]] std::int64_t card_id) const override
+ {
+ return true;
+ }
+
+ /// Return configured cards as the focused test pool.
+ mw::E<std::vector<Card>> getPoolCards() const override
+ {
+ return getCards();
+ }
+};
+
+App::Request authenticatedRequest()
+{
+ App::Request request;
+ request.set_header(
+ "Cookie", "card_collection_session=" + std::string(64, 'a'));
+ return request;
+}
+
} // namespace
/// Verify named routes respect a nested application base path.
@@ -92,7 +133,7 @@ TEST(AppTest, BuildsNamedUrls)
EXPECT_EQ(
app.urlFor("card-index"),
- "https://example.test/collection/");
+ "https://example.test/collection/admin/cards");
EXPECT_EQ(
app.urlFor("card-new"),
"https://example.test/collection/cards/new");
@@ -101,7 +142,7 @@ TEST(AppTest, BuildsNamedUrls)
"https://example.test/collection/cards/PKM%2f2");
EXPECT_EQ(
app.urlFor("series-edit", {"42"}),
- "https://example.test/collection/series/42/edit");
+ "https://example.test/collection/admin/series/42/edit");
}
/// Verify static relative paths and ordered query values are encoded.
@@ -140,7 +181,7 @@ TEST(AppTest, RejectsInvalidRoutes)
/// Verify the index renders escaped fake records in natural ID order.
TEST(AppTest, RendersCardIndex)
{
- auto data_source = std::make_unique<DataSourceFake>(
+ auto data_source = std::make_unique<AuthenticatedDataSource>(
std::vector<Card>{
makeCard(10, 10, "Tenth card"),
makeCard(2, 2, "<script>Second card</script>"),
@@ -150,7 +191,7 @@ TEST(AppTest, RendersCardIndex)
std::move(data_source),
emptyGames(),
deterministicRandom());
- App::Request request;
+ App::Request request = authenticatedRequest();
App::Response response;
app.handleCardIndex(request, response);
@@ -176,10 +217,10 @@ TEST(AppTest, RendersCardCreationForm)
{
App app(
makeConfig("https://example.test/collection/"),
- emptyDataSource(),
+ std::make_unique<AuthenticatedDataSource>(),
emptyGames(),
deterministicRandom());
- App::Request request;
+ App::Request request = authenticatedRequest();
App::Response response;
app.handleCardNew(request, response);
@@ -206,7 +247,7 @@ TEST(AppTest, RendersCardEditForm)
card.identity.game_short_name = std::nullopt;
card.short_description = "A quiet night.";
card.rarity = 4;
- auto data_source = std::make_unique<DataSourceFake>(
+ auto data_source = std::make_unique<AuthenticatedDataSource>(
std::vector<Card>{card});
App app(
makeConfig("https://example.test/collection/"),
@@ -215,7 +256,7 @@ TEST(AppTest, RendersCardEditForm)
deterministicRandom());
auto public_id = formatPublicId(card.identity);
ASSERT_TRUE(public_id);
- App::Request request;
+ App::Request request = authenticatedRequest();
request.path_params.emplace("id", *public_id);
App::Response response;
@@ -243,7 +284,7 @@ TEST(AppTest, RendersCardView)
card.short_description = "A quiet <night>.";
card.long_description = "First line\nSecond line";
card.rarity = 4;
- auto data_source = std::make_unique<DataSourceFake>(
+ auto data_source = std::make_unique<AuthenticatedDataSource>(
std::vector<Card>{card},
std::vector<Series>{
{7, "test", "Night Signals", "A series description"},
@@ -256,7 +297,7 @@ TEST(AppTest, RendersCardView)
std::move(data_source),
testGames(),
deterministicRandom());
- App::Request request;
+ App::Request request = authenticatedRequest();
request.path_params.emplace("id", "test-2");
App::Response response;
@@ -280,10 +321,10 @@ TEST(AppTest, RejectsUnknownCardView)
{
App app(
makeConfig("https://example.test/"),
- emptyDataSource(),
+ std::make_unique<AuthenticatedDataSource>(),
emptyGames(),
deterministicRandom());
- App::Request request;
+ App::Request request = authenticatedRequest();
request.path_params.emplace("id", "PKM-2");
App::Response response;
diff --git a/tests/authentication_test.cpp b/tests/authentication_test.cpp
new file mode 100644
index 0000000..46abf2d
--- /dev/null
+++ b/tests/authentication_test.cpp
@@ -0,0 +1,194 @@
+#include <chrono>
+#include <cstddef>
+#include <cstdint>
+#include <filesystem>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <mw/crypto_mock.hpp>
+
+#include "authentication.h"
+#include "game_registry.h"
+#include "startup.h"
+
+namespace
+{
+
+class TemporaryDatabase
+{
+public:
+ TemporaryDatabase()
+ : path_(
+ std::filesystem::path(testing::TempDir()) /
+ ("authentication_service_" + std::to_string(
+ std::chrono::steady_clock::now()
+ .time_since_epoch().count()) + ".sqlite3"))
+ {}
+
+ ~TemporaryDatabase()
+ {
+ std::error_code error;
+ std::filesystem::remove(path_, error);
+ std::filesystem::remove(path_.string() + "-shm", error);
+ std::filesystem::remove(path_.string() + "-wal", error);
+ }
+
+ const std::filesystem::path& path() const
+ {
+ return path_;
+ }
+
+private:
+ std::filesystem::path path_;
+};
+
+class ClockMock final : public ClockInterface
+{
+public:
+ std::chrono::system_clock::time_point now() const override
+ {
+ return current;
+ }
+
+ std::chrono::system_clock::time_point current =
+ std::chrono::system_clock::time_point(std::chrono::seconds(100));
+};
+
+class EmailSenderMock final : public EmailSenderInterface
+{
+public:
+ mw::E<void> send(const AuthenticationEmail& email) override
+ {
+ last_email = email;
+ if(fail)
+ {
+ return std::unexpected(mw::runtimeError("Sender failed"));
+ }
+ return {};
+ }
+
+ bool usesGlobalQuota() const override
+ {
+ return global_quota;
+ }
+
+ std::optional<AuthenticationEmail> last_email;
+ bool fail = false;
+ bool global_quota = true;
+};
+
+std::string finalPathSegment(const mw::URL& url)
+{
+ const std::string path = url.path();
+ return path.substr(path.find_last_of('/') + 1);
+}
+
+std::vector<std::byte> bytes(unsigned char value)
+{
+ return std::vector<std::byte>(32, static_cast<std::byte>(value));
+}
+
+std::string hexToken(char low_nibble)
+{
+ std::string result;
+ result.reserve(64);
+ for(int index = 0; index < 32; ++index)
+ {
+ result.push_back('0');
+ result.push_back(low_nibble);
+ }
+ return result;
+}
+
+} // namespace
+
+TEST(AuthenticationServiceTest, CompletesSingleUseNonSlidingFlow)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+ auto data_source = prepareDataSource(database.path(), games);
+ ASSERT_TRUE(data_source);
+ auto base_url = mw::URL::fromStr("http://127.0.0.1/app/");
+ ASSERT_TRUE(base_url);
+ ClockMock clock;
+ EmailSenderMock sender;
+ mw::CryptoMock crypto;
+ EXPECT_CALL(crypto, randomBytes(32))
+ .WillOnce(testing::Return(bytes(1)))
+ .WillOnce(testing::Return(bytes(2)))
+ .WillOnce(testing::Return(bytes(3)));
+ AuthenticationService authentication(
+ **data_source, sender, clock, crypto, std::move(*base_url), 10);
+
+ auto requested = authentication.requestEmail("Player@Example.com");
+ ASSERT_TRUE(requested) << requested.error().msg();
+ ASSERT_TRUE(sender.last_email);
+ EXPECT_EQ(sender.last_email->recipient, "Player@Example.com");
+ const std::string challenge_token = finalPathSegment(
+ sender.last_email->confirmation_url);
+ ASSERT_EQ(challenge_token, hexToken('1'));
+ auto opened = authentication.validate(challenge_token);
+ ASSERT_TRUE(opened);
+ ASSERT_TRUE(*opened);
+ auto opened_again = authentication.validate(challenge_token);
+ ASSERT_TRUE(opened_again);
+ ASSERT_TRUE(*opened_again);
+
+ auto established = authentication.confirm(challenge_token);
+ ASSERT_TRUE(established) << established.error().msg();
+ EXPECT_EQ(established->user.email, "Player@Example.com");
+ EXPECT_EQ(established->user.role, UserRole::PLAYER);
+ EXPECT_FALSE(established->user.username);
+ EXPECT_EQ(established->token, hexToken('2'));
+ EXPECT_EQ(
+ established->csrf_token,
+ hexToken('3'));
+ opened = authentication.validate(challenge_token);
+ ASSERT_TRUE(opened);
+ EXPECT_FALSE(*opened);
+
+ clock.current = std::chrono::system_clock::time_point(
+ std::chrono::seconds(200));
+ auto session = authentication.session(established->token);
+ ASSERT_TRUE(session);
+ ASSERT_TRUE(*session);
+ EXPECT_EQ((**session).expires_at, established->expires_at);
+ ASSERT_TRUE(authentication.logout(established->token));
+ session = authentication.session(established->token);
+ ASSERT_TRUE(session);
+ EXPECT_FALSE(*session);
+}
+
+TEST(AuthenticationServiceTest, RateLimitsAndInvalidatesSenderFailure)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+ auto data_source = prepareDataSource(database.path(), games);
+ ASSERT_TRUE(data_source);
+ auto base_url = mw::URL::fromStr("http://127.0.0.1/");
+ ASSERT_TRUE(base_url);
+ ClockMock clock;
+ EmailSenderMock sender;
+ sender.global_quota = false;
+ mw::CryptoMock crypto;
+ EXPECT_CALL(crypto, randomBytes(32))
+ .WillOnce(testing::Return(bytes(4)))
+ .WillOnce(testing::Return(bytes(5)));
+ AuthenticationService authentication(
+ **data_source, sender, clock, crypto, std::move(*base_url), 10);
+
+ ASSERT_TRUE(authentication.requestEmail("person@example.com"));
+ EXPECT_FALSE(authentication.requestEmail("PERSON@example.com"));
+ clock.current = std::chrono::system_clock::time_point(
+ std::chrono::seconds(161));
+ sender.fail = true;
+ EXPECT_FALSE(authentication.requestEmail("other@example.com"));
+ ASSERT_TRUE(sender.last_email);
+ auto challenge = authentication.validate(
+ finalPathSegment(sender.last_email->confirmation_url));
+ ASSERT_TRUE(challenge);
+ EXPECT_FALSE(*challenge);
+}
diff --git a/tests/card_service_test.cpp b/tests/card_service_test.cpp
index bb65c49..642a8e2 100644
--- a/tests/card_service_test.cpp
+++ b/tests/card_service_test.cpp
@@ -2,6 +2,7 @@
#include "data_sqlite.h"
#include "game_registry.h"
#include "multipart_reader.h"
+#include "series_service.h"
#include "startup.h"
#include "test_game.h"
@@ -84,6 +85,34 @@ std::string readFile(const std::filesystem::path& path)
std::istreambuf_iterator<char>());
}
+mw::E<std::unique_ptr<DataSourceInterface>> prepareTestDataSource(
+ const std::filesystem::path& database,
+ const GameRegistry& games)
+{
+ auto data_source = prepareDataSource(database, games);
+ if(!data_source)
+ {
+ return std::unexpected(std::move(data_source.error()));
+ }
+ auto transaction = (*data_source)->beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto updated = (*transaction)->updateUsername(
+ 1, "Administrator", "administrator");
+ if(!updated)
+ {
+ return std::unexpected(std::move(updated.error()));
+ }
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ return std::unexpected(std::move(committed.error()));
+ }
+ return std::move(*data_source);
+}
+
} // namespace
/// Verify card creation publishes normalized assets with its committed row.
@@ -93,7 +122,7 @@ TEST(CardServiceTest, CreatesLooseCard)
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
GameRegistry games;
- auto data_source = prepareDataSource(database, games);
+ auto data_source = prepareTestDataSource(database, games);
ASSERT_TRUE(data_source);
const std::filesystem::path staging =
@@ -107,7 +136,7 @@ TEST(CardServiceTest, CreatesLooseCard)
random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
- auto public_id = service.createLooseCard({
+ auto public_id = service.createLooseCard(1, {
"Moon card",
std::optional<std::string>("Short"),
std::nullopt,
@@ -147,7 +176,7 @@ TEST(CardServiceTest, RejectsInvalidImage)
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
GameRegistry games;
- auto data_source = prepareDataSource(database, games);
+ auto data_source = prepareTestDataSource(database, games);
ASSERT_TRUE(data_source);
const std::filesystem::path staging =
@@ -163,7 +192,7 @@ TEST(CardServiceTest, RejectsInvalidImage)
random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
- auto public_id = service.createLooseCard({
+ auto public_id = service.createLooseCard(1, {
"Bad card",
std::nullopt,
std::nullopt,
@@ -188,7 +217,7 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
GameRegistry games;
- auto data_source = prepareDataSource(database, games);
+ auto data_source = prepareTestDataSource(database, games);
ASSERT_TRUE(data_source);
const std::filesystem::path staging =
@@ -206,7 +235,7 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
- auto public_id = service.createLooseCard({
+ auto public_id = service.createLooseCard(1, {
"Opaque foil",
std::nullopt,
std::nullopt,
@@ -237,7 +266,7 @@ TEST(CardServiceTest, CreatesCompiledGameCard)
ASSERT_TRUE(games.add(std::make_unique<TestGame>()));
const GameDefinition* game = games.find("test");
ASSERT_NE(game, nullptr);
- auto data_source = prepareDataSource(database, games);
+ auto data_source = prepareTestDataSource(database, games);
ASSERT_TRUE(data_source);
auto series_transaction = (*data_source)->beginTransaction();
@@ -260,6 +289,7 @@ TEST(CardServiceTest, CreatesCompiledGameCard)
ImageProcessor(75, 256),
AssetStore(temporary.path()));
auto public_id = service.createGameCard(
+ 1,
{
"Game card",
std::nullopt,
@@ -293,6 +323,7 @@ TEST(CardServiceTest, CreatesCompiledGameCard)
temporary.path() / ".staging/edit";
std::filesystem::create_directory(edit_staging);
auto updated = service.updateGameCard(
+ 1,
{
**stored,
1,
@@ -330,7 +361,7 @@ TEST(CardServiceTest, UpdatesLooseCard)
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
GameRegistry games;
- auto data_source = prepareDataSource(database, games);
+ auto data_source = prepareTestDataSource(database, games);
ASSERT_TRUE(data_source);
const std::filesystem::path create_staging =
@@ -344,7 +375,7 @@ TEST(CardServiceTest, UpdatesLooseCard)
random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
- auto public_id = service.createLooseCard({
+ auto public_id = service.createLooseCard(1, {
"Original",
std::nullopt,
std::nullopt,
@@ -363,7 +394,7 @@ TEST(CardServiceTest, UpdatesLooseCard)
const std::filesystem::path metadata_staging =
temporary.path() / ".staging/metadata";
std::filesystem::create_directory(metadata_staging);
- auto metadata_update = service.updateLooseCard({
+ auto metadata_update = service.updateLooseCard(1, {
original_card,
1,
"Edited metadata",
@@ -392,7 +423,7 @@ TEST(CardServiceTest, UpdatesLooseCard)
const std::filesystem::path stale_staging =
temporary.path() / ".staging/stale";
std::filesystem::create_directory(stale_staging);
- auto stale_update = service.updateLooseCard({
+ auto stale_update = service.updateLooseCard(1, {
original_card,
1,
"Stale edit",
@@ -418,7 +449,7 @@ TEST(CardServiceTest, UpdatesLooseCard)
const std::filesystem::path replacement_front =
artwork_staging / "upload_front";
writeJpeg(replacement_front);
- auto artwork_update = service.updateLooseCard({
+ auto artwork_update = service.updateLooseCard(1, {
cards->front(),
2,
"Edited artwork",
@@ -445,7 +476,7 @@ TEST(CardServiceTest, UpdatesLooseCard)
std::filesystem::exists(published / "front-art.avif"));
EXPECT_TRUE(std::filesystem::is_regular_file(published / "thumb.avif"));
- auto deleted = service.deleteCard(cards->front());
+ auto deleted = service.deleteCard(1, cards->front());
ASSERT_TRUE(deleted) << deleted.error().msg();
cards = (*data_source)->getCards();
ASSERT_TRUE(cards);
@@ -460,7 +491,7 @@ TEST(AssetStoreTest, ReconcilesInterruptedTransitions)
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
GameRegistry games;
- auto data_source = prepareDataSource(database, games);
+ auto data_source = prepareTestDataSource(database, games);
ASSERT_TRUE(data_source);
const std::filesystem::path staging =
temporary.path() / ".staging/upload";
@@ -473,7 +504,7 @@ TEST(AssetStoreTest, ReconcilesInterruptedTransitions)
random,
ImageProcessor(75, 256),
assets);
- auto public_id = service.createLooseCard({
+ auto public_id = service.createLooseCard(1, {
"Recovery card",
std::nullopt,
std::nullopt,
@@ -559,3 +590,86 @@ TEST(MultipartReaderTest, StreamsExpectedFields)
EXPECT_EQ(readFile(*upload->front), image_bytes);
EXPECT_FALSE(upload->foil);
}
+
+/// Verify creator authorship and rarity rules survive direct service calls.
+TEST(CardServiceTest, EnforcesCreatorOwnershipAndRarity)
+{
+ initializeImageMagick();
+ TemporaryCardRoot temporary;
+ const std::filesystem::path database = temporary.path() / "cards.sqlite3";
+ GameRegistry games;
+ ASSERT_TRUE(games.add(std::make_unique<TestGame>()));
+ auto data_source = prepareTestDataSource(database, games);
+ ASSERT_TRUE(data_source);
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ User creator = {
+ 0,
+ "creator@example.com",
+ "creator@example.com",
+ std::nullopt,
+ UserRole::CREATOR,
+ 1,
+ 0,
+ 0};
+ auto creator_id = (*transaction)->insertUser(creator);
+ ASSERT_TRUE(creator_id);
+ ASSERT_TRUE((*transaction)->updateUsername(
+ *creator_id, "Creator", "creator"));
+ ASSERT_TRUE((*transaction)->commit());
+
+ const std::filesystem::path staging =
+ temporary.path() / ".staging/creator";
+ const std::filesystem::path front = staging / "upload_front";
+ std::filesystem::create_directories(staging);
+ writePng(front);
+ NonSecretRandom random(5);
+ CardService service(
+ **data_source,
+ random,
+ ImageProcessor(75, 256),
+ AssetStore(temporary.path()));
+ CreateLooseCardInput input = {
+ "Creator card",
+ std::nullopt,
+ std::nullopt,
+ 9,
+ staging,
+ front,
+ std::nullopt,
+ std::nullopt,
+ false};
+ auto public_id = service.createLooseCard(*creator_id, std::move(input));
+ ASSERT_TRUE(public_id) << public_id.error().msg();
+ auto cards = (*data_source)->getCards();
+ ASSERT_TRUE(cards);
+ ASSERT_EQ(cards->size(), 1);
+ EXPECT_EQ(cards->front().creator_user_id, *creator_id);
+ EXPECT_EQ(cards->front().rarity, 0);
+ EXPECT_FALSE(service.deleteCard(*creator_id, cards->front()));
+
+ const std::filesystem::path tampered_staging =
+ temporary.path() / ".staging/tampered";
+ const std::filesystem::path tampered_front =
+ tampered_staging / "upload_front";
+ std::filesystem::create_directories(tampered_staging);
+ writePng(tampered_front);
+ EXPECT_FALSE(service.createLooseCard(*creator_id, {
+ "Tampered card",
+ std::nullopt,
+ std::nullopt,
+ 1,
+ tampered_staging,
+ tampered_front,
+ std::nullopt,
+ std::nullopt,
+ true,
+ }));
+ SeriesService series_service(**data_source, games);
+ auto forbidden_series = series_service.create(
+ *creator_id, "test", "Creator series", "Not allowed");
+ ASSERT_FALSE(forbidden_series);
+ const auto* http_error = forbidden_series.error().as<mw::HTTPError>();
+ ASSERT_NE(http_error, nullptr);
+ EXPECT_EQ(http_error->code, 403);
+}
diff --git a/tests/config_test.cpp b/tests/config_test.cpp
index 2dec367..2f90762 100644
--- a/tests/config_test.cpp
+++ b/tests/config_test.cpp
@@ -57,6 +57,13 @@ void writeConfig(
<< "card_storage_root = \"var/cards\"\n"
<< "avif_quality = 75\n"
<< "thumbnail_long_side = 256\n"
+ << "administrator_email = \"Admin@Example.com\"\n"
+ << "maximum_accumulated_pulls = 3\n"
+ << "[email]\n"
+ << "transport = \"file\"\n"
+ << "link_file = \""
+ << (path.parent_path() / "auth-link.txt").string()
+ << "\"\n"
<< extra;
}
@@ -84,6 +91,8 @@ TEST(ConfigTest, LoadsTcpConfiguration)
EXPECT_EQ(address.address, "127.0.0.1");
EXPECT_EQ(address.port, 8080);
EXPECT_EQ(config->static_root, temporary.path() / "static");
+ EXPECT_EQ(config->administrator_email_key, "admin@example.com");
+ EXPECT_EQ(config->maximum_accumulated_pulls, 3);
EXPECT_TRUE(std::filesystem::is_directory(
temporary.path() / "var/cards/published"));
}
diff --git a/tests/data_mock.h b/tests/data_mock.h
index b658f16..c38f2f9 100644
--- a/tests/data_mock.h
+++ b/tests/data_mock.h
@@ -35,6 +35,14 @@ public:
(std::uint32_t number),
(override));
+ /// Mock a user read performed under the transaction lock.
+ MOCK_METHOD((mw::E<std::optional<User>>), getUserForUpdate,
+ (std::int64_t user_id), (override));
+
+ /// Mock a normalized-email user read under the transaction lock.
+ MOCK_METHOD((mw::E<std::optional<User>>), getUserByEmailKeyForUpdate,
+ (const std::string& email_key), (override));
+
/// Mock a card read performed under the transaction lock.
MOCK_METHOD(
(mw::E<std::optional<Card>>),
@@ -42,6 +50,79 @@ public:
(std::int64_t card_id),
(override));
+ /// Mock ownership under the transaction lock.
+ MOCK_METHOD((mw::E<bool>), userOwnsCardForUpdate,
+ (std::int64_t user_id, std::int64_t card_id), (override));
+
+ /// Mock a positive-rarity pool read under the transaction lock.
+ MOCK_METHOD((mw::E<std::vector<Card>>), getPoolCardsForUpdate,
+ (), (override));
+
+ /// Mock insertion of a newly confirmed user.
+ MOCK_METHOD((mw::E<std::int64_t>), insertUser,
+ (const User& user), (override));
+
+ /// Mock replacement of a normalized username pair.
+ MOCK_METHOD((mw::E<bool>), updateUsername,
+ (std::int64_t user_id, const std::string& username,
+ const std::string& username_key), (override));
+
+ /// Mock permanent player promotion.
+ MOCK_METHOD((mw::E<bool>), promoteUser,
+ (std::int64_t user_id), (override));
+
+ /// Mock atomic authentication-email capacity reservation.
+ MOCK_METHOD((mw::E<AuthenticationReservation>),
+ reserveAuthenticationEmail,
+ (const std::string& email_key, std::int64_t now,
+ bool use_global_quota, std::int64_t utc_day,
+ std::uint32_t daily_limit), (override));
+
+ /// Mock insertion of a pending authentication challenge.
+ MOCK_METHOD((mw::E<std::int64_t>), insertAuthenticationChallenge,
+ (const std::string& email, const std::string& email_key,
+ const TokenHash& token_hash, std::int64_t created_at,
+ std::int64_t expires_at), (override));
+
+ /// Mock activation of a delivered authentication challenge.
+ MOCK_METHOD((mw::E<bool>), markAuthenticationChallengeDelivered,
+ (std::int64_t challenge_id, std::int64_t delivered_at),
+ (override));
+
+ /// Mock removal of a failed authentication challenge.
+ MOCK_METHOD((mw::E<void>), deleteAuthenticationChallenge,
+ (std::int64_t challenge_id), (override));
+
+ /// Mock conditional authentication challenge consumption.
+ MOCK_METHOD((mw::E<std::optional<AuthenticationChallenge>>),
+ consumeAuthenticationChallenge,
+ (const TokenHash& token_hash, std::int64_t now), (override));
+
+ /// Mock invalidation of an email identity's other challenges.
+ MOCK_METHOD((mw::E<void>), invalidateAuthenticationChallenges,
+ (const std::string& email_key,
+ std::int64_t except_challenge_id, std::int64_t now),
+ (override));
+
+ /// Mock insertion of a non-sliding session.
+ MOCK_METHOD((mw::E<std::int64_t>), insertSession,
+ (std::int64_t user_id, const TokenHash& token_hash,
+ const std::string& csrf_token, std::int64_t created_at,
+ std::int64_t expires_at), (override));
+
+ /// Mock deletion of a session digest.
+ MOCK_METHOD((mw::E<void>), deleteSession,
+ (const TokenHash& token_hash), (override));
+
+ /// Mock persistence of lazily refreshed pull state.
+ MOCK_METHOD((mw::E<void>), updatePullState,
+ (std::int64_t user_id, std::uint32_t stored_pulls,
+ std::int64_t refresh_day), (override));
+
+ /// Mock insertion or increment of one collection holding.
+ MOCK_METHOD((mw::E<std::int64_t>), incrementHolding,
+ (std::int64_t user_id, std::int64_t card_id), (override));
+
/// Mock insertion of a card and its dependent rows.
MOCK_METHOD(
(mw::E<std::int64_t>),
@@ -126,6 +207,14 @@ public:
(),
(const, override));
+ /// Mock retrieval of one creator's authored cards.
+ MOCK_METHOD((mw::E<std::vector<Card>>), getCardsByCreator,
+ (std::int64_t creator_user_id), (const, override));
+
+ /// Mock retrieval of the current positive-rarity pool.
+ MOCK_METHOD((mw::E<std::vector<Card>>), getPoolCards,
+ (), (const, override));
+
/// Mock retrieval of a card by identity.
MOCK_METHOD(
(mw::E<std::optional<Card>>),
@@ -133,6 +222,48 @@ public:
(const CardIdentity& identity),
(const, override));
+ /// Mock retrieval of a user by internal identity.
+ MOCK_METHOD((mw::E<std::optional<User>>), getUser,
+ (std::int64_t user_id), (const, override));
+
+ /// Mock retrieval of a user by normalized immutable email.
+ MOCK_METHOD((mw::E<std::optional<User>>), getUserByEmailKey,
+ (const std::string& email_key), (const, override));
+
+ /// Mock retrieval of all administrator-visible users.
+ MOCK_METHOD((mw::E<std::vector<User>>), getUsers,
+ (), (const, override));
+
+ /// Mock retrieval of a valid joined session.
+ MOCK_METHOD((mw::E<std::optional<SessionContext>>), getSession,
+ (const TokenHash& token_hash, std::int64_t now),
+ (const, override));
+
+ /// Mock read-only authentication challenge validation.
+ MOCK_METHOD((mw::E<std::optional<AuthenticationChallenge>>),
+ getAuthenticationChallenge,
+ (const TokenHash& token_hash, std::int64_t now),
+ (const, override));
+
+ /// Mock retrieval of one user's distinct collection.
+ MOCK_METHOD((mw::E<std::vector<CollectionEntry>>), getCollection,
+ (std::int64_t user_id), (const, override));
+
+ /// Mock a persisted card-ownership query.
+ MOCK_METHOD((mw::E<bool>), userOwnsCard,
+ (std::int64_t user_id, std::int64_t card_id),
+ (const, override));
+
+ /// Mock immutable administrator reconciliation.
+ MOCK_METHOD((mw::E<User>), reconcileAdministrator,
+ (const std::string& email, const std::string& email_key,
+ std::int64_t created_at, std::int64_t pull_refresh_day),
+ (override));
+
+ /// Mock best-effort authentication cleanup.
+ MOCK_METHOD((mw::E<void>), cleanupAuthentication,
+ (std::int64_t now), (override));
+
/// Mock retrieval of game-specific card display fields.
MOCK_METHOD(
(mw::E<std::vector<DisplayField>>),
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
index 5fd3eb8..f177ddd 100644
--- a/tests/data_sqlite_test.cpp
+++ b/tests/data_sqlite_test.cpp
@@ -59,6 +59,7 @@ Card makeLooseCard(std::uint32_t number, std::string name)
std::nullopt,
"avif",
1,
+ 1,
};
}
@@ -112,6 +113,7 @@ TEST(DataSourceSQLiteTest, ReturnsCards)
ASSERT_TRUE((*connection)->execute(
"CREATE TABLE cards ("
"id INTEGER PRIMARY KEY, "
+ "creator_user_id INTEGER NOT NULL, "
"game_short_name TEXT, "
"card_number INTEGER NOT NULL, "
"name TEXT NOT NULL, "
@@ -123,10 +125,10 @@ TEST(DataSourceSQLiteTest, ReturnsCards)
"thumbnail_extension TEXT NOT NULL, "
"revision INTEGER NOT NULL);"));
ASSERT_TRUE((*connection)->execute(
- "INSERT INTO cards VALUES (2, 'pkm', 7, 'Moon card', "
+ "INSERT INTO cards VALUES (2, 1, 'pkm', 7, 'Moon card', "
"'Short', 'Long', 4, 'avif', 'webp', 'avif', 3);"));
ASSERT_TRUE((*connection)->execute(
- "INSERT INTO cards VALUES (1, NULL, 35, 'Loose card', "
+ "INSERT INTO cards VALUES (1, 1, NULL, 35, 'Loose card', "
"NULL, NULL, 0, 'jpg', NULL, 'webp', 1);"));
connection->reset();
@@ -147,6 +149,7 @@ TEST(DataSourceSQLiteTest, ReturnsCards)
EXPECT_EQ((*cards)[0].foil_extension, std::nullopt);
EXPECT_EQ((*cards)[0].thumbnail_extension, "webp");
EXPECT_EQ((*cards)[0].revision, 1);
+ EXPECT_EQ((*cards)[0].creator_user_id, 1);
EXPECT_EQ((*cards)[1].id, 2);
EXPECT_EQ((*cards)[1].identity.game_short_name, "pkm");
@@ -175,12 +178,13 @@ TEST(DataSourceSQLiteTest, RejectsInvalidCards)
ASSERT_TRUE(connection);
ASSERT_TRUE((*connection)->execute(
"CREATE TABLE cards ("
- "id INTEGER, game_short_name TEXT, card_number INTEGER, "
+ "id INTEGER, creator_user_id INTEGER, game_short_name TEXT, "
+ "card_number INTEGER, "
"name TEXT, short_description TEXT, long_description TEXT, "
"rarity INTEGER, front_extension TEXT, foil_extension TEXT, "
"thumbnail_extension TEXT, revision INTEGER);"));
ASSERT_TRUE((*connection)->execute(
- "INSERT INTO cards VALUES (1, NULL, -1, 'Broken', NULL, NULL, "
+ "INSERT INTO cards VALUES (1, 1, NULL, -1, 'Broken', NULL, NULL, "
"0, 'jpg', NULL, 'jpg', 1);"));
connection->reset();
@@ -334,6 +338,7 @@ TEST(DataSourceSQLiteTest, PersistsCompiledGameCards)
std::nullopt,
"avif",
1,
+ 1,
};
auto card_id = (*transaction)->insertCard(
card, game, metadata->get(), {*series_id});
@@ -445,3 +450,191 @@ TEST(DataSourceSQLiteTest, RejectsDuplicateLooseNumber)
EXPECT_FALSE((*second_transaction)->insertCard(
makeLooseCard(8, "Duplicate"), nullptr, nullptr, {}));
}
+
+/// Verify administrator metadata is immutable and exactly one row is kept.
+TEST(DataSourceSQLiteTest, ReconcilesImmutableAdministrator)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+ auto data_source = prepareDataSource(
+ database.path(), games, "Admin@Example.com", "admin@example.com",
+ 100, 0);
+ ASSERT_TRUE(data_source) << data_source.error().msg();
+
+ auto administrator = (*data_source)->getUserByEmailKey(
+ "admin@example.com");
+ ASSERT_TRUE(administrator);
+ ASSERT_TRUE(*administrator);
+ EXPECT_EQ((**administrator).email, "Admin@Example.com");
+ EXPECT_EQ((**administrator).role, UserRole::ADMINISTRATOR);
+ EXPECT_EQ((**administrator).stored_pulls, 1);
+
+ auto same = (*data_source)->reconcileAdministrator(
+ "admin@example.com", "admin@example.com", 200, 1);
+ ASSERT_TRUE(same);
+ EXPECT_EQ(same->id, (**administrator).id);
+ EXPECT_FALSE((*data_source)->reconcileAdministrator(
+ "other@example.com", "other@example.com", 200, 1));
+
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ User second_administrator = {
+ 0,
+ "second@example.com",
+ "second@example.com",
+ std::nullopt,
+ UserRole::ADMINISTRATOR,
+ 1,
+ 0,
+ 100};
+ EXPECT_FALSE((*transaction)->insertUser(second_administrator));
+}
+
+/// Verify challenges, sessions, limits, users, and holdings are transactional.
+TEST(DataSourceSQLiteTest, PersistsMvpAccountAndCollectionState)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+ auto data_source = prepareDataSource(database.path(), games);
+ ASSERT_TRUE(data_source) << data_source.error().msg();
+ auto administrator = (*data_source)->getUserByEmailKey(
+ "admin@example.com");
+ ASSERT_TRUE(administrator);
+ ASSERT_TRUE(*administrator);
+
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ User player = {
+ 0,
+ "Player@Example.com",
+ "player@example.com",
+ std::nullopt,
+ UserRole::PLAYER,
+ 1,
+ 0,
+ 10};
+ auto player_id = (*transaction)->insertUser(player);
+ ASSERT_TRUE(player_id) << player_id.error().msg();
+ ASSERT_TRUE((*transaction)->commit());
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->updateUsername(
+ *player_id, "Player", "player"));
+ ASSERT_TRUE((*transaction)->promoteUser(*player_id));
+ auto reservation = (*transaction)->reserveAuthenticationEmail(
+ "player@example.com", 100, true, 0, 1);
+ ASSERT_TRUE(reservation) << reservation.error().msg();
+ EXPECT_EQ(
+ reservation->status,
+ AuthenticationReservationStatus::RESERVED);
+ ASSERT_TRUE((*transaction)->commit());
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ reservation = (*transaction)->reserveAuthenticationEmail(
+ "player@example.com", 130, true, 0, 1);
+ ASSERT_TRUE(reservation);
+ EXPECT_EQ(
+ reservation->status,
+ AuthenticationReservationStatus::EMAIL_LIMITED);
+ EXPECT_EQ(reservation->retry_after, 30);
+ ASSERT_TRUE((*transaction)->commit());
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ reservation = (*transaction)->reserveAuthenticationEmail(
+ "new@example.com", 161, true, 0, 1);
+ ASSERT_TRUE(reservation);
+ EXPECT_EQ(
+ reservation->status,
+ AuthenticationReservationStatus::GLOBAL_LIMITED);
+ ASSERT_TRUE((*transaction)->commit());
+
+ const TokenHash challenge_hash(32, 7);
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ reservation = (*transaction)->reserveAuthenticationEmail(
+ "new@example.com", 161, false, 0, 1);
+ ASSERT_TRUE(reservation);
+ EXPECT_EQ(
+ reservation->status,
+ AuthenticationReservationStatus::RESERVED);
+ auto challenge_id = (*transaction)->insertAuthenticationChallenge(
+ "new@example.com", "new@example.com", challenge_hash, 161, 761);
+ ASSERT_TRUE(challenge_id) << challenge_id.error().msg();
+ ASSERT_TRUE((*transaction)->commit());
+ auto challenge = (*data_source)->getAuthenticationChallenge(
+ challenge_hash, 200);
+ ASSERT_TRUE(challenge);
+ EXPECT_FALSE(*challenge);
+
+ const TokenHash session_hash(32, 9);
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->markAuthenticationChallengeDelivered(
+ *challenge_id, 170));
+ auto consumed = (*transaction)->consumeAuthenticationChallenge(
+ challenge_hash, 200);
+ ASSERT_TRUE(consumed);
+ ASSERT_TRUE(*consumed);
+ auto session_id = (*transaction)->insertSession(
+ *player_id, session_hash, std::string(64, 'c'), 200, 400);
+ ASSERT_TRUE(session_id) << session_id.error().msg();
+ ASSERT_TRUE((*transaction)->commit());
+
+ auto session = (*data_source)->getSession(session_hash, 300);
+ ASSERT_TRUE(session);
+ ASSERT_TRUE(*session);
+ EXPECT_EQ((**session).user.id, *player_id);
+ EXPECT_EQ((**session).user.role, UserRole::CREATOR);
+ EXPECT_EQ((**session).csrf_token, std::string(64, 'c'));
+ auto expired = (*data_source)->getSession(session_hash, 400);
+ ASSERT_TRUE(expired);
+ EXPECT_FALSE(*expired);
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ Card card = makeLooseCard(123, "Collectible");
+ card.creator_user_id = (**administrator).id;
+ auto card_id = (*transaction)->insertCard(card, nullptr, nullptr, {});
+ ASSERT_TRUE(card_id) << card_id.error().msg();
+ auto quantity = (*transaction)->incrementHolding(*player_id, *card_id);
+ ASSERT_TRUE(quantity);
+ EXPECT_EQ(*quantity, 1);
+ quantity = (*transaction)->incrementHolding(*player_id, *card_id);
+ ASSERT_TRUE(quantity);
+ EXPECT_EQ(*quantity, 2);
+ ASSERT_TRUE((*transaction)->commit());
+
+ auto collection = (*data_source)->getCollection(*player_id);
+ ASSERT_TRUE(collection);
+ ASSERT_EQ(collection->size(), 1);
+ EXPECT_EQ(collection->front().quantity, 2);
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->deleteCard(*card_id));
+ ASSERT_TRUE((*transaction)->commit());
+ collection = (*data_source)->getCollection(*player_id);
+ ASSERT_TRUE(collection);
+ EXPECT_TRUE(collection->empty());
+}
+
+/// Verify unreleased prototype databases fail with actionable guidance.
+TEST(DataSourceSQLiteTest, RejectsObsoletePrototypeSchema)
+{
+ TemporaryDatabase database;
+ auto connection = mw::SQLite::connectFile(database.path().string());
+ ASSERT_TRUE(connection);
+ ASSERT_TRUE((*connection)->execute(
+ "CREATE TABLE cards(id INTEGER PRIMARY KEY);"));
+ ASSERT_TRUE((*connection)->execute("PRAGMA user_version = 1;"));
+ connection->reset();
+
+ auto data_source = DataSourceSQLite::fromFile(database.path());
+ ASSERT_TRUE(data_source);
+ auto version = (*data_source)->getSchemaVersion();
+ ASSERT_FALSE(version);
+ EXPECT_NE(version.error().msg().find("delete and recreate"),
+ std::string::npos);
+}
diff --git a/tests/email_sender_mailjet_test.cpp b/tests/email_sender_mailjet_test.cpp
new file mode 100644
index 0000000..6cee17c
--- /dev/null
+++ b/tests/email_sender_mailjet_test.cpp
@@ -0,0 +1,86 @@
+#include <chrono>
+#include <memory>
+#include <string>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <mw/http_client_mock.hpp>
+
+#include "email_sender_mailjet.h"
+
+TEST(MailjetEmailSenderTest, ConfiguresAndSendsSuccessfulRequest)
+{
+ auto session = std::make_unique<mw::HTTPSessionMock>();
+ mw::HTTPSessionMock* mock = session.get();
+ EXPECT_CALL(*mock, allowedProtocols("https"))
+ .WillOnce(testing::Return(mw::E<void>{}));
+ EXPECT_CALL(*mock, allowedRedirectProtocols("https"))
+ .WillOnce(testing::Return(mw::E<void>{}));
+ EXPECT_CALL(*mock, followRedirects(false));
+ EXPECT_CALL(*mock, maxRedirections(0))
+ .WillOnce(testing::Return(mw::E<void>{}));
+ EXPECT_CALL(*mock, maxSize(64 * 1024))
+ .WillOnce(testing::Return(mw::E<void>{}));
+ EXPECT_CALL(*mock, connectionTimeout(std::chrono::seconds(5)))
+ .WillOnce(testing::Return(mw::E<void>{}));
+ EXPECT_CALL(*mock, transferTimeout(std::chrono::seconds(15)))
+ .WillOnce(testing::Return(mw::E<void>{}));
+ mw::HTTPResponse response(200, R"({"Messages":[{"Status":"success"}]})");
+ EXPECT_CALL(*mock, post(testing::_))
+ .WillOnce(testing::Invoke(
+ [&response](const mw::HTTPRequest& request)
+ {
+ EXPECT_EQ(
+ request.url,
+ "https://api.mailjet.com/v3.1/send");
+ EXPECT_EQ(
+ request.header.at("Content-Type"),
+ "application/json");
+ EXPECT_EQ(
+ request.header.at("Authorization"),
+ "Basic YXBpOnNlY3JldA==");
+ EXPECT_NE(request.request_data.find("person@example.com"),
+ std::string::npos);
+ EXPECT_NE(request.request_data.find("confirm/token"),
+ std::string::npos);
+ return mw::E<const mw::HTTPResponse*>(&response);
+ }));
+ MailjetEmailSender sender(
+ std::move(session),
+ "cards@example.com",
+ "Card Collection",
+ "api",
+ "secret");
+ ASSERT_TRUE(sender.configure());
+ auto url = mw::URL::fromStr("https://cards.example/confirm/token");
+ ASSERT_TRUE(url);
+
+ auto sent = sender.send({
+ "person@example.com",
+ std::move(*url),
+ std::chrono::system_clock::now() + std::chrono::minutes(10),
+ });
+
+ ASSERT_TRUE(sent) << sent.error().msg();
+ EXPECT_TRUE(sender.usesGlobalQuota());
+}
+
+TEST(MailjetEmailSenderTest, RejectsUpstreamFailureAndInvalidJson)
+{
+ auto session = std::make_unique<mw::HTTPSessionMock>();
+ mw::HTTPSessionMock* mock = session.get();
+ mw::HTTPResponse response(502, "failure");
+ EXPECT_CALL(*mock, post(testing::_))
+ .WillOnce(testing::Return(
+ mw::E<const mw::HTTPResponse*>(&response)));
+ MailjetEmailSender sender(
+ std::move(session), "a@example.com", "Cards", "api", "secret");
+ auto url = mw::URL::fromStr("https://cards.example/confirm/token");
+ ASSERT_TRUE(url);
+
+ EXPECT_FALSE(sender.send({
+ "person@example.com",
+ std::move(*url),
+ std::chrono::system_clock::now(),
+ }));
+}
diff --git a/tests/mvp_primitives_test.cpp b/tests/mvp_primitives_test.cpp
new file mode 100644
index 0000000..9e11fc0
--- /dev/null
+++ b/tests/mvp_primitives_test.cpp
@@ -0,0 +1,177 @@
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <string>
+#include <filesystem>
+#include <fstream>
+#include <vector>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <mw/crypto_mock.hpp>
+
+#include "authorization.h"
+#include "card_pool.h"
+#include "email_address.h"
+#include "email_sender_file.h"
+#include "secret_token.h"
+#include "username.h"
+
+namespace
+{
+
+Card card(std::int64_t id, std::int64_t rarity,
+ std::int64_t creator_user_id = 1)
+{
+ Card result{};
+ result.id = id;
+ result.rarity = rarity;
+ result.creator_user_id = creator_user_id;
+ return result;
+}
+
+User user(std::int64_t id, UserRole role)
+{
+ return {id, "user@example.com", "user@example.com", "User", role,
+ 1, 0, 0};
+}
+
+TEST(EmailAddressTest, NormalizesEquivalentAsciiAddresses)
+{
+ auto address = normalizeEmail(" Name@Example.COM\t");
+ ASSERT_TRUE(address) << address.error().msg();
+ EXPECT_EQ(address->email, "Name@Example.COM");
+ EXPECT_EQ(address->key, "name@example.com");
+}
+
+TEST(EmailAddressTest, RejectsInvalidDotAtomAndDomainLabels)
+{
+ EXPECT_FALSE(normalizeEmail("a..b@example.com"));
+ EXPECT_FALSE(normalizeEmail("a@-example.com"));
+ EXPECT_FALSE(normalizeEmail("a@example..com"));
+ EXPECT_FALSE(normalizeEmail("a\xC3\xA9@example.com"));
+}
+
+TEST(UsernameTest, NormalizesAndFullyCaseFolds)
+{
+ auto composed = normalizeUsername("\xC3\xA9");
+ auto decomposed = normalizeUsername("e\xCC\x81");
+ ASSERT_TRUE(composed);
+ ASSERT_TRUE(decomposed);
+ EXPECT_EQ(composed->username, decomposed->username);
+
+ auto sharp_s = normalizeUsername("Stra\xC3\x9F" "e");
+ auto letters = normalizeUsername("STRASSE");
+ ASSERT_TRUE(sharp_s);
+ ASSERT_TRUE(letters);
+ EXPECT_EQ(sharp_s->key, letters->key);
+}
+
+TEST(UsernameTest, EnforcesBoundariesAndControls)
+{
+ EXPECT_TRUE(normalizeUsername(std::string(32, 'a')));
+ EXPECT_FALSE(normalizeUsername(std::string(33, 'a')));
+ EXPECT_FALSE(normalizeUsername(" name"));
+ EXPECT_FALSE(normalizeUsername("name\xE3\x80\x80"));
+ EXPECT_FALSE(normalizeUsername("bad\nname"));
+ EXPECT_FALSE(normalizeUsername(std::string("\xC3", 1)));
+}
+
+TEST(AuthorizationTest, EnforcesOwnershipAndRoleExceptions)
+{
+ AuthorizationService authorization;
+ const Card authored = card(10, 1, 2);
+ const User player = user(1, UserRole::PLAYER);
+ const User creator = user(2, UserRole::CREATOR);
+ const User other_creator = user(3, UserRole::CREATOR);
+ const User administrator = user(4, UserRole::ADMINISTRATOR);
+
+ EXPECT_TRUE(authorization.canViewCard(player, authored, true));
+ EXPECT_FALSE(authorization.canViewCard(player, authored, false));
+ EXPECT_TRUE(authorization.canViewCard(creator, authored, false));
+ EXPECT_FALSE(authorization.canEditCard(other_creator, authored));
+ EXPECT_TRUE(authorization.canEditCard(administrator, authored));
+ EXPECT_FALSE(authorization.canSetRarity(creator));
+ EXPECT_TRUE(authorization.canSetRarity(administrator));
+}
+
+TEST(CardPoolTest, CalculatesRequiredRarityRatio)
+{
+ CardPoolService pool;
+ const auto entries = pool.calculate({card(3, 2), card(2, 1), card(1, 0)});
+ ASSERT_EQ(entries.size(), 2);
+ EXPECT_EQ(entries[0].card.id, 2);
+ EXPECT_DOUBLE_EQ(entries[0].scaled_weight, 1.0);
+ EXPECT_DOUBLE_EQ(entries[1].scaled_weight, 0.5);
+ EXPECT_NEAR(entries[0].probability, 2.0 / 3.0, 1e-15);
+ EXPECT_NEAR(entries[1].probability, 1.0 / 3.0, 1e-15);
+}
+
+TEST(CardPoolTest, SelectsAtPortableRandomBoundaries)
+{
+ CardPoolService pool;
+ const auto entries = pool.calculate({card(1, 1), card(2, 1)});
+ mw::CryptoMock crypto;
+ EXPECT_CALL(crypto, randomBytes(8))
+ .WillOnce(testing::Return(std::vector<std::byte>(8)))
+ .WillOnce(testing::Return(std::vector<std::byte>{
+ std::byte{0xff}, std::byte{0xff}, std::byte{0xff},
+ std::byte{0xff}, std::byte{0xff}, std::byte{0xff},
+ std::byte{0xff}, std::byte{0xff}}));
+ auto first = pool.select(entries, crypto);
+ auto last = pool.select(entries, crypto);
+ ASSERT_TRUE(first);
+ ASSERT_TRUE(last);
+ EXPECT_EQ(first->card.id, 1);
+ EXPECT_EQ(last->card.id, 2);
+}
+
+TEST(CardPoolTest, FormatsProbabilityForCardViews)
+{
+ EXPECT_EQ(formatProbability(0), "0%");
+ EXPECT_EQ(formatProbability(1), "100%");
+ EXPECT_EQ(formatProbability(1.0 / 3.0), "33.333333%");
+ EXPECT_EQ(formatProbability(0.000000001), "<0.000001%");
+}
+
+TEST(SecretTokenTest, RejectsNonCanonicalCredentials)
+{
+ EXPECT_FALSE(hashSecretToken(std::string(63, '0')));
+ EXPECT_FALSE(hashSecretToken(std::string(64, 'A')));
+ EXPECT_TRUE(hashSecretToken(std::string(64, '0')));
+ EXPECT_TRUE(constantTimeEqual("secret", "secret"));
+ EXPECT_FALSE(constantTimeEqual("secret", "secreu"));
+ EXPECT_FALSE(constantTimeEqual("secret", "secret-long"));
+}
+
+TEST(FileEmailSenderTest, AtomicallyPublishesPrivateLatestLink)
+{
+ const std::filesystem::path target =
+ std::filesystem::path(testing::TempDir()) /
+ "card_collection_auth_link.txt";
+ std::error_code ignored;
+ std::filesystem::remove(target, ignored);
+ auto url = mw::URL::fromStr("http://127.0.0.1/confirm/secret");
+ ASSERT_TRUE(url);
+ FileEmailSender sender(target);
+
+ auto sent = sender.send({
+ "person@example.com",
+ std::move(*url),
+ std::chrono::system_clock::now(),
+ });
+
+ ASSERT_TRUE(sent) << sent.error().msg();
+ std::ifstream input(target);
+ std::string contents;
+ std::getline(input, contents);
+ EXPECT_EQ(contents, "http://127.0.0.1/confirm/secret");
+ const auto permissions = std::filesystem::status(target).permissions();
+ EXPECT_EQ(
+ permissions & (std::filesystem::perms::group_all |
+ std::filesystem::perms::others_all),
+ std::filesystem::perms::none);
+ std::filesystem::remove(target, ignored);
+}
+
+} // namespace
diff --git a/tests/user_collection_test.cpp b/tests/user_collection_test.cpp
new file mode 100644
index 0000000..b05ac26
--- /dev/null
+++ b/tests/user_collection_test.cpp
@@ -0,0 +1,222 @@
+#include <chrono>
+#include <cstddef>
+#include <cstdint>
+#include <filesystem>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <mw/crypto_mock.hpp>
+
+#include "collection.h"
+#include "data_sqlite.h"
+#include "game_registry.h"
+#include "startup.h"
+#include "user_service.h"
+
+namespace
+{
+
+class TemporaryDatabase
+{
+public:
+ TemporaryDatabase()
+ : path_(
+ std::filesystem::path(testing::TempDir()) /
+ ("collection_service_" + std::to_string(
+ std::chrono::steady_clock::now()
+ .time_since_epoch().count()) + ".sqlite3"))
+ {}
+
+ ~TemporaryDatabase()
+ {
+ std::error_code error;
+ std::filesystem::remove(path_, error);
+ std::filesystem::remove(path_.string() + "-shm", error);
+ std::filesystem::remove(path_.string() + "-wal", error);
+ }
+
+ const std::filesystem::path& path() const
+ {
+ return path_;
+ }
+
+private:
+ std::filesystem::path path_;
+};
+
+class ClockMock final : public ClockInterface
+{
+public:
+ std::chrono::system_clock::time_point now() const override
+ {
+ return current;
+ }
+
+ std::chrono::system_clock::time_point current{};
+};
+
+mw::E<std::int64_t> insertPlayer(
+ DataSourceInterface& data_source,
+ const std::string& email,
+ std::uint32_t pulls = 1)
+{
+ auto transaction = data_source.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ User user = {
+ 0,
+ email,
+ email,
+ std::nullopt,
+ UserRole::PLAYER,
+ pulls,
+ 0,
+ 0};
+ auto id = (*transaction)->insertUser(user);
+ if(!id)
+ {
+ return std::unexpected(std::move(id.error()));
+ }
+ auto username = (*transaction)->updateUsername(
+ *id, email.substr(0, email.find('@')), email);
+ if(!username)
+ {
+ return std::unexpected(std::move(username.error()));
+ }
+ auto commit = (*transaction)->commit();
+ if(!commit)
+ {
+ return std::unexpected(std::move(commit.error()));
+ }
+ return *id;
+}
+
+mw::E<std::int64_t> insertPoolCard(
+ DataSourceInterface& data_source,
+ std::int64_t creator_user_id,
+ std::uint32_t number,
+ std::int64_t rarity)
+{
+ auto transaction = data_source.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ Card card = {
+ 0,
+ {std::nullopt, number},
+ "Pool card",
+ std::nullopt,
+ std::nullopt,
+ rarity,
+ "jpg",
+ std::nullopt,
+ "jpg",
+ 1,
+ creator_user_id};
+ auto id = (*transaction)->insertCard(card, nullptr, nullptr, {});
+ if(!id)
+ {
+ return std::unexpected(std::move(id.error()));
+ }
+ auto commit = (*transaction)->commit();
+ if(!commit)
+ {
+ return std::unexpected(std::move(commit.error()));
+ }
+ return *id;
+}
+
+} // namespace
+
+TEST(UserServiceTest, OnboardsAndPermanentlyPromotesPlayer)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+ auto data_source = prepareDataSource(database.path(), games);
+ ASSERT_TRUE(data_source);
+ auto administrator = (*data_source)->getUserByEmailKey(
+ "admin@example.com");
+ ASSERT_TRUE(administrator);
+ ASSERT_TRUE(*administrator);
+ auto player_id = insertPlayer(**data_source, "player@example.com");
+ ASSERT_TRUE(player_id);
+ UserService users(**data_source);
+
+ auto renamed = users.setUsername(*player_id, "Stra\xC3\x9F" "e");
+ ASSERT_TRUE(renamed) << renamed.error().msg();
+ EXPECT_EQ(renamed->username, "Stra\xC3\x9F" "e");
+ auto promoted = users.promote((**administrator).id, *player_id);
+ ASSERT_TRUE(promoted) << promoted.error().msg();
+ EXPECT_EQ(promoted->role, UserRole::CREATOR);
+ auto repeated = users.promote((**administrator).id, *player_id);
+ ASSERT_TRUE(repeated);
+ EXPECT_EQ(repeated->role, UserRole::CREATOR);
+ EXPECT_FALSE(users.promote(*player_id, (**administrator).id));
+}
+
+TEST(CollectionServiceTest, AccruesCapsAndPullsAtomically)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+ auto data_source = prepareDataSource(database.path(), games);
+ ASSERT_TRUE(data_source);
+ auto administrator = (*data_source)->getUserByEmailKey(
+ "admin@example.com");
+ ASSERT_TRUE(administrator);
+ ASSERT_TRUE(*administrator);
+ auto player_id = insertPlayer(**data_source, "collector@example.com");
+ ASSERT_TRUE(player_id);
+ auto card_id = insertPoolCard(
+ **data_source, (**administrator).id, 42, 1);
+ ASSERT_TRUE(card_id);
+ ClockMock clock;
+ mw::CryptoMock crypto;
+ EXPECT_CALL(crypto, randomBytes(8))
+ .WillOnce(testing::Return(std::vector<std::byte>(8)));
+ CollectionService collection(**data_source, clock, crypto, 3);
+
+ auto pulled = collection.pull(*player_id);
+ ASSERT_TRUE(pulled) << pulled.error().msg();
+ EXPECT_EQ(pulled->card.id, *card_id);
+ EXPECT_EQ(pulled->quantity, 1);
+ EXPECT_FALSE(collection.pull(*player_id));
+
+ clock.current = std::chrono::system_clock::time_point(
+ std::chrono::days(10));
+ auto refreshed = collection.refresh(*player_id);
+ ASSERT_TRUE(refreshed);
+ EXPECT_EQ(refreshed->stored_pulls, 3);
+ EXPECT_EQ(refreshed->pull_refresh_day, 10);
+ clock.current = std::chrono::system_clock::time_point(
+ std::chrono::days(9));
+ refreshed = collection.refresh(*player_id);
+ ASSERT_TRUE(refreshed);
+ EXPECT_EQ(refreshed->stored_pulls, 3);
+ EXPECT_EQ(refreshed->pull_refresh_day, 10);
+}
+
+TEST(CollectionServiceTest, EmptyPoolPreservesRefreshedPull)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+ auto data_source = prepareDataSource(database.path(), games);
+ ASSERT_TRUE(data_source);
+ auto player_id = insertPlayer(**data_source, "empty@example.com");
+ ASSERT_TRUE(player_id);
+ ClockMock clock;
+ mw::CryptoMock crypto;
+ CollectionService collection(**data_source, clock, crypto, 3);
+
+ EXPECT_FALSE(collection.pull(*player_id));
+ auto user = (*data_source)->getUser(*player_id);
+ ASSERT_TRUE(user);
+ ASSERT_TRUE(*user);
+ EXPECT_EQ((**user).stored_pulls, 1);
+}