Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 55e8d01..0623c28 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -19,12 +19,17 @@ add_executable(
src/app.cpp
src/asset_store.cpp
src/card_service.cpp
+ src/config.cpp
src/data.cpp
src/data_sqlite.cpp
+ src/game_registry.cpp
src/image_processor.cpp
+ src/markdown_renderer.cpp
src/main.cpp
src/multipart_reader.cpp
+ src/non_secret_random.cpp
src/public_id.cpp
+ src/series_service.cpp
src/startup.cpp
src/url_builder.cpp
)
@@ -93,15 +98,70 @@ if(CARD_COLLECTION_BUILD_TESTS)
include(GoogleTest)
gtest_discover_tests(data_mock_test)
+ add_executable(
+ config_test
+ src/config.cpp
+ src/non_secret_random.cpp
+ tests/config_test.cpp
+ )
+ target_compile_features(config_test PRIVATE cxx_std_23)
+ set_target_properties(config_test PROPERTIES CXX_EXTENSIONS OFF)
+ target_include_directories(
+ config_test
+ PRIVATE
+ ${libmw_SOURCE_DIR}/includes
+ src
+ )
+ target_link_libraries(
+ config_test
+ PRIVATE
+ GTest::gtest_main
+ mw::http-server
+ mw::mw
+ mw::url
+ tomlplusplus::tomlplusplus
+ )
+ gtest_discover_tests(config_test)
+
+ add_executable(
+ markdown_renderer_test
+ src/markdown_renderer.cpp
+ tests/markdown_renderer_test.cpp
+ )
+ target_compile_features(markdown_renderer_test PRIVATE cxx_std_23)
+ set_target_properties(
+ markdown_renderer_test
+ PROPERTIES CXX_EXTENSIONS OFF
+ )
+ target_include_directories(
+ markdown_renderer_test
+ PRIVATE
+ ${libmw_SOURCE_DIR}/includes
+ src
+ )
+ target_link_libraries(
+ markdown_renderer_test
+ PRIVATE
+ GTest::gtest_main
+ MacroDown::MacroDown
+ mw::mw
+ mw::url
+ )
+ gtest_discover_tests(markdown_renderer_test)
+
add_executable(
app_test
src/app.cpp
src/asset_store.cpp
src/card_service.cpp
+ src/game_registry.cpp
+ src/non_secret_random.cpp
src/data_fake.cpp
src/image_processor.cpp
+ src/markdown_renderer.cpp
src/multipart_reader.cpp
src/public_id.cpp
+ src/series_service.cpp
src/url_builder.cpp
tests/app_test.cpp
)
@@ -120,7 +180,9 @@ if(CARD_COLLECTION_BUILD_TESTS)
ImageMagick::Magick++
ImageMagick::MagickWand
ImageMagick::MagickCore
+ MacroDown::MacroDown
mw::http-server
+ mw::sqlite
mw::url
pantor::inja
spdlog::spdlog
@@ -132,6 +194,57 @@ if(CARD_COLLECTION_BUILD_TESTS)
)
gtest_discover_tests(app_test)
+ add_executable(
+ app_integration_test
+ src/app.cpp
+ src/asset_store.cpp
+ src/card_service.cpp
+ src/data.cpp
+ src/data_sqlite.cpp
+ src/game_registry.cpp
+ src/image_processor.cpp
+ src/markdown_renderer.cpp
+ src/multipart_reader.cpp
+ src/non_secret_random.cpp
+ src/public_id.cpp
+ src/series_service.cpp
+ src/startup.cpp
+ src/url_builder.cpp
+ tests/app_integration_test.cpp
+ )
+ target_compile_features(app_integration_test PRIVATE cxx_std_23)
+ set_target_properties(
+ app_integration_test
+ PROPERTIES CXX_EXTENSIONS OFF
+ )
+ target_include_directories(
+ app_integration_test
+ PRIVATE
+ ${libmw_SOURCE_DIR}/includes
+ src
+ )
+ target_compile_definitions(
+ app_integration_test
+ PRIVATE
+ CARD_COLLECTION_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
+ )
+ target_link_libraries(
+ app_integration_test
+ PRIVATE
+ GTest::gtest_main
+ ImageMagick::Magick++
+ ImageMagick::MagickWand
+ ImageMagick::MagickCore
+ MacroDown::MacroDown
+ mw::http-server
+ mw::mw
+ mw::sqlite
+ mw::url
+ pantor::inja
+ spdlog::spdlog
+ )
+ gtest_discover_tests(app_integration_test)
+
add_executable(
data_fake_test
src/data_fake.cpp
@@ -157,6 +270,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
data_sqlite_test
src/data.cpp
src/data_sqlite.cpp
+ src/game_registry.cpp
src/startup.cpp
tests/data_sqlite_test.cpp
)
@@ -184,8 +298,11 @@ if(CARD_COLLECTION_BUILD_TESTS)
src/card_service.cpp
src/data.cpp
src/data_sqlite.cpp
+ src/game_registry.cpp
src/image_processor.cpp
+ src/markdown_renderer.cpp
src/multipart_reader.cpp
+ src/non_secret_random.cpp
src/public_id.cpp
src/startup.cpp
tests/card_service_test.cpp
@@ -208,10 +325,25 @@ if(CARD_COLLECTION_BUILD_TESTS)
ImageMagick::Magick++
ImageMagick::MagickWand
ImageMagick::MagickCore
+ MacroDown::MacroDown
mw::http-server
mw::mw
mw::sqlite
spdlog::spdlog
)
gtest_discover_tests(card_service_test)
+
+ find_program(NODE_EXECUTABLE node)
+ if(NODE_EXECUTABLE)
+ add_test(
+ NAME CardFormLogicTest
+ COMMAND ${NODE_EXECUTABLE} --test
+ ${CMAKE_CURRENT_SOURCE_DIR}/tests/card_form_logic_test.js
+ )
+ add_test(
+ NAME CardPreviewMathTest
+ COMMAND ${NODE_EXECUTABLE} --test
+ ${CMAKE_CURRENT_SOURCE_DIR}/tests/card_preview_math_test.js
+ )
+ endif()
endif()
diff --git a/config.example.toml b/config.example.toml
new file mode 100644
index 0000000..6a46f20
--- /dev/null
+++ b/config.example.toml
@@ -0,0 +1,8 @@
+base_url = "http://127.0.0.1:8080/"
+listen_address = "127.0.0.1"
+listen_port = 8080
+static_root = "static"
+database_path = "var/card_collection.sqlite3"
+card_storage_root = "var/cards"
+avif_quality = 75
+thumbnail_long_side = 256
diff --git a/prd.md b/prd.md
index ba57ddf..ce5417b 100644
--- a/prd.md
+++ b/prd.md
@@ -120,8 +120,9 @@ existing game's schema require the normal versioned migration process.
## Image inputs and storage formats
-Artwork accepts JPEG, PNG, WebP, and AVIF. Foil-control textures accept PNG,
-WebP, and AVIF because they require an alpha channel.
+Artwork and foil-control textures accept JPEG, PNG, WebP, and AVIF. Neither
+image requires an alpha channel; WebGL treats a missing alpha channel as fully
+opaque.
PNG inputs are converted to AVIF using a quality setting from the
configuration file. JPEG, WebP, and AVIF inputs retain their source formats.
diff --git a/src/app.cpp b/src/app.cpp
index 27cb90b..2382566 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -16,9 +16,11 @@
#include <vector>
#include <spdlog/spdlog.h>
+#include <mw/utils.hpp>
#include "public_id.h"
#include "multipart_reader.h"
+#include "game_definition.h"
namespace
{
@@ -41,6 +43,12 @@ struct IndexCard
std::string public_id;
};
+struct HtmlSubstitution
+{
+ std::string marker;
+ RenderedHtml html;
+};
+
RouteSegment literal(std::string value)
{
return {RouteSegmentKind::LITERAL, std::move(value)};
@@ -194,6 +202,40 @@ void respondBadRequest(
response.set_content(message + "\n", "text/plain; charset=utf-8");
}
+mw::E<std::string> renderWithHtml(
+ inja::Environment& environment,
+ const inja::Template& page_template,
+ const inja::json& data,
+ const std::vector<HtmlSubstitution>& substitutions)
+{
+ std::string output;
+ try
+ {
+ output = environment.render(page_template, data);
+ }
+ catch(const std::exception& error)
+ {
+ return std::unexpected(mw::runtimeError(error.what()));
+ }
+ for(const HtmlSubstitution& substitution : substitutions)
+ {
+ const std::size_t position = output.find(substitution.marker);
+ if(position == std::string::npos ||
+ output.find(
+ substitution.marker,
+ position + substitution.marker.size()) != std::string::npos)
+ {
+ return std::unexpected(mw::runtimeError(
+ "A trusted HTML marker was not rendered exactly once"));
+ }
+ output.replace(
+ position,
+ substitution.marker.size(),
+ substitution.html.value());
+ }
+ return output;
+}
+
void respondOperationError(
App::Response& response,
const mw::Error& error,
@@ -211,22 +253,6 @@ void respondOperationError(
respondInternalError(response);
}
-std::string trimAscii(std::string value)
-{
- const auto is_space = [](unsigned char character)
- {
- return std::isspace(character) != 0;
- };
- const auto begin = std::ranges::find_if_not(value, is_space);
- const auto end = std::find_if_not(value.rbegin(), value.rend(), is_space)
- .base();
- if(begin >= end)
- {
- return {};
- }
- return std::string(begin, end);
-}
-
std::optional<std::string> optionalText(
const std::unordered_map<std::string, std::string>& fields,
const std::string& name)
@@ -236,7 +262,7 @@ std::optional<std::string> optionalText(
{
return std::nullopt;
}
- std::string value = trimAscii(position->second);
+ std::string value(mw::strip(position->second));
if(value.empty())
{
return std::nullopt;
@@ -286,6 +312,68 @@ mw::E<std::int64_t> parseRevision(
return revision;
}
+std::optional<std::int64_t> parsePositiveId(const std::string& value)
+{
+ std::int64_t id = 0;
+ const auto result = std::from_chars(
+ value.data(), value.data() + value.size(), id);
+ if(value.empty() || result.ec != std::errc{} ||
+ result.ptr != value.data() + value.size() || id < 1)
+ {
+ return std::nullopt;
+ }
+ return id;
+}
+
+FormFields gameFields(
+ const std::unordered_map<std::string, std::string>& fields)
+{
+ FormFields result;
+ for(const auto& [name, value] : fields)
+ {
+ if(name.starts_with("game.") && name.size() > 5)
+ {
+ result.emplace(name.substr(5), value);
+ }
+ }
+ return result;
+}
+
+mw::E<std::vector<std::int64_t>> seriesMemberships(
+ const std::vector<std::string>& values,
+ const std::string& game_short_name,
+ DataSourceInterface& data_source)
+{
+ std::vector<std::int64_t> result;
+ result.reserve(values.size());
+ for(const std::string& value : values)
+ {
+ auto id = parsePositiveId(value);
+ if(!id)
+ {
+ return std::unexpected(mw::httpError(
+ 422, "Series IDs must be positive integers"));
+ }
+ auto series = data_source.getSeries(*id);
+ if(!series)
+ {
+ return std::unexpected(std::move(series.error()));
+ }
+ if(!*series || (**series).game_short_name != game_short_name)
+ {
+ return std::unexpected(mw::httpError(
+ 422, "A selected series does not belong to the card game"));
+ }
+ if(std::ranges::find(result, *id) != result.end())
+ {
+ return std::unexpected(mw::httpError(
+ 422, "A series was selected more than once"));
+ }
+ result.push_back(*id);
+ }
+ return result;
+}
+
bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
{
std::error_code filesystem_error;
@@ -306,22 +394,30 @@ bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
App::App(
const Config& config,
- std::unique_ptr<DataSourceInterface> data_source)
+ std::unique_ptr<DataSourceInterface> data_source,
+ std::unique_ptr<GameRegistry> games,
+ std::unique_ptr<NonSecretRandom> random)
: mw::HTTPServer(config.listen_address),
config_(config),
data_source_(std::move(data_source)),
+ games_(std::move(games)),
+ random_(std::move(random)),
url_builder_(config.base_url),
templates_(config.static_root.parent_path() / "templates")
{
- if(!data_source_)
+ if(!data_source_ || !games_ || !random_)
{
- throw std::invalid_argument("App requires a data source");
+ throw std::invalid_argument(
+ "App requires data, games, and a random generator");
}
card_service_ = std::make_unique<CardService>(
*data_source_,
+ *random_,
ImageProcessor(config_.avif_quality, config_.thumbnail_long_side),
AssetStore(config_.card_storage_root));
+ series_service_ = std::make_unique<SeriesService>(
+ *data_source_, *games_);
templates_.set_html_autoescape(true);
templates_.add_callback(
@@ -354,14 +450,59 @@ App::App(
arguments);
});
card_form_template_ = templates_.parse_template("card_form.html");
+ card_delete_template_ = templates_.parse_template("card_delete.html");
card_index_template_ = templates_.parse_template("card_index.html");
card_view_template_ = templates_.parse_template("card_view.html");
+ series_delete_template_ = templates_.parse_template(
+ "series_delete.html");
+ series_form_template_ = templates_.parse_template("series_form.html");
+ series_index_template_ = templates_.parse_template("series_index.html");
}
void App::handleCardNew(
[[maybe_unused]] const Request& request,
Response& response)
{
+ inja::json games = inja::json::array();
+ for(const GameDefinition* game : games_->games())
+ {
+ inja::json fields = inja::json::array();
+ for(const GameFormField& field : game->formFields())
+ {
+ fields.push_back({
+ {"input_type", field.input_type},
+ {"label", field.label},
+ {"minimum", field.minimum},
+ {"name", field.name},
+ {"required", field.required},
+ {"value", ""},
+ });
+ }
+ games.push_back({
+ {"display_name", std::string(game->displayName())},
+ {"fields", std::move(fields)},
+ {"short_name", std::string(game->shortName())},
+ });
+ }
+ inja::json series = inja::json::array();
+ auto series_result = data_source_->getSeries();
+ if(!series_result)
+ {
+ spdlog::error(
+ "Failed to load series for card form: {}",
+ series_result.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ for(const Series& item : *series_result)
+ {
+ series.push_back({
+ {"game", item.game_short_name},
+ {"id", item.id},
+ {"name", item.name},
+ {"selected", false},
+ });
+ }
const inja::json template_data = {
{"action_url", urlFor("cards")},
{"back_url", urlFor("card-index")},
@@ -369,6 +510,7 @@ void App::handleCardNew(
{"foil_action", "keep"},
{"foil_url", ""},
{"front_url", urlFor("static", {"card_placeholder.svg"})},
+ {"games", std::move(games)},
{"has_foil", false},
{"heading", "Create card"},
{"long_description", ""},
@@ -385,6 +527,8 @@ void App::handleCardNew(
urlFor("static", {"foil/spectral_xyz.bin"})},
{"rarity", 0},
{"revision", 0},
+ {"selected_game", ""},
+ {"series", std::move(series)},
{"short_description", ""},
{"submit_label", "Create card"},
{"thumbnail_long_side", config_.thumbnail_long_side},
@@ -439,13 +583,6 @@ void App::handleCardEdit(
return;
}
const Card& card = **card_result;
- if(card.identity.game_short_name)
- {
- respondBadRequest(
- response, "Game-specific cards are not implemented yet");
- return;
- }
-
auto public_id_result = formatPublicId(card.identity);
if(!public_id_result)
{
@@ -468,6 +605,75 @@ void App::handleCardEdit(
{{"v", std::to_string(card.revision)}});
}
+ FormFields current_game_values;
+ const GameDefinition* selected_game = nullptr;
+ if(card.identity.game_short_name)
+ {
+ selected_game = games_->find(*card.identity.game_short_name);
+ if(selected_game == nullptr)
+ {
+ respondInternalError(response);
+ return;
+ }
+ auto values = data_source_->getGameFormValues(
+ *selected_game, card.id);
+ if(!values)
+ {
+ spdlog::error(
+ "Failed to load game fields for card {}: {}",
+ card.id,
+ values.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ current_game_values = std::move(*values);
+ }
+
+ inja::json games = inja::json::array();
+ for(const GameDefinition* game : games_->games())
+ {
+ inja::json fields = inja::json::array();
+ for(const GameFormField& field : game->formFields())
+ {
+ const auto value = current_game_values.find(field.name);
+ fields.push_back({
+ {"input_type", field.input_type},
+ {"label", field.label},
+ {"minimum", field.minimum},
+ {"name", field.name},
+ {"required", field.required},
+ {"value", value == current_game_values.end()
+ ? std::string()
+ : value->second},
+ });
+ }
+ games.push_back({
+ {"display_name", std::string(game->displayName())},
+ {"fields", std::move(fields)},
+ {"short_name", std::string(game->shortName())},
+ });
+ }
+
+ auto all_series = data_source_->getSeries();
+ auto memberships = data_source_->getCardSeries(card.id);
+ if(!all_series || !memberships)
+ {
+ spdlog::error("Failed to load series for card {} edit form", card.id);
+ respondInternalError(response);
+ return;
+ }
+ inja::json series = inja::json::array();
+ for(const Series& item : *all_series)
+ {
+ series.push_back({
+ {"game", item.game_short_name},
+ {"id", item.id},
+ {"name", item.name},
+ {"selected", std::ranges::find(*memberships, item.id) !=
+ memberships->end()},
+ });
+ }
+
const inja::json template_data = {
{"action_url", urlFor("card", {public_id})},
{"back_url", urlFor("card", {public_id})},
@@ -475,6 +681,7 @@ void App::handleCardEdit(
{"foil_action", "keep"},
{"foil_url", foil_url},
{"front_url", front_url},
+ {"games", std::move(games)},
{"has_foil", card.foil_extension.has_value()},
{"heading", "Edit card"},
{"long_description", card.long_description.value_or("")},
@@ -485,6 +692,8 @@ void App::handleCardEdit(
urlFor("static", {"foil/card_preview.js"})},
{"rarity", card.rarity},
{"revision", card.revision},
+ {"selected_game", card.identity.game_short_name.value_or("")},
+ {"series", std::move(series)},
{"shader_fragment_url",
urlFor("static", {"foil/frag-shader.glsl"})},
{"shader_vertex_url",
@@ -535,12 +744,9 @@ void App::handleCardCreate(
}
const auto game = upload->fields.find("game");
- if(game != upload->fields.end() && !trimAscii(game->second).empty())
- {
- respondBadRequest(
- response, "Game-specific cards are not implemented yet");
- return;
- }
+ const std::string game_short_name = game == upload->fields.end()
+ ? std::string()
+ : std::string(mw::strip(game->second));
const auto source_mode = upload->fields.find("source_mode");
if(source_mode != upload->fields.end() &&
source_mode->second != "files" &&
@@ -553,7 +759,7 @@ void App::handleCardCreate(
const auto name_position = upload->fields.find("name");
const std::string name = name_position == upload->fields.end()
? std::string()
- : trimAscii(name_position->second);
+ : std::string(mw::strip(name_position->second));
if(name.empty())
{
respondBadRequest(response, "Card name is required");
@@ -569,12 +775,6 @@ void App::handleCardCreate(
respondBadRequest(response, "Front artwork is required");
return;
}
- if(!upload->series_ids.empty())
- {
- respondBadRequest(
- response, "Loose cards cannot belong to a series");
- return;
- }
auto rarity = parseRarity(upload->fields);
if(!rarity)
{
@@ -582,7 +782,7 @@ void App::handleCardCreate(
return;
}
- auto created = card_service_->createLooseCard({
+ CreateCardInput input = {
name,
optionalText(upload->fields, "short_description"),
optionalText(upload->fields, "long_description"),
@@ -591,7 +791,53 @@ void App::handleCardCreate(
*upload->front,
upload->foil,
upload->thumbnail,
- });
+ };
+ mw::E<std::string> created = std::unexpected(
+ mw::runtimeError("Card creation was not dispatched"));
+ if(game_short_name.empty())
+ {
+ if(!upload->series_ids.empty())
+ {
+ respondBadRequest(
+ response, "Loose cards cannot belong to a series");
+ return;
+ }
+ created = card_service_->createLooseCard(std::move(input));
+ }
+ else
+ {
+ const GameDefinition* definition = games_->find(game_short_name);
+ if(definition == nullptr)
+ {
+ respondBadRequest(response, "Unknown compiled game");
+ return;
+ }
+ auto metadata = definition->validateMetadata(
+ gameFields(upload->fields));
+ if(!metadata)
+ {
+ respondOperationError(
+ response,
+ metadata.error(),
+ "Failed to validate game metadata");
+ return;
+ }
+ auto memberships = seriesMemberships(
+ upload->series_ids, game_short_name, *data_source_);
+ if(!memberships)
+ {
+ respondOperationError(
+ response,
+ memberships.error(),
+ "Failed to validate series memberships");
+ return;
+ }
+ created = card_service_->createGameCard(
+ std::move(input),
+ *definition,
+ **metadata,
+ *memberships);
+ }
if(!created)
{
respondOperationError(
@@ -635,12 +881,6 @@ void App::handleCardUpdate(
respondNotFound(response);
return;
}
- if((**card_result).identity.game_short_name)
- {
- respondBadRequest(
- response, "Game-specific cards are not implemented yet");
- return;
- }
if(!request.is_multipart_form_data())
{
respondBadRequest(response, "Expected a multipart form upload");
@@ -668,17 +908,10 @@ void App::handleCardUpdate(
respondBadRequest(response, "Unknown image source mode");
return;
}
- if(!upload->series_ids.empty())
- {
- respondBadRequest(
- response, "Loose cards cannot belong to a series");
- return;
- }
-
const auto name_position = upload->fields.find("name");
const std::string name = name_position == upload->fields.end()
? std::string()
- : trimAscii(name_position->second);
+ : std::string(mw::strip(name_position->second));
if(name.empty())
{
respondBadRequest(response, "Card name is required");
@@ -748,7 +981,7 @@ void App::handleCardUpdate(
return;
}
- auto updated = card_service_->updateLooseCard({
+ UpdateLooseCardInput input = {
std::move(**card_result),
*revision,
name,
@@ -761,7 +994,55 @@ void App::handleCardUpdate(
upload->front,
upload->foil,
upload->thumbnail,
- });
+ };
+ mw::E<std::string> updated = std::unexpected(
+ mw::runtimeError("Card update was not dispatched"));
+ if(!input.current_card.identity.game_short_name)
+ {
+ if(!upload->series_ids.empty())
+ {
+ respondBadRequest(
+ response, "Loose cards cannot belong to a series");
+ return;
+ }
+ updated = card_service_->updateLooseCard(std::move(input));
+ }
+ else
+ {
+ const std::string& game_short_name =
+ *input.current_card.identity.game_short_name;
+ const GameDefinition* definition = games_->find(game_short_name);
+ if(definition == nullptr)
+ {
+ respondInternalError(response);
+ return;
+ }
+ auto metadata = definition->validateMetadata(
+ gameFields(upload->fields));
+ if(!metadata)
+ {
+ respondOperationError(
+ response,
+ metadata.error(),
+ "Failed to validate game metadata");
+ return;
+ }
+ auto membership_ids = seriesMemberships(
+ upload->series_ids, game_short_name, *data_source_);
+ if(!membership_ids)
+ {
+ respondOperationError(
+ response,
+ membership_ids.error(),
+ "Failed to validate series memberships");
+ return;
+ }
+ updated = card_service_->updateGameCard(
+ std::move(input),
+ *definition,
+ **metadata,
+ *membership_ids);
+ }
if(!updated)
{
respondOperationError(
@@ -845,6 +1126,40 @@ void App::handleCardView(
}
}
+ inja::json game_fields = inja::json::array();
+ std::string game_name = "Loose card";
+ if(card.identity.game_short_name)
+ {
+ const GameDefinition* game = games_->find(
+ *card.identity.game_short_name);
+ if(game == nullptr)
+ {
+ spdlog::error(
+ "Card {} uses an unregistered compiled game",
+ card.id);
+ respondInternalError(response);
+ return;
+ }
+ game_name = std::string(game->displayName());
+ auto fields = data_source_->getGameDisplayFields(*game, card.id);
+ if(!fields)
+ {
+ spdlog::error(
+ "Failed to load game fields for card {}: {}",
+ card.id,
+ fields.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ for(const DisplayField& field : *fields)
+ {
+ game_fields.push_back({
+ {"label", field.label},
+ {"value", field.value},
+ });
+ }
+ }
+
auto membership_result = data_source_->getCardSeries(card.id);
if(!membership_result)
{
@@ -885,20 +1200,58 @@ void App::handleCardView(
missing_assets.emplace_back("foil control");
}
+ std::vector<HtmlSubstitution> html_substitutions;
+ std::string short_description_marker;
+ if(card.short_description)
+ {
+ auto rendered = MarkdownRenderer().render(*card.short_description);
+ if(!rendered)
+ {
+ spdlog::error(
+ "Failed to render short description for card {}: {}",
+ card.id,
+ rendered.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ short_description_marker =
+ "CARD_COLLECTION_HTML_" + random_->hex(16);
+ html_substitutions.push_back({
+ short_description_marker, std::move(*rendered)});
+ }
+ std::string long_description_marker;
+ if(card.long_description)
+ {
+ auto rendered = MarkdownRenderer().render(*card.long_description);
+ if(!rendered)
+ {
+ spdlog::error(
+ "Failed to render long description for card {}: {}",
+ card.id,
+ rendered.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ long_description_marker =
+ "CARD_COLLECTION_HTML_" + random_->hex(16);
+ html_substitutions.push_back({
+ long_description_marker, std::move(*rendered)});
+ }
+
const inja::json template_data = {
{"asset_warning", !missing_assets.empty()},
{"back_url", urlFor("card-index")},
{"display_id", uppercaseAscii(public_id)},
+ {"delete_url", urlFor("card-delete", {public_id})},
{"edit_url", urlFor("card-edit", {public_id})},
{"foil_url", foil_url.value_or("")},
{"front_url", front_url},
- {"game", card.identity.game_short_name
- ? uppercaseAscii(*card.identity.game_short_name)
- : "Loose card"},
+ {"game", game_name},
+ {"game_fields", std::move(game_fields)},
{"has_foil", card.foil_extension.has_value()},
{"has_long_description", card.long_description.has_value()},
{"has_short_description", card.short_description.has_value()},
- {"long_description", card.long_description.value_or("")},
+ {"long_description", long_description_marker},
{"missing_assets", missing_assets},
{"model_url", urlFor("static", {"foil/model/card.obj"})},
{"name", card.name},
@@ -910,26 +1263,128 @@ void App::handleCardView(
urlFor("static", {"foil/frag-shader.glsl"})},
{"shader_vertex_url",
urlFor("static", {"foil/vert-shader.glsl"})},
- {"short_description", card.short_description.value_or("")},
+ {"short_description", short_description_marker},
{"spectral_lut_url",
urlFor("static", {"foil/spectral_xyz.bin"})},
{"title", card.name + " · Card Collection"},
};
+ try
+ {
+ response.status = 200;
+ auto content = renderWithHtml(
+ templates_,
+ card_view_template_,
+ template_data,
+ html_substitutions);
+ if(!content)
+ {
+ throw std::runtime_error(content.error().msg());
+ }
+ response.set_content(*content, "text/html; charset=utf-8");
+ }
+ catch(const std::exception& error)
+ {
+ spdlog::error("Failed to render card {}: {}", card.id, error.what());
+ respondInternalError(response);
+ }
+}
+
+void App::handleCardDeleteConfirm(
+ const Request& request,
+ Response& response)
+{
+ const auto parameter = request.path_params.find("id");
+ if(parameter == request.path_params.end())
+ {
+ respondNotFound(response);
+ return;
+ }
+ auto identity = parsePublicId(parameter->second);
+ if(!identity)
+ {
+ respondNotFound(response);
+ return;
+ }
+ auto card = data_source_->getCard(*identity);
+ if(!card || !*card)
+ {
+ if(!card)
+ {
+ spdlog::error(
+ "Failed to load card for deletion: {}",
+ card.error().msg());
+ respondInternalError(response);
+ }
+ else
+ {
+ respondNotFound(response);
+ }
+ return;
+ }
+ const inja::json template_data = {
+ {"action_url", urlFor("card-delete", {parameter->second})},
+ {"back_url", urlFor("card", {parameter->second})},
+ {"name", (**card).name},
+ {"title", "Delete card · Card Collection"},
+ };
try
{
response.status = 200;
response.set_content(
- templates_.render(card_view_template_, template_data),
+ templates_.render(card_delete_template_, template_data),
"text/html; charset=utf-8");
}
catch(const std::exception& error)
{
- spdlog::error("Failed to render card {}: {}", card.id, error.what());
+ spdlog::error("Failed to render card deletion: {}", error.what());
respondInternalError(response);
}
}
+void App::handleCardDelete(
+ const Request& request,
+ Response& response)
+{
+ const auto parameter = request.path_params.find("id");
+ if(parameter == request.path_params.end())
+ {
+ respondNotFound(response);
+ return;
+ }
+ auto identity = parsePublicId(parameter->second);
+ if(!identity)
+ {
+ respondNotFound(response);
+ return;
+ }
+ auto card = data_source_->getCard(*identity);
+ if(!card || !*card)
+ {
+ if(!card)
+ {
+ spdlog::error(
+ "Failed to load card for deletion: {}",
+ card.error().msg());
+ respondInternalError(response);
+ }
+ else
+ {
+ respondNotFound(response);
+ }
+ return;
+ }
+ auto deleted = card_service_->deleteCard(**card);
+ if(!deleted)
+ {
+ respondOperationError(
+ response, deleted.error(), "Failed to delete a card");
+ return;
+ }
+ response.status = 303;
+ response.set_header("Location", urlFor("card-index"));
+}
+
std::string App::urlFor(
const std::string& name,
const std::vector<std::string>& arguments,
@@ -1054,6 +1509,302 @@ void App::handleCardIndex(
}
}
+void App::handleSeriesIndex(
+ [[maybe_unused]] const Request& request,
+ Response& response)
+{
+ auto series_result = data_source_->getSeries();
+ if(!series_result)
+ {
+ spdlog::error(
+ "Failed to load the series index: {}",
+ series_result.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ inja::json series = inja::json::array();
+ std::vector<HtmlSubstitution> html_substitutions;
+ for(const Series& item : *series_result)
+ {
+ const GameDefinition* game = games_->find(item.game_short_name);
+ auto rendered = MarkdownRenderer().render(item.description);
+ if(!rendered)
+ {
+ spdlog::error(
+ "Failed to render description for series {}: {}",
+ item.id,
+ rendered.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ const std::string marker =
+ "CARD_COLLECTION_HTML_" + random_->hex(16);
+ html_substitutions.push_back({marker, std::move(*rendered)});
+ series.push_back({
+ {"delete_url",
+ urlFor("series-delete", {std::to_string(item.id)})},
+ {"description", marker},
+ {"has_description", !item.description.empty()},
+ {"edit_url",
+ urlFor("series-edit", {std::to_string(item.id)})},
+ {"game", game == nullptr
+ ? uppercaseAscii(item.game_short_name)
+ : std::string(game->displayName())},
+ {"name", item.name},
+ });
+ }
+ const inja::json template_data = {
+ {"can_create", !games_->games().empty()},
+ {"create_url", urlFor("series-new")},
+ {"series", std::move(series)},
+ {"title", "Series · Card Collection"},
+ };
+ try
+ {
+ response.status = 200;
+ auto content = renderWithHtml(
+ templates_,
+ series_index_template_,
+ template_data,
+ html_substitutions);
+ if(!content)
+ {
+ throw std::runtime_error(content.error().msg());
+ }
+ response.set_content(*content, "text/html; charset=utf-8");
+ }
+ catch(const std::exception& error)
+ {
+ spdlog::error("Failed to render the series index: {}", error.what());
+ respondInternalError(response);
+ }
+}
+
+void App::handleSeriesNew(
+ [[maybe_unused]] const Request& request,
+ Response& response)
+{
+ inja::json games = inja::json::array();
+ for(const GameDefinition* game : games_->games())
+ {
+ games.push_back({
+ {"display_name", std::string(game->displayName())},
+ {"short_name", std::string(game->shortName())},
+ });
+ }
+ const inja::json template_data = {
+ {"action_url", urlFor("series")},
+ {"back_url", urlFor("series-index")},
+ {"description", ""},
+ {"game", ""},
+ {"games", std::move(games)},
+ {"heading", "Create series"},
+ {"mode", "create"},
+ {"name", ""},
+ {"submit_label", "Create series"},
+ {"title", "Create series · Card Collection"},
+ };
+ try
+ {
+ response.status = 200;
+ response.set_content(
+ templates_.render(series_form_template_, template_data),
+ "text/html; charset=utf-8");
+ }
+ catch(const std::exception& error)
+ {
+ spdlog::error("Failed to render the series form: {}", error.what());
+ respondInternalError(response);
+ }
+}
+
+void App::handleSeriesCreate(
+ const Request& request,
+ Response& response)
+{
+ if(!request.has_param("game") || !request.has_param("name"))
+ {
+ respondBadRequest(response, "Game and series name are required");
+ return;
+ }
+ auto created = series_service_->create(
+ request.get_param_value("game"),
+ request.get_param_value("name"),
+ request.has_param("description")
+ ? request.get_param_value("description")
+ : std::string());
+ if(!created)
+ {
+ respondOperationError(
+ response, created.error(), "Failed to create a series");
+ return;
+ }
+ response.status = 303;
+ response.set_header("Location", urlFor("series-index"));
+}
+
+void App::handleSeriesEdit(
+ const Request& request,
+ Response& response)
+{
+ const auto parameter = request.path_params.find("id");
+ const auto id = parameter == request.path_params.end()
+ ? std::nullopt
+ : parsePositiveId(parameter->second);
+ if(!id)
+ {
+ respondNotFound(response);
+ return;
+ }
+ auto series_result = data_source_->getSeries(*id);
+ if(!series_result)
+ {
+ spdlog::error(
+ "Failed to load series {}: {}",
+ *id,
+ series_result.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ if(!*series_result)
+ {
+ respondNotFound(response);
+ return;
+ }
+ const Series& series = **series_result;
+ const GameDefinition* game = games_->find(series.game_short_name);
+ const inja::json template_data = {
+ {"action_url", urlFor("series-item", {std::to_string(*id)})},
+ {"back_url", urlFor("series-index")},
+ {"description", series.description},
+ {"game", game == nullptr
+ ? uppercaseAscii(series.game_short_name)
+ : std::string(game->displayName())},
+ {"games", inja::json::array()},
+ {"heading", "Edit series"},
+ {"mode", "edit"},
+ {"name", series.name},
+ {"submit_label", "Save changes"},
+ {"title", "Edit " + series.name + " · Card Collection"},
+ };
+ try
+ {
+ response.status = 200;
+ response.set_content(
+ templates_.render(series_form_template_, template_data),
+ "text/html; charset=utf-8");
+ }
+ catch(const std::exception& error)
+ {
+ spdlog::error("Failed to render series {}: {}", *id, error.what());
+ respondInternalError(response);
+ }
+}
+
+void App::handleSeriesUpdate(
+ const Request& request,
+ Response& response)
+{
+ const auto parameter = request.path_params.find("id");
+ const auto id = parameter == request.path_params.end()
+ ? std::nullopt
+ : parsePositiveId(parameter->second);
+ if(!id || !request.has_param("name"))
+ {
+ respondBadRequest(response, "Valid series ID and name are required");
+ return;
+ }
+ auto updated = series_service_->update(
+ *id,
+ request.get_param_value("name"),
+ request.has_param("description")
+ ? request.get_param_value("description")
+ : std::string());
+ if(!updated)
+ {
+ respondOperationError(
+ response, updated.error(), "Failed to update a series");
+ return;
+ }
+ response.status = 303;
+ response.set_header("Location", urlFor("series-index"));
+}
+
+void App::handleSeriesDeleteConfirm(
+ const Request& request,
+ Response& response)
+{
+ const auto parameter = request.path_params.find("id");
+ const auto id = parameter == request.path_params.end()
+ ? std::nullopt
+ : parsePositiveId(parameter->second);
+ if(!id)
+ {
+ respondNotFound(response);
+ return;
+ }
+ auto series_result = data_source_->getSeries(*id);
+ if(!series_result || !*series_result)
+ {
+ if(!series_result)
+ {
+ spdlog::error(
+ "Failed to load series {} for deletion: {}",
+ *id,
+ series_result.error().msg());
+ respondInternalError(response);
+ }
+ else
+ {
+ respondNotFound(response);
+ }
+ return;
+ }
+ const inja::json template_data = {
+ {"action_url", urlFor("series-delete", {std::to_string(*id)})},
+ {"back_url", urlFor("series-index")},
+ {"name", (**series_result).name},
+ {"title", "Delete series · Card Collection"},
+ };
+ try
+ {
+ response.status = 200;
+ response.set_content(
+ templates_.render(series_delete_template_, template_data),
+ "text/html; charset=utf-8");
+ }
+ catch(const std::exception& error)
+ {
+ spdlog::error(
+ "Failed to render series deletion: {}", error.what());
+ respondInternalError(response);
+ }
+}
+
+void App::handleSeriesDelete(
+ const Request& request,
+ Response& response)
+{
+ const auto parameter = request.path_params.find("id");
+ const auto id = parameter == request.path_params.end()
+ ? std::nullopt
+ : parsePositiveId(parameter->second);
+ if(!id)
+ {
+ respondBadRequest(response, "A valid series ID is required");
+ return;
+ }
+ auto removed = series_service_->remove(*id);
+ if(!removed)
+ {
+ respondOperationError(
+ response, removed.error(), "Failed to delete a series");
+ return;
+ }
+ response.status = 303;
+ response.set_header("Location", urlFor("series-index"));
+}
+
void App::setup()
{
const std::filesystem::path published_cards =
@@ -1087,6 +1838,33 @@ void App::setup()
server.Post(
getPath("card", {"id"}),
std::bind_front(&App::handleCardUpdate, this));
+ server.Get(
+ getPath("card-delete", {"id"}),
+ std::bind_front(&App::handleCardDeleteConfirm, this));
+ server.Post(
+ getPath("card-delete", {"id"}),
+ std::bind_front(&App::handleCardDelete, this));
+ server.Get(
+ getPath("series-index"),
+ std::bind_front(&App::handleSeriesIndex, this));
+ server.Get(
+ getPath("series-new"),
+ std::bind_front(&App::handleSeriesNew, this));
+ server.Post(
+ getPath("series"),
+ std::bind_front(&App::handleSeriesCreate, this));
+ server.Get(
+ getPath("series-edit", {"id"}),
+ std::bind_front(&App::handleSeriesEdit, this));
+ server.Post(
+ getPath("series-item", {"id"}),
+ std::bind_front(&App::handleSeriesUpdate, this));
+ server.Get(
+ getPath("series-delete", {"id"}),
+ std::bind_front(&App::handleSeriesDeleteConfirm, this));
+ server.Post(
+ getPath("series-delete", {"id"}),
+ std::bind_front(&App::handleSeriesDelete, this));
}
std::string App::getPath(
diff --git a/src/app.h b/src/app.h
index 9bed86e..2104776 100644
--- a/src/app.h
+++ b/src/app.h
@@ -10,6 +10,9 @@
#include "card_service.h"
#include "config.h"
#include "data.h"
+#include "game_registry.h"
+#include "non_secret_random.h"
+#include "series_service.h"
#include "url_builder.h"
/// Card Collection HTTP application and named-route owner.
@@ -31,7 +34,9 @@ public:
/// Construct the application from configuration and a data source.
App(
const Config& config,
- std::unique_ptr<DataSourceInterface> data_source);
+ std::unique_ptr<DataSourceInterface> data_source,
+ std::unique_ptr<GameRegistry> games,
+ std::unique_ptr<NonSecretRandom> random);
/// Return the absolute URL for a named application route.
std::string urlFor(
@@ -63,6 +68,37 @@ public:
Response& response,
const ContentReader& content_reader);
+ /// Render the card-delete confirmation page.
+ void handleCardDeleteConfirm(
+ const Request& request,
+ Response& response);
+
+ /// Delete one card and redirect to the index.
+ void handleCardDelete(const Request& request, Response& response);
+
+ /// Render the series administration index.
+ void handleSeriesIndex(const Request& request, Response& response);
+
+ /// Render the create-series form.
+ void handleSeriesNew(const Request& request, Response& response);
+
+ /// Persist a new series.
+ void handleSeriesCreate(const Request& request, Response& response);
+
+ /// Render the edit-series form.
+ void handleSeriesEdit(const Request& request, Response& response);
+
+ /// Persist changes to one series.
+ void handleSeriesUpdate(const Request& request, Response& response);
+
+ /// Render the delete-series confirmation page.
+ void handleSeriesDeleteConfirm(
+ const Request& request,
+ Response& response);
+
+ /// Delete one series and redirect to the series index.
+ void handleSeriesDelete(const Request& request, Response& response);
+
private:
/// Register implemented handlers and static mounts.
void setup() override;
@@ -77,10 +113,17 @@ private:
Config config_;
std::unique_ptr<DataSourceInterface> data_source_;
+ std::unique_ptr<GameRegistry> games_;
+ std::unique_ptr<NonSecretRandom> random_;
std::unique_ptr<CardService> card_service_;
+ std::unique_ptr<SeriesService> series_service_;
UrlBuilder url_builder_;
inja::Environment templates_;
inja::Template card_form_template_;
+ inja::Template card_delete_template_;
inja::Template card_index_template_;
inja::Template card_view_template_;
+ inja::Template series_delete_template_;
+ inja::Template series_form_template_;
+ inja::Template series_index_template_;
};
diff --git a/src/asset_store.cpp b/src/asset_store.cpp
index a059aae..bec17f1 100644
--- a/src/asset_store.cpp
+++ b/src/asset_store.cpp
@@ -1,12 +1,79 @@
#include "asset_store.h"
+#include <charconv>
#include <filesystem>
+#include <optional>
#include <string>
#include <system_error>
#include <utility>
#include <spdlog/spdlog.h>
+#include "data.h"
+#include "public_id.h"
+
+namespace
+{
+
+struct RecoveryName
+{
+ std::string public_id;
+ std::int64_t revision;
+};
+
+std::optional<RecoveryName> parseRecoveryName(
+ const std::string& name,
+ std::string_view prefix)
+{
+ if(!name.starts_with(prefix))
+ {
+ return std::nullopt;
+ }
+ const std::size_t begin = prefix.size();
+ std::size_t marker = name.find("-r", begin);
+ while(marker != std::string::npos)
+ {
+ const std::size_t revision_begin = marker + 2;
+ const std::size_t suffix = name.find('-', revision_begin);
+ if(suffix != std::string::npos)
+ {
+ const std::string public_id = name.substr(begin, marker - begin);
+ const std::string revision_text = name.substr(
+ revision_begin, suffix - revision_begin);
+ auto identity = parsePublicId(public_id);
+ std::int64_t revision = 0;
+ const auto parsed = std::from_chars(
+ revision_text.data(),
+ revision_text.data() + revision_text.size(),
+ revision);
+ if(identity && !revision_text.empty() &&
+ parsed.ec == std::errc{} &&
+ parsed.ptr == revision_text.data() + revision_text.size() &&
+ revision > 0)
+ {
+ return RecoveryName{public_id, revision};
+ }
+ }
+ marker = name.find("-r", marker + 2);
+ }
+ return std::nullopt;
+}
+
+mw::E<void> removeDirectory(const std::filesystem::path& path)
+{
+ std::error_code filesystem_error;
+ std::filesystem::remove_all(path, filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to remove abandoned card assets: " +
+ filesystem_error.message()));
+ }
+ return {};
+}
+
+} // namespace
+
AssetStore::AssetStore(std::filesystem::path card_storage_root)
: published_root_(
std::move(card_storage_root) / "published")
@@ -58,12 +125,14 @@ mw::E<void> AssetStore::publish(
mw::E<AssetReplacement> AssetStore::replace(
const std::filesystem::path& staging_directory,
- const std::string& public_id) const
+ const std::string& public_id,
+ std::int64_t previous_revision) const
{
const std::filesystem::path destination = published_root_ / public_id;
- const std::filesystem::path previous =
- staging_directory.parent_path() /
- (staging_directory.filename().string() + ".previous");
+ const std::filesystem::path previous = staging_directory.parent_path() /
+ ("backup-" + public_id + "-r" +
+ std::to_string(previous_revision) + "-" +
+ staging_directory.filename().string());
std::error_code filesystem_error;
if(!std::filesystem::is_directory(destination, filesystem_error))
{
@@ -133,6 +202,81 @@ void AssetStore::finish(const AssetReplacement& replacement) const
}
}
+mw::E<TrashedAssets> AssetStore::trash(
+ const std::string& public_id,
+ std::int64_t revision,
+ const std::string& suffix) const
+{
+ const std::filesystem::path source = published_root_ / public_id;
+ std::error_code filesystem_error;
+ if(!std::filesystem::exists(source, filesystem_error))
+ {
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to inspect card assets for deletion: " +
+ filesystem_error.message()));
+ }
+ return TrashedAssets{public_id, {}};
+ }
+ const std::filesystem::path trash_root =
+ published_root_.parent_path() / ".trash";
+ std::filesystem::create_directories(trash_root, filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to create the private trash directory: " +
+ filesystem_error.message()));
+ }
+ const std::filesystem::path destination = trash_root /
+ (public_id + "-r" + std::to_string(revision) + "-" + suffix);
+ std::filesystem::rename(source, destination, filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to move card assets into private trash: " +
+ filesystem_error.message()));
+ }
+ return TrashedAssets{public_id, destination};
+}
+
+void AssetStore::restore(const TrashedAssets& trashed) const
+{
+ if(trashed.trash_directory.empty())
+ {
+ return;
+ }
+ std::error_code filesystem_error;
+ std::filesystem::rename(
+ trashed.trash_directory,
+ published_root_ / trashed.public_id,
+ filesystem_error);
+ if(filesystem_error)
+ {
+ spdlog::critical(
+ "Failed to restore deleted assets for card {}: {}",
+ trashed.public_id,
+ filesystem_error.message());
+ }
+}
+
+void AssetStore::finish(const TrashedAssets& trashed) const
+{
+ if(trashed.trash_directory.empty())
+ {
+ return;
+ }
+ std::error_code filesystem_error;
+ std::filesystem::remove_all(trashed.trash_directory, filesystem_error);
+ if(filesystem_error)
+ {
+ spdlog::warn(
+ "Failed to remove committed trash for card {}: {}",
+ trashed.public_id,
+ filesystem_error.message());
+ }
+}
+
void AssetStore::removePublished(const std::string& public_id) const
{
std::error_code filesystem_error;
@@ -146,3 +290,160 @@ void AssetStore::removePublished(const std::string& public_id) const
filesystem_error.message());
}
}
+
+mw::E<void> AssetStore::reconcile(DataSourceInterface& data_source) const
+{
+ const std::filesystem::path storage_root = published_root_.parent_path();
+ const std::filesystem::path staging_root = storage_root / ".staging";
+ const std::filesystem::path trash_root = storage_root / ".trash";
+ std::error_code filesystem_error;
+ std::filesystem::create_directories(staging_root, filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to inspect staging during recovery: " +
+ filesystem_error.message()));
+ }
+
+ for(const std::filesystem::directory_entry& entry :
+ std::filesystem::directory_iterator(staging_root))
+ {
+ const std::string name = entry.path().filename().string();
+ auto recovery = parseRecoveryName(name, "backup-");
+ if(!recovery)
+ {
+ if(!name.starts_with("backup-"))
+ {
+ auto removed = removeDirectory(entry.path());
+ if(!removed)
+ {
+ return removed;
+ }
+ }
+ else
+ {
+ spdlog::warn("Leaving malformed recovery entry {}", name);
+ }
+ continue;
+ }
+ auto identity = parsePublicId(recovery->public_id);
+ auto card = data_source.getCard(*identity);
+ if(!card)
+ {
+ return std::unexpected(std::move(card.error()));
+ }
+ const std::filesystem::path canonical =
+ published_root_ / recovery->public_id;
+ if(*card && (**card).revision == recovery->revision)
+ {
+ auto removed = removeDirectory(canonical);
+ if(!removed)
+ {
+ return removed;
+ }
+ std::filesystem::rename(
+ entry.path(), canonical, filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to restore interrupted card edit: " +
+ filesystem_error.message()));
+ }
+ }
+ else if(*card && (**card).revision > recovery->revision)
+ {
+ auto removed = removeDirectory(entry.path());
+ if(!removed)
+ {
+ return removed;
+ }
+ }
+ else if(!*card)
+ {
+ auto removed = removeDirectory(entry.path());
+ if(!removed)
+ {
+ return removed;
+ }
+ }
+ else
+ {
+ return std::unexpected(mw::runtimeError(
+ "A card revision predates its recovery backup"));
+ }
+ }
+
+ std::filesystem::create_directories(trash_root, filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to inspect trash during recovery: " +
+ filesystem_error.message()));
+ }
+ for(const std::filesystem::directory_entry& entry :
+ std::filesystem::directory_iterator(trash_root))
+ {
+ auto recovery = parseRecoveryName(
+ entry.path().filename().string(), "");
+ if(!recovery)
+ {
+ spdlog::warn(
+ "Leaving malformed trash entry {}",
+ entry.path().filename().string());
+ continue;
+ }
+ auto identity = parsePublicId(recovery->public_id);
+ auto card = data_source.getCard(*identity);
+ if(!card)
+ {
+ return std::unexpected(std::move(card.error()));
+ }
+ const std::filesystem::path canonical =
+ published_root_ / recovery->public_id;
+ if(*card && !std::filesystem::exists(canonical))
+ {
+ std::filesystem::rename(
+ entry.path(), canonical, filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to restore interrupted card deletion: " +
+ filesystem_error.message()));
+ }
+ }
+ else if(!*card || std::filesystem::exists(canonical))
+ {
+ auto removed = removeDirectory(entry.path());
+ if(!removed)
+ {
+ return removed;
+ }
+ }
+ }
+
+ for(const std::filesystem::directory_entry& entry :
+ std::filesystem::directory_iterator(published_root_))
+ {
+ const std::string public_id = entry.path().filename().string();
+ auto identity = parsePublicId(public_id);
+ if(!identity)
+ {
+ spdlog::warn("Leaving non-card published entry {}", public_id);
+ continue;
+ }
+ auto card = data_source.getCard(*identity);
+ if(!card)
+ {
+ return std::unexpected(std::move(card.error()));
+ }
+ if(!*card)
+ {
+ auto removed = removeDirectory(entry.path());
+ if(!removed)
+ {
+ return removed;
+ }
+ }
+ }
+ return {};
+}
diff --git a/src/asset_store.h b/src/asset_store.h
index 7a97840..662c358 100644
--- a/src/asset_store.h
+++ b/src/asset_store.h
@@ -1,10 +1,13 @@
#pragma once
+#include <cstdint>
#include <filesystem>
#include <string>
#include <mw/error.hpp>
+class DataSourceInterface;
+
/// Published asset directory retained until an edit transaction commits.
struct AssetReplacement
{
@@ -15,6 +18,16 @@ struct AssetReplacement
std::filesystem::path previous_directory;
};
+/// Published card assets retained until a delete transaction commits.
+struct TrashedAssets
+{
+ /// Public ID whose directory was moved.
+ std::string public_id;
+
+ /// Private trash path, empty when no published directory existed.
+ std::filesystem::path trash_directory;
+};
+
/// Atomically publish and remove complete card asset directories.
class AssetStore
{
@@ -33,7 +46,8 @@ public:
/// Swap a complete staging directory with existing published assets.
mw::E<AssetReplacement> replace(
const std::filesystem::path& staging_directory,
- const std::string& public_id) const;
+ const std::string& public_id,
+ std::int64_t previous_revision) const;
/// Restore the previous assets after a database rollback.
void restore(const AssetReplacement& replacement) const;
@@ -41,9 +55,24 @@ public:
/// Remove previous assets after the database update commits.
void finish(const AssetReplacement& replacement) const;
+ /// Move published assets to a uniquely named private trash directory.
+ mw::E<TrashedAssets> trash(
+ const std::string& public_id,
+ std::int64_t revision,
+ const std::string& suffix) const;
+
+ /// Restore assets after a card-delete transaction rollback.
+ void restore(const TrashedAssets& trashed) const;
+
+ /// Permanently remove assets after a card-delete transaction commits.
+ void finish(const TrashedAssets& trashed) const;
+
/// Remove one precisely identified published directory after rollback.
void removePublished(const std::string& public_id) const;
+ /// Reconcile private transitions and orphaned public directories.
+ mw::E<void> reconcile(DataSourceInterface& data_source) const;
+
private:
std::filesystem::path published_root_;
};
diff --git a/src/card_service.cpp b/src/card_service.cpp
index c7c4c87..408a364 100644
--- a/src/card_service.cpp
+++ b/src/card_service.cpp
@@ -2,27 +2,18 @@
#include <cstdint>
#include <filesystem>
-#include <limits>
#include <optional>
-#include <random>
#include <string>
#include <system_error>
#include <utility>
#include "public_id.h"
+#include "game_definition.h"
namespace
{
-inline constexpr int MAX_ID_ATTEMPTS = 64;
-
-std::uint32_t randomLooseNumber()
-{
- thread_local std::mt19937 generator(std::random_device{}());
- thread_local std::uniform_int_distribution<std::uint32_t> distribution(
- 0, std::numeric_limits<std::uint32_t>::max());
- return distribution(generator);
-}
+inline constexpr int MAX_ID_ATTEMPTS = 128;
mw::E<void> copyAsset(
const std::filesystem::path& source,
@@ -47,9 +38,11 @@ mw::E<void> copyAsset(
CardService::CardService(
DataSourceInterface& data_source,
+ NonSecretRandom& random,
ImageProcessor image_processor,
AssetStore asset_store)
: data_source_(data_source),
+ random_(random),
image_processor_(std::move(image_processor)),
asset_store_(std::move(asset_store))
{}
@@ -57,6 +50,41 @@ CardService::CardService(
mw::E<std::string> CardService::createLooseCard(
CreateLooseCardInput input)
{
+ return createCard(std::move(input), nullptr, nullptr, {});
+}
+
+mw::E<std::string> CardService::createGameCard(
+ 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);
+}
+
+mw::E<std::string> CardService::createCard(
+ CreateCardInput input,
+ const GameDefinition* game,
+ const GameCardMetadata* metadata,
+ const std::vector<std::int64_t>& series_ids)
+{
+ if(input.short_description)
+ {
+ auto rendered = markdown_renderer_.render(*input.short_description);
+ if(!rendered)
+ {
+ return std::unexpected(std::move(rendered.error()));
+ }
+ }
+ if(input.long_description)
+ {
+ auto rendered = markdown_renderer_.render(*input.long_description);
+ if(!rendered)
+ {
+ return std::unexpected(std::move(rendered.error()));
+ }
+ }
auto front = image_processor_.process(
input.front, CardAssetType::FRONT_ART);
if(!front)
@@ -117,30 +145,48 @@ mw::E<std::string> CardService::createLooseCard(
return std::unexpected(std::move(transaction.error()));
}
- std::optional<std::uint32_t> number;
- for(int attempt = 0; attempt < MAX_ID_ATTEMPTS; ++attempt)
+ std::uint64_t number = 0;
+ if(game != nullptr)
{
- const std::uint32_t candidate = randomLooseNumber();
- auto exists = (*transaction)->looseNumberExists(candidate);
- if(!exists)
- {
- return std::unexpected(std::move(exists.error()));
- }
- if(!*exists)
+ auto allocated = (*transaction)->allocateGameNumber(
+ std::string(game->shortName()));
+ if(!allocated)
{
- number = candidate;
- break;
+ return std::unexpected(std::move(allocated.error()));
}
+ number = *allocated;
}
- if(!number)
+ else
{
- return std::unexpected(mw::runtimeError(
- "Failed to allocate a unique loose-card number"));
+ std::optional<std::uint32_t> loose_number;
+ for(int attempt = 0; attempt < MAX_ID_ATTEMPTS; ++attempt)
+ {
+ const std::uint32_t candidate = random_.next();
+ auto exists = (*transaction)->looseNumberExists(candidate);
+ if(!exists)
+ {
+ return std::unexpected(std::move(exists.error()));
+ }
+ if(!*exists)
+ {
+ loose_number = candidate;
+ break;
+ }
+ }
+ if(!loose_number)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to allocate a unique loose-card number"));
+ }
+ number = *loose_number;
}
Card card = {
0,
- {std::nullopt, *number},
+ {game == nullptr
+ ? std::nullopt
+ : std::optional<std::string>(game->shortName()),
+ number},
std::move(input.name),
std::move(input.short_description),
std::move(input.long_description),
@@ -151,7 +197,7 @@ mw::E<std::string> CardService::createLooseCard(
1,
};
auto inserted = (*transaction)->insertCard(
- card, nullptr, nullptr, {});
+ card, game, metadata, series_ids);
if(!inserted)
{
return std::unexpected(std::move(inserted.error()));
@@ -180,12 +226,51 @@ mw::E<std::string> CardService::createLooseCard(
mw::E<std::string> CardService::updateLooseCard(
UpdateLooseCardInput input)
+{
+ return updateCard(std::move(input), nullptr, nullptr, {});
+}
+
+mw::E<std::string> CardService::updateGameCard(
+ 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);
+}
+
+mw::E<std::string> CardService::updateCard(
+ UpdateLooseCardInput input,
+ const GameDefinition* game,
+ const GameCardMetadata* metadata,
+ const std::vector<std::int64_t>& series_ids)
{
Card& card = input.current_card;
- if(card.id <= 0 || card.identity.game_short_name)
+ if(input.short_description)
+ {
+ auto rendered = markdown_renderer_.render(*input.short_description);
+ if(!rendered)
+ {
+ return std::unexpected(std::move(rendered.error()));
+ }
+ }
+ if(input.long_description)
+ {
+ auto rendered = markdown_renderer_.render(*input.long_description);
+ if(!rendered)
+ {
+ return std::unexpected(std::move(rendered.error()));
+ }
+ }
+ const bool is_game_card = card.identity.game_short_name.has_value();
+ if(card.id <= 0 || is_game_card != (game != nullptr) ||
+ (game == nullptr) != (metadata == nullptr) ||
+ (game != nullptr &&
+ game->shortName() != *card.identity.game_short_name))
{
return std::unexpected(mw::httpError(
- 400, "Only existing loose cards can be edited"));
+ 400, "Card identity does not match its compiled game"));
}
if(input.expected_revision != card.revision)
{
@@ -351,7 +436,8 @@ mw::E<std::string> CardService::updateLooseCard(
409, "The card was changed in another request"));
}
- auto updated = (*transaction)->updateCard(card, nullptr, nullptr, {});
+ auto updated = (*transaction)->updateCard(
+ card, game, metadata, series_ids);
if(!updated)
{
return std::unexpected(std::move(updated.error()));
@@ -359,7 +445,9 @@ mw::E<std::string> CardService::updateLooseCard(
if(rendering_changed)
{
auto replaced = asset_store_.replace(
- input.staging_directory, *public_id);
+ input.staging_directory,
+ *public_id,
+ input.expected_revision);
if(!replaced)
{
return std::unexpected(std::move(replaced.error()));
@@ -382,3 +470,46 @@ mw::E<std::string> CardService::updateLooseCard(
}
return *public_id;
}
+
+mw::E<void> CardService::deleteCard(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)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto current = (*transaction)->getCardForUpdate(card.id);
+ if(!current)
+ {
+ return std::unexpected(std::move(current.error()));
+ }
+ if(!*current)
+ {
+ return std::unexpected(mw::httpError(404, "Card not found"));
+ }
+ 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);
+ if(!deleted)
+ {
+ asset_store_.restore(*trashed);
+ return std::unexpected(std::move(deleted.error()));
+ }
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ asset_store_.restore(*trashed);
+ return std::unexpected(std::move(committed.error()));
+ }
+ asset_store_.finish(*trashed);
+ return {};
+}
diff --git a/src/card_service.h b/src/card_service.h
index f91ae6b..7ea4ea1 100644
--- a/src/card_service.h
+++ b/src/card_service.h
@@ -11,9 +11,11 @@
#include "asset_store.h"
#include "data.h"
#include "image_processor.h"
+#include "markdown_renderer.h"
+#include "non_secret_random.h"
-/// Validated common fields and staged files for a new loose card.
-struct CreateLooseCardInput
+/// Validated common fields and staged files for a new card.
+struct CreateCardInput
{
/// Human-readable card name.
std::string name;
@@ -40,6 +42,9 @@ struct CreateLooseCardInput
std::optional<std::filesystem::path> thumbnail;
};
+/// Create-card input retained as the loose-card handler's explicit name.
+using CreateLooseCardInput = CreateCardInput;
+
/// Requested handling for an existing required artwork asset.
enum class FrontAssetAction
{
@@ -102,17 +107,51 @@ public:
/// Construct a card creation service from its owned boundaries.
CardService(
DataSourceInterface& data_source,
+ NonSecretRandom& random,
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 compiled-game card with validated metadata and memberships.
+ mw::E<std::string> createGameCard(
+ 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 compiled-game card and its validated metadata/memberships.
+ mw::E<std::string> updateGameCard(
+ 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);
+
private:
+ /// Create a loose or compiled-game card through the common pipeline.
+ mw::E<std::string> createCard(
+ CreateCardInput input,
+ const GameDefinition* game,
+ const GameCardMetadata* metadata,
+ const std::vector<std::int64_t>& series_ids);
+
+ /// Update a loose or compiled-game card through the common pipeline.
+ mw::E<std::string> updateCard(
+ UpdateLooseCardInput input,
+ const GameDefinition* game,
+ const GameCardMetadata* metadata,
+ const std::vector<std::int64_t>& series_ids);
+
DataSourceInterface& data_source_;
+ NonSecretRandom& random_;
ImageProcessor image_processor_;
AssetStore asset_store_;
+ MarkdownRenderer markdown_renderer_;
};
diff --git a/src/config.cpp b/src/config.cpp
new file mode 100644
index 0000000..c06b8ac
--- /dev/null
+++ b/src/config.cpp
@@ -0,0 +1,279 @@
+#include "config.h"
+
+#include <array>
+#include <cstdint>
+#include <filesystem>
+#include <optional>
+#include <set>
+#include <string>
+#include <string_view>
+#include <system_error>
+#include <utility>
+
+#include <toml++/toml.hpp>
+
+namespace
+{
+
+const std::set<std::string> CONFIG_KEYS = {
+ "avif_quality",
+ "base_url",
+ "card_storage_root",
+ "database_path",
+ "listen_address",
+ "listen_port",
+ "static_root",
+ "thumbnail_long_side",
+};
+
+mw::E<std::string> requiredString(
+ const toml::table& table,
+ std::string_view name)
+{
+ auto value = table[name].value<std::string>();
+ if(!value)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Configuration key '" + std::string(name) +
+ "' must be a string"));
+ }
+ return std::move(*value);
+}
+
+mw::E<std::int64_t> requiredInteger(
+ const toml::table& table,
+ std::string_view name)
+{
+ auto value = table[name].value<std::int64_t>();
+ if(!value)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Configuration key '" + std::string(name) +
+ "' must be an integer"));
+ }
+ return *value;
+}
+
+std::filesystem::path normalizedPath(
+ const std::filesystem::path& config_directory,
+ const std::string& value)
+{
+ std::filesystem::path path(value);
+ if(path.is_relative())
+ {
+ path = config_directory / path;
+ }
+ return std::filesystem::absolute(path).lexically_normal();
+}
+
+bool pathContains(
+ const std::filesystem::path& parent,
+ const std::filesystem::path& child)
+{
+ auto parent_position = parent.begin();
+ auto child_position = child.begin();
+ while(parent_position != parent.end() &&
+ child_position != child.end() &&
+ *parent_position == *child_position)
+ {
+ ++parent_position;
+ ++child_position;
+ }
+ return parent_position == parent.end();
+}
+
+mw::E<void> createDirectory(
+ const std::filesystem::path& path,
+ std::string_view description)
+{
+ std::error_code filesystem_error;
+ std::filesystem::create_directories(path, filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to create " + std::string(description) + ": " +
+ filesystem_error.message()));
+ }
+ return {};
+}
+
+} // namespace
+
+mw::E<Config> loadConfig(const std::filesystem::path& config_path)
+{
+ toml::table table;
+ try
+ {
+ table = toml::parse_file(config_path.string());
+ }
+ catch(const toml::parse_error& error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to parse configuration: " +
+ std::string(error.description())));
+ }
+
+ for(const auto& [key, value] : table)
+ {
+ [[maybe_unused]] const toml::node& node = value;
+ if(!CONFIG_KEYS.contains(std::string(key.str())))
+ {
+ return std::unexpected(mw::runtimeError(
+ "Unknown 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");
+ auto database_path_text = requiredString(table, "database_path");
+ auto card_storage_text = requiredString(table, "card_storage_root");
+ auto avif_quality = requiredInteger(table, "avif_quality");
+ auto thumbnail_long_side = requiredInteger(
+ table, "thumbnail_long_side");
+ if(!base_url_text || !listen_address_text || !static_root_text ||
+ !database_path_text || !card_storage_text || !avif_quality ||
+ !thumbnail_long_side)
+ {
+ if(!base_url_text)
+ {
+ return std::unexpected(std::move(base_url_text.error()));
+ }
+ if(!listen_address_text)
+ {
+ return std::unexpected(std::move(listen_address_text.error()));
+ }
+ if(!static_root_text)
+ {
+ return std::unexpected(std::move(static_root_text.error()));
+ }
+ if(!database_path_text)
+ {
+ return std::unexpected(std::move(database_path_text.error()));
+ }
+ if(!card_storage_text)
+ {
+ return std::unexpected(std::move(card_storage_text.error()));
+ }
+ if(!avif_quality)
+ {
+ return std::unexpected(std::move(avif_quality.error()));
+ }
+ return std::unexpected(std::move(thumbnail_long_side.error()));
+ }
+
+ auto base_url = mw::URL::fromStr(*base_url_text);
+ if(!base_url ||
+ (base_url->scheme() != "http" && base_url->scheme() != "https") ||
+ base_url->host().empty())
+ {
+ return std::unexpected(mw::runtimeError(
+ "base_url must be an absolute HTTP or HTTPS URL"));
+ }
+ std::string base_path = base_url->path();
+ if(base_path.empty())
+ {
+ base_path = "/";
+ }
+ while(base_path.size() > 1 && base_path.ends_with('/'))
+ {
+ base_path.pop_back();
+ }
+ if(!base_path.ends_with('/'))
+ {
+ base_path.push_back('/');
+ }
+ base_url->path(base_path.c_str());
+
+ std::optional<mw::HTTPServer::ListenAddress> listen_address;
+ if(listen_address_text->starts_with("unix:"))
+ {
+ const std::string socket_path = listen_address_text->substr(5);
+ if(socket_path.empty())
+ {
+ return std::unexpected(mw::runtimeError(
+ "A Unix listen address requires a socket path"));
+ }
+ listen_address.emplace(mw::SocketFileInfo(socket_path));
+ }
+ else
+ {
+ auto listen_port = requiredInteger(table, "listen_port");
+ if(!listen_port || *listen_port < 1 || *listen_port > 65535)
+ {
+ return std::unexpected(mw::runtimeError(
+ "listen_port must be an integer from 1 through 65535"));
+ }
+ listen_address.emplace(mw::IPSocketInfo{
+ *listen_address_text, static_cast<int>(*listen_port)});
+ }
+ if(*avif_quality < 0 || *avif_quality > 100)
+ {
+ return std::unexpected(mw::runtimeError(
+ "avif_quality must be from 0 through 100"));
+ }
+ if(*thumbnail_long_side < 1 ||
+ *thumbnail_long_side > UINT32_MAX)
+ {
+ return std::unexpected(mw::runtimeError(
+ "thumbnail_long_side must be a positive 32-bit integer"));
+ }
+
+ const std::filesystem::path config_directory =
+ std::filesystem::absolute(config_path).parent_path();
+ const std::filesystem::path static_root = normalizedPath(
+ config_directory, *static_root_text);
+ const std::filesystem::path database_path = normalizedPath(
+ config_directory, *database_path_text);
+ const std::filesystem::path card_storage_root = normalizedPath(
+ config_directory, *card_storage_text);
+ std::error_code filesystem_error;
+ if(!std::filesystem::is_directory(static_root, filesystem_error) ||
+ filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "static_root must name an existing directory"));
+ }
+ if(pathContains(static_root, card_storage_root) ||
+ pathContains(card_storage_root, static_root))
+ {
+ return std::unexpected(mw::runtimeError(
+ "static_root and card_storage_root must not overlap"));
+ }
+ if(pathContains(static_root, database_path) ||
+ pathContains(card_storage_root, database_path))
+ {
+ return std::unexpected(mw::runtimeError(
+ "database_path must not be inside a mounted asset root"));
+ }
+
+ auto database_directory = createDirectory(
+ database_path.parent_path(), "the database directory");
+ if(!database_directory)
+ {
+ return std::unexpected(std::move(database_directory.error()));
+ }
+ auto card_directory = createDirectory(
+ card_storage_root, "the card storage directory");
+ if(!card_directory)
+ {
+ return std::unexpected(std::move(card_directory.error()));
+ }
+ auto published_directory = createDirectory(
+ card_storage_root / "published", "the published card directory");
+ if(!published_directory)
+ {
+ return std::unexpected(std::move(published_directory.error()));
+ }
+
+ return Config{
+ std::move(*base_url),
+ std::move(*listen_address),
+ static_root,
+ database_path,
+ card_storage_root,
+ static_cast<int>(*avif_quality),
+ static_cast<std::uint32_t>(*thumbnail_long_side),
+ };
+}
diff --git a/src/config.h b/src/config.h
index 982d3ce..89174fd 100644
--- a/src/config.h
+++ b/src/config.h
@@ -4,6 +4,7 @@
#include <filesystem>
#include <mw/http_server.hpp>
+#include <mw/error.hpp>
#include <mw/url.hpp>
/// Validated process configuration.
@@ -30,3 +31,6 @@ struct Config
/// Long-side pixel count used for generated thumbnails.
std::uint32_t thumbnail_long_side;
};
+
+/// Load, validate, and normalize one TOML process configuration.
+mw::E<Config> loadConfig(const std::filesystem::path& config_path);
diff --git a/src/data.h b/src/data.h
index 8bad276..bd397a5 100644
--- a/src/data.h
+++ b/src/data.h
@@ -10,12 +10,7 @@
#include "card.h"
#include "game.h"
-
-/// Polymorphic validated metadata owned by a compiled game.
-class GameCardMetadata;
-
-/// Compiled behavior and metadata schema for one game.
-class GameDefinition;
+#include "game_definition.h"
/// Immutable collection of compiled game definitions.
class GameRegistry;
@@ -108,6 +103,11 @@ public:
const GameDefinition& game,
std::int64_t card_id) const = 0;
+ /// Return current game-owned values for the edit form.
+ virtual mw::E<FormFields> getGameFormValues(
+ const GameDefinition& game,
+ std::int64_t card_id) const = 0;
+
/// Return all series, ordered by game and name.
virtual mw::E<std::vector<Series>> getSeries() const = 0;
diff --git a/src/data_fake.cpp b/src/data_fake.cpp
index f972541..674c68c 100644
--- a/src/data_fake.cpp
+++ b/src/data_fake.cpp
@@ -84,6 +84,13 @@ mw::E<std::vector<DisplayField>> DataSourceFake::getGameDisplayFields(
return fields->second;
}
+mw::E<FormFields> DataSourceFake::getGameFormValues(
+ [[maybe_unused]] const GameDefinition& game,
+ [[maybe_unused]] std::int64_t card_id) const
+{
+ return FormFields{};
+}
+
mw::E<std::vector<Series>> DataSourceFake::getSeries() const
{
return series_;
diff --git a/src/data_fake.h b/src/data_fake.h
index a050f2f..13db8e0 100644
--- a/src/data_fake.h
+++ b/src/data_fake.h
@@ -45,6 +45,11 @@ public:
const GameDefinition& game,
std::int64_t card_id) const override;
+ /// Return no editable game values from the read-only fake.
+ mw::E<FormFields> getGameFormValues(
+ const GameDefinition& game,
+ std::int64_t card_id) const override;
+
/// Return all configured series in insertion order.
mw::E<std::vector<Series>> getSeries() const override;
diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
index 097f924..ae48098 100644
--- a/src/data_sqlite.cpp
+++ b/src/data_sqlite.cpp
@@ -13,15 +13,11 @@
#include <spdlog/spdlog.h>
-namespace
-{
+#include "game_definition.h"
+#include "game_registry.h"
-mw::Error notImplemented(std::string_view operation)
+namespace
{
- return mw::runtimeError(
- "DataSourceSQLite has not implemented " +
- std::string(operation) + ".");
-}
mw::E<void> rollbackWithError(
mw::SQLite& connection,
@@ -324,15 +320,19 @@ public:
return std::unexpected(mw::runtimeError(
"A game card requires compiled game metadata"));
}
- return std::unexpected(notImplemented(
- "game-specific card insertion"));
+ if(game->shortName() != *card.identity.game_short_name)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Card and compiled game identities differ"));
+ }
}
- if(game != nullptr)
+ else if(game != nullptr || !series_ids.empty())
{
return std::unexpected(mw::runtimeError(
- "A loose card cannot have compiled game metadata"));
+ "A loose card cannot have game metadata or series"));
}
- if(card.identity.card_number >
+ if(!card.identity.game_short_name &&
+ card.identity.card_number >
std::numeric_limits<std::uint32_t>::max())
{
return std::unexpected(mw::runtimeError(
@@ -371,6 +371,16 @@ public:
}
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(
@@ -412,19 +422,33 @@ public:
return std::unexpected(mw::runtimeError(
"An updated card requires an internal ID"));
}
- if(card.identity.game_short_name || game != nullptr ||
- metadata != nullptr || !series_ids.empty())
+ 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(notImplemented(
- "game-specific card updates"));
+ 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 game_short_name IS NULL "
- "AND card_number = ?;");
+ "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()));
@@ -439,6 +463,8 @@ public:
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)
{
@@ -460,35 +486,146 @@ public:
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(
- [[maybe_unused]] std::int64_t card_id) override
+ std::int64_t card_id) override
{
- return std::unexpected(notImplemented("card deletion"));
+ 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(
- [[maybe_unused]] const Series& series) override
+ const Series& series) override
{
- return std::unexpected(notImplemented("series insertion"));
+ 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(
- [[maybe_unused]] const Series& series) override
+ const Series& series) override
{
- return std::unexpected(notImplemented("series updates"));
+ 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(
- [[maybe_unused]] std::int64_t series_id) override
+ std::int64_t series_id) override
{
- return std::unexpected(notImplemented("series deletion"));
+ 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.
@@ -536,6 +673,19 @@ mw::E<std::unique_ptr<DataSourceSQLite>> DataSourceSQLite::fromFile(
{
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)));
}
@@ -547,7 +697,7 @@ mw::E<std::int64_t> DataSourceSQLite::getSchemaVersion() const
}
mw::E<void> DataSourceSQLite::migrateSchema0To1(
- [[maybe_unused]] const GameRegistry& games)
+ const GameRegistry& games)
{
std::lock_guard lock(mutex_);
auto version = connection_->evalToValue<std::int64_t>(
@@ -576,6 +726,15 @@ mw::E<void> DataSourceSQLite::migrateSchema0To1(
*connection_, std::move(result.error()));
}
}
+ for(const GameDefinition* game : games.games())
+ {
+ auto game_schema = game->createSchema(*connection_);
+ if(!game_schema)
+ {
+ return rollbackWithError(
+ *connection_, std::move(game_schema.error()));
+ }
+ }
auto set_version = connection_->execute("PRAGMA user_version = 1;");
if(!set_version)
@@ -748,21 +907,85 @@ mw::E<std::optional<Card>> DataSourceSQLite::getCard(
mw::E<std::vector<DisplayField>>
DataSourceSQLite::getGameDisplayFields(
- [[maybe_unused]] const GameDefinition& game,
- [[maybe_unused]] std::int64_t card_id) const
+ const GameDefinition& game,
+ std::int64_t card_id) const
{
- return std::unexpected(notImplemented("game display-field reads"));
+ std::lock_guard lock(mutex_);
+ return game.displayFields(*connection_, card_id);
+}
+
+mw::E<FormFields> DataSourceSQLite::getGameFormValues(
+ const GameDefinition& game,
+ std::int64_t card_id) const
+{
+ std::lock_guard lock(mutex_);
+ return game.formValues(*connection_, card_id);
}
mw::E<std::vector<Series>> DataSourceSQLite::getSeries() const
{
- return std::unexpected(notImplemented("series index reads"));
+ std::lock_guard lock(mutex_);
+ auto rows = connection_->eval<
+ std::int64_t,
+ std::string,
+ std::string,
+ std::string>(
+ "SELECT id, game_short_name, name, description "
+ "FROM series ORDER BY game_short_name, name, id;");
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ std::vector<Series> result;
+ result.reserve(rows->size());
+ for(auto& [id, game, name, description] : *rows)
+ {
+ result.push_back({
+ id,
+ std::move(game),
+ std::move(name),
+ std::move(description),
+ });
+ }
+ return result;
}
mw::E<std::optional<Series>> DataSourceSQLite::getSeries(
- [[maybe_unused]] std::int64_t series_id) const
+ std::int64_t series_id) const
{
- return std::unexpected(notImplemented("series reads"));
+ std::lock_guard lock(mutex_);
+ auto statement = connection_->statementFromStr(
+ "SELECT id, game_short_name, name, description "
+ "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()));
+ }
+ auto rows = connection_->eval<
+ std::int64_t,
+ std::string,
+ std::string,
+ std::string>(std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ if(rows->empty())
+ {
+ return std::optional<Series>{};
+ }
+ auto& [id, game, name, description] = rows->front();
+ return std::optional<Series>(Series{
+ id,
+ std::move(game),
+ std::move(name),
+ std::move(description),
+ });
}
mw::E<std::vector<std::int64_t>> DataSourceSQLite::getCardSeries(
@@ -798,7 +1021,23 @@ mw::E<std::vector<std::int64_t>> DataSourceSQLite::getCardSeries(
mw::E<std::vector<std::string>>
DataSourceSQLite::getPersistedGameNames() const
{
- return std::unexpected(notImplemented("persisted-game reads"));
+ std::lock_guard lock(mutex_);
+ auto rows = connection_->eval<std::string>(
+ "SELECT game_short_name FROM cards "
+ "WHERE game_short_name IS NOT NULL "
+ "UNION SELECT game_short_name FROM series "
+ "ORDER BY game_short_name;");
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ std::vector<std::string> result;
+ result.reserve(rows->size());
+ for(auto& [name] : *rows)
+ {
+ result.push_back(std::move(name));
+ }
+ return result;
}
mw::E<void> DataSourceSQLite::setSchemaVersion(
diff --git a/src/data_sqlite.h b/src/data_sqlite.h
index 9b97850..aca4c42 100644
--- a/src/data_sqlite.h
+++ b/src/data_sqlite.h
@@ -48,6 +48,11 @@ public:
const GameDefinition& game,
std::int64_t card_id) const override;
+ /// Return current game-owned values for the edit form.
+ mw::E<FormFields> getGameFormValues(
+ const GameDefinition& game,
+ std::int64_t card_id) const override;
+
/// Return all series, ordered by game and name.
mw::E<std::vector<Series>> getSeries() const override;
diff --git a/src/game_definition.h b/src/game_definition.h
new file mode 100644
index 0000000..44b9e25
--- /dev/null
+++ b/src/game_definition.h
@@ -0,0 +1,92 @@
+#pragma once
+
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+#include <vector>
+
+#include <mw/database.hpp>
+#include <mw/error.hpp>
+
+#include "game.h"
+
+/// Posted fields owned by one compiled game definition.
+using FormFields = std::unordered_map<std::string, std::string>;
+
+/// One server-owned field rendered for compiled-game card metadata.
+struct GameFormField
+{
+ /// Posted field name below the game namespace.
+ std::string name;
+
+ /// Human-readable form label.
+ std::string label;
+
+ /// Trusted HTML input type.
+ std::string input_type;
+
+ /// Whether browsers should require a value.
+ bool required;
+
+ /// Optional minimum value for numeric inputs.
+ std::string minimum;
+};
+
+/// Polymorphic validated metadata owned by a compiled game.
+class GameCardMetadata
+{
+public:
+ /// Destroy validated game metadata through its base interface.
+ virtual ~GameCardMetadata() = default;
+};
+
+/// Compiled behavior and persistence hooks for one game.
+class GameDefinition
+{
+public:
+ /// Destroy a compiled game through its base interface.
+ virtual ~GameDefinition() = default;
+
+ /// Return the immutable lowercase public short name.
+ virtual std::string_view shortName() const = 0;
+
+ /// Return the human-readable game name.
+ virtual std::string_view displayName() const = 0;
+
+ /// Return the Markdown game description.
+ virtual std::string_view description() const = 0;
+
+ /// Add this game's extension schema to a new database.
+ virtual mw::E<void> createSchema(mw::SQLite& database) const = 0;
+
+ /// Validate posted game fields into strongly typed metadata.
+ virtual mw::E<std::unique_ptr<GameCardMetadata>> validateMetadata(
+ const FormFields& fields) const = 0;
+
+ /// Return trusted field definitions for the create-card form.
+ virtual std::vector<GameFormField> formFields() const = 0;
+
+ /// Return current field values for the edit-card form.
+ virtual mw::E<FormFields> formValues(
+ mw::SQLite& database,
+ std::int64_t card_id) const = 0;
+
+ /// Insert game-owned metadata for a common card ID.
+ virtual mw::E<void> insertMetadata(
+ mw::SQLite& database,
+ std::int64_t card_id,
+ const GameCardMetadata& metadata) const = 0;
+
+ /// Replace game-owned metadata for a common card ID.
+ virtual mw::E<void> updateMetadata(
+ mw::SQLite& database,
+ std::int64_t card_id,
+ const GameCardMetadata& metadata) const = 0;
+
+ /// Return read-only label/value fields for a common card ID.
+ virtual mw::E<std::vector<DisplayField>> displayFields(
+ mw::SQLite& database,
+ std::int64_t card_id) const = 0;
+};
diff --git a/src/game_registry.cpp b/src/game_registry.cpp
new file mode 100644
index 0000000..38edc25
--- /dev/null
+++ b/src/game_registry.cpp
@@ -0,0 +1,65 @@
+#include "game_registry.h"
+
+#include <algorithm>
+#include <memory>
+#include <string_view>
+
+#include "game_definition.h"
+
+namespace
+{
+
+bool validShortName(std::string_view value)
+{
+ return !value.empty() && std::ranges::all_of(
+ value,
+ [](char character)
+ {
+ return (character >= 'a' && character <= 'z') ||
+ (character >= '0' && character <= '9');
+ });
+}
+
+} // namespace
+
+GameRegistry::GameRegistry() = default;
+
+GameRegistry::~GameRegistry() = default;
+
+mw::E<void> GameRegistry::add(std::unique_ptr<GameDefinition> game)
+{
+ if(!game || !validShortName(game->shortName()))
+ {
+ return std::unexpected(mw::runtimeError(
+ "A compiled game requires a lowercase alphanumeric short name"));
+ }
+ if(find(game->shortName()) != nullptr)
+ {
+ return std::unexpected(mw::runtimeError(
+ "A compiled game short name was registered more than once"));
+ }
+ games_.push_back(std::move(game));
+ return {};
+}
+
+const GameDefinition* GameRegistry::find(std::string_view short_name) const
+{
+ const auto position = std::ranges::find_if(
+ games_,
+ [short_name](const std::unique_ptr<GameDefinition>& game)
+ {
+ return game->shortName() == short_name;
+ });
+ return position == games_.end() ? nullptr : position->get();
+}
+
+std::vector<const GameDefinition*> GameRegistry::games() const
+{
+ std::vector<const GameDefinition*> result;
+ result.reserve(games_.size());
+ for(const std::unique_ptr<GameDefinition>& game : games_)
+ {
+ result.push_back(game.get());
+ }
+ return result;
+}
diff --git a/src/game_registry.h b/src/game_registry.h
index 5dd744b..b9c7569 100644
--- a/src/game_registry.h
+++ b/src/game_registry.h
@@ -1,12 +1,32 @@
#pragma once
-/// Immutable collection of compiled game definitions.
-///
-/// Production currently registers no games. Lookup and schema-extension
-/// behavior will be added with the first compiled game definition.
+#include <memory>
+#include <string_view>
+#include <vector>
+
+#include <mw/error.hpp>
+
+class GameDefinition;
+
+/// Immutable-after-startup collection of compiled game definitions.
class GameRegistry
{
public:
- /// Construct the empty production registry.
- GameRegistry() = default;
+ /// Construct an empty registry.
+ GameRegistry();
+
+ /// Destroy all owned compiled game definitions.
+ ~GameRegistry();
+
+ /// Register one uniquely named compiled game definition.
+ mw::E<void> add(std::unique_ptr<GameDefinition> game);
+
+ /// Return a registered game by short name, or null when absent.
+ const GameDefinition* find(std::string_view short_name) const;
+
+ /// Return all registered games in registration order.
+ std::vector<const GameDefinition*> games() const;
+
+private:
+ std::vector<std::unique_ptr<GameDefinition>> games_;
};
diff --git a/src/main.cpp b/src/main.cpp
index 473b719..f5de9a2 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,7 +1,8 @@
#include <array>
#include <chrono>
#include <csignal>
-#include <filesystem>
+#include <iostream>
+#include <memory>
#include <string>
#include <thread>
#include <utility>
@@ -16,9 +17,6 @@
namespace
{
-/// Development HTTP port used until configuration loading is implemented.
-inline constexpr int DEVELOPMENT_PORT = 8080;
-
/// Signal-safe flag requesting shutdown from the main loop.
volatile std::sig_atomic_t STOP_REQUESTED = 0;
@@ -61,44 +59,36 @@ mw::E<void> prepareImageMagick()
return {};
}
-mw::E<Config> makeDevelopmentConfig()
+void printUsage(std::ostream& output, const char* program)
{
- const std::string base_url_text =
- "http://127.0.0.1:" + std::to_string(DEVELOPMENT_PORT) + "/";
- auto base_url = mw::URL::fromStr(base_url_text);
- if(!base_url)
- {
- return std::unexpected(std::move(base_url.error()));
- }
-
- const std::filesystem::path source_root = CARD_COLLECTION_SOURCE_DIR;
- const std::filesystem::path card_storage_root = source_root / "var/cards";
- std::error_code filesystem_error;
- std::filesystem::create_directories(
- card_storage_root / "published", filesystem_error);
- if(filesystem_error)
- {
- return std::unexpected(mw::runtimeError(
- "Failed to create the development card directory: " +
- filesystem_error.message()));
- }
-
- return Config{
- std::move(*base_url),
- mw::IPSocketInfo{"127.0.0.1", DEVELOPMENT_PORT},
- source_root / "static",
- source_root / "var/card_collection.sqlite3",
- card_storage_root,
- 75,
- 256,
- };
+ output << "Usage: " << program << " <config.toml>\n";
}
} // namespace
-/// Start the Card Collection development server with its SQLite database.
+/// Start Card Collection from one validated configuration file.
int main(int argc, char** argv)
{
+ const char* program = argc > 0 ? argv[0] : "card_collection";
+ if(argc == 2 && std::string(argv[1]) == "--help")
+ {
+ printUsage(std::cout, program);
+ return 0;
+ }
+ if(argc != 2)
+ {
+ printUsage(std::cerr, program);
+ return 2;
+ }
+
+ auto config = loadConfig(argv[1]);
+ if(!config)
+ {
+ std::cerr << "Failed to load configuration: "
+ << config.error().msg() << '\n';
+ return 1;
+ }
+
Magick::InitializeMagick(argc > 0 ? argv[0] : nullptr);
auto image_magick = prepareImageMagick();
if(!image_magick)
@@ -109,26 +99,31 @@ int main(int argc, char** argv)
return 1;
}
- auto config = makeDevelopmentConfig();
- if(!config)
+ auto games = std::make_unique<GameRegistry>();
+ auto data_source = prepareDataSource(config->database_path, *games);
+ if(!data_source)
{
spdlog::error(
- "Failed to configure the server: {}",
- config.error().msg());
+ "Failed to prepare the card database: {}",
+ data_source.error().msg());
return 1;
}
- GameRegistry games;
- auto data_source = prepareDataSource(config->database_path, games);
- if(!data_source)
+ AssetStore assets(config->card_storage_root);
+ auto reconciled = assets.reconcile(**data_source);
+ if(!reconciled)
{
spdlog::error(
- "Failed to prepare the card database: {}",
- data_source.error().msg());
+ "Failed to reconcile card storage: {}",
+ reconciled.error().msg());
return 1;
}
- App app(*config, std::move(*data_source));
+ App app(
+ *config,
+ std::move(*data_source),
+ std::move(games),
+ std::make_unique<NonSecretRandom>());
std::signal(SIGINT, handleSignal);
std::signal(SIGTERM, handleSignal);
@@ -142,9 +137,7 @@ int main(int argc, char** argv)
return 1;
}
- spdlog::info(
- "Development server listening at http://127.0.0.1:{}/",
- DEVELOPMENT_PORT);
+ spdlog::info("Server listening at {}", config->base_url.str());
while(STOP_REQUESTED == 0)
{
std::this_thread::sleep_for(std::chrono::milliseconds(100));
diff --git a/src/markdown_renderer.cpp b/src/markdown_renderer.cpp
new file mode 100644
index 0000000..83a201b
--- /dev/null
+++ b/src/markdown_renderer.cpp
@@ -0,0 +1,145 @@
+#include "markdown_renderer.h"
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <variant>
+
+#include <macrodown.h>
+#include <mw/url.hpp>
+#include <mw/utils.hpp>
+#include <nodes.h>
+
+namespace
+{
+
+std::optional<std::string> literalText(const macrodown::Node& node)
+{
+ if(const auto* text = std::get_if<macrodown::Text>(&node.data))
+ {
+ return text->content;
+ }
+ const auto* group = std::get_if<macrodown::Group>(&node.data);
+ if(group == nullptr)
+ {
+ return std::nullopt;
+ }
+ std::string result;
+ for(const std::unique_ptr<macrodown::Node>& child : group->children)
+ {
+ auto child_text = literalText(*child);
+ if(!child_text)
+ {
+ return std::nullopt;
+ }
+ result += *child_text;
+ }
+ return result;
+}
+
+mw::E<void> validateUrls(const macrodown::Node& node)
+{
+ if(const auto* macro = std::get_if<macrodown::Macro>(&node.data))
+ {
+ if(macro->name == "link" || macro->name == "img")
+ {
+ if(macro->arguments.empty())
+ {
+ return std::unexpected(mw::httpError(
+ 422, "Markdown links require an absolute URL"));
+ }
+ auto text = literalText(*macro->arguments.front());
+ auto url = text ? mw::URL::fromStr(*text)
+ : mw::E<mw::URL>(std::unexpected(
+ mw::runtimeError("Computed URL")));
+ if(!text || !url ||
+ (url->scheme() != "http" && url->scheme() != "https") ||
+ url->host().empty())
+ {
+ return std::unexpected(mw::httpError(
+ 422,
+ "Markdown links and images require literal HTTP or "
+ "HTTPS URLs"));
+ }
+ }
+ for(const std::unique_ptr<macrodown::Node>& argument :
+ macro->arguments)
+ {
+ auto valid = validateUrls(*argument);
+ if(!valid)
+ {
+ return valid;
+ }
+ }
+ }
+ else if(const auto* group = std::get_if<macrodown::Group>(&node.data))
+ {
+ for(const std::unique_ptr<macrodown::Node>& child : group->children)
+ {
+ auto valid = validateUrls(*child);
+ if(!valid)
+ {
+ return valid;
+ }
+ }
+ }
+ return {};
+}
+
+void escapeText(macrodown::Node& node)
+{
+ if(auto* text = std::get_if<macrodown::Text>(&node.data))
+ {
+ text->content = mw::escapeHTML(text->content);
+ }
+ else if(auto* macro = std::get_if<macrodown::Macro>(&node.data))
+ {
+ for(const std::unique_ptr<macrodown::Node>& argument :
+ macro->arguments)
+ {
+ escapeText(*argument);
+ }
+ }
+ else if(auto* group = std::get_if<macrodown::Group>(&node.data))
+ {
+ for(const std::unique_ptr<macrodown::Node>& child : group->children)
+ {
+ escapeText(*child);
+ }
+ }
+}
+
+} // namespace
+
+RenderedHtml::RenderedHtml(std::string value)
+ : value_(std::move(value))
+{}
+
+const std::string& RenderedHtml::value() const
+{
+ return value_;
+}
+
+mw::E<RenderedHtml> MarkdownRenderer::render(
+ const std::string& source) const
+{
+ try
+ {
+ macrodown::MacroDown renderer;
+ std::unique_ptr<macrodown::Node> tree = renderer.parse(source);
+ auto valid = validateUrls(*tree);
+ if(!valid)
+ {
+ return std::unexpected(std::move(valid.error()));
+ }
+ escapeText(*tree);
+ return RenderedHtml(renderer.render(*tree));
+ }
+ catch(const std::exception& error)
+ {
+ return std::unexpected(mw::httpError(
+ 422, "Markdown could not be rendered: " +
+ std::string(error.what())));
+ }
+}
diff --git a/src/markdown_renderer.h b/src/markdown_renderer.h
new file mode 100644
index 0000000..048f23f
--- /dev/null
+++ b/src/markdown_renderer.h
@@ -0,0 +1,27 @@
+#pragma once
+
+#include <string>
+
+#include <mw/error.hpp>
+
+/// Trusted HTML produced only by the validated Markdown renderer.
+class RenderedHtml
+{
+public:
+ /// Construct a trusted fragment from validated renderer output.
+ explicit RenderedHtml(std::string value);
+
+ /// Return the trusted HTML fragment.
+ const std::string& value() const;
+
+private:
+ std::string value_;
+};
+
+/// Render MacroDown documents while rejecting unsafe URLs and raw HTML.
+class MarkdownRenderer
+{
+public:
+ /// Validate and render one Markdown document.
+ mw::E<RenderedHtml> render(const std::string& source) const;
+};
diff --git a/src/multipart_reader.cpp b/src/multipart_reader.cpp
index ab2af04..c5cdb10 100644
--- a/src/multipart_reader.cpp
+++ b/src/multipart_reader.cpp
@@ -24,6 +24,10 @@ std::atomic<std::uint64_t> NEXT_STAGING_ID = 0;
bool isTextField(std::string_view name)
{
+ if(name.starts_with("game.") && name.size() > 5)
+ {
+ return true;
+ }
static const std::unordered_set<std::string> names = {
"game",
"name",
diff --git a/src/non_secret_random.cpp b/src/non_secret_random.cpp
new file mode 100644
index 0000000..c51a922
--- /dev/null
+++ b/src/non_secret_random.cpp
@@ -0,0 +1,46 @@
+#include "non_secret_random.h"
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <random>
+#include <string>
+
+NonSecretRandom::NonSecretRandom()
+{
+ std::random_device device;
+ std::array<std::uint32_t, 8> seeds;
+ for(std::uint32_t& seed : seeds)
+ {
+ seed = device();
+ }
+ std::seed_seq sequence(seeds.begin(), seeds.end());
+ engine_.seed(sequence);
+}
+
+NonSecretRandom::NonSecretRandom(std::uint64_t seed)
+ : engine_(seed)
+{}
+
+std::uint32_t NonSecretRandom::next()
+{
+ std::lock_guard lock(mutex_);
+ std::uniform_int_distribution<std::uint32_t> distribution;
+ return distribution(engine_);
+}
+
+std::string NonSecretRandom::hex(std::size_t byte_count)
+{
+ static constexpr char HEX_DIGITS[] = "0123456789abcdef";
+ std::lock_guard lock(mutex_);
+ std::uniform_int_distribution<unsigned int> distribution(0, 255);
+ std::string result;
+ result.reserve(byte_count * 2);
+ for(std::size_t index = 0; index < byte_count; ++index)
+ {
+ const unsigned int byte = distribution(engine_);
+ result.push_back(HEX_DIGITS[byte >> 4]);
+ result.push_back(HEX_DIGITS[byte & 0x0f]);
+ }
+ return result;
+}
diff --git a/src/non_secret_random.h b/src/non_secret_random.h
new file mode 100644
index 0000000..352a31d
--- /dev/null
+++ b/src/non_secret_random.h
@@ -0,0 +1,28 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <mutex>
+#include <random>
+#include <string>
+
+/// Thread-safe pseudorandom values that must never be used as secrets.
+class NonSecretRandom
+{
+public:
+ /// Seed a production generator from std::random_device.
+ NonSecretRandom();
+
+ /// Seed a deterministic generator for tests.
+ explicit NonSecretRandom(std::uint64_t seed);
+
+ /// Return one uniformly distributed 32-bit value.
+ std::uint32_t next();
+
+ /// Return pseudorandom bytes encoded as lowercase hexadecimal.
+ std::string hex(std::size_t byte_count);
+
+private:
+ std::mutex mutex_;
+ std::mt19937_64 engine_;
+};
diff --git a/src/series_service.cpp b/src/series_service.cpp
new file mode 100644
index 0000000..61e277a
--- /dev/null
+++ b/src/series_service.cpp
@@ -0,0 +1,169 @@
+#include "series_service.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <string>
+#include <utility>
+
+#include <mw/utils.hpp>
+
+namespace
+{
+
+mw::E<void> rejectDuplicate(
+ DataSourceInterface& data_source,
+ const Series& candidate)
+{
+ auto series = data_source.getSeries();
+ if(!series)
+ {
+ return std::unexpected(std::move(series.error()));
+ }
+ const bool duplicate = std::ranges::any_of(
+ *series,
+ [&candidate](const Series& existing)
+ {
+ return existing.id != candidate.id &&
+ existing.game_short_name == candidate.game_short_name &&
+ existing.name == candidate.name;
+ });
+ if(duplicate)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "A series with this name already exists for the game"));
+ }
+ return {};
+}
+
+} // namespace
+
+SeriesService::SeriesService(
+ DataSourceInterface& data_source,
+ const GameRegistry& games)
+ : data_source_(data_source),
+ games_(games)
+{}
+
+mw::E<std::int64_t> SeriesService::create(
+ std::string game_short_name,
+ std::string name,
+ std::string description)
+{
+ name = std::string(mw::strip(name));
+ if(games_.find(game_short_name) == nullptr)
+ {
+ return std::unexpected(mw::httpError(422, "Unknown compiled game"));
+ }
+ if(name.empty())
+ {
+ return std::unexpected(mw::httpError(422, "Series name is required"));
+ }
+ if(name.size() > 200)
+ {
+ return std::unexpected(mw::httpError(422, "Series name is too long"));
+ }
+ auto rendered = markdown_renderer_.render(description);
+ if(!rendered)
+ {
+ return std::unexpected(std::move(rendered.error()));
+ }
+ Series series = {
+ 0,
+ std::move(game_short_name),
+ std::move(name),
+ std::move(description),
+ };
+ auto unique = rejectDuplicate(data_source_, series);
+ if(!unique)
+ {
+ return std::unexpected(std::move(unique.error()));
+ }
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto id = (*transaction)->insertSeries(series);
+ if(!id)
+ {
+ return std::unexpected(std::move(id.error()));
+ }
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ return std::unexpected(std::move(committed.error()));
+ }
+ return *id;
+}
+
+mw::E<void> SeriesService::update(
+ std::int64_t series_id,
+ std::string name,
+ std::string description)
+{
+ auto current = data_source_.getSeries(series_id);
+ if(!current)
+ {
+ return std::unexpected(std::move(current.error()));
+ }
+ if(!*current)
+ {
+ return std::unexpected(mw::httpError(404, "Series not found"));
+ }
+ Series series = std::move(**current);
+ series.name = std::string(mw::strip(name));
+ series.description = std::move(description);
+ if(series.name.empty())
+ {
+ return std::unexpected(mw::httpError(422, "Series name is required"));
+ }
+ if(series.name.size() > 200)
+ {
+ return std::unexpected(mw::httpError(422, "Series name is too long"));
+ }
+ auto rendered = markdown_renderer_.render(series.description);
+ if(!rendered)
+ {
+ return std::unexpected(std::move(rendered.error()));
+ }
+ auto unique = rejectDuplicate(data_source_, series);
+ if(!unique)
+ {
+ return std::unexpected(std::move(unique.error()));
+ }
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto updated = (*transaction)->updateSeries(series);
+ if(!updated)
+ {
+ return std::unexpected(std::move(updated.error()));
+ }
+ return (*transaction)->commit();
+}
+
+mw::E<void> SeriesService::remove(std::int64_t series_id)
+{
+ auto current = data_source_.getSeries(series_id);
+ if(!current)
+ {
+ return std::unexpected(std::move(current.error()));
+ }
+ if(!*current)
+ {
+ return std::unexpected(mw::httpError(404, "Series not found"));
+ }
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto deleted = (*transaction)->deleteSeries(series_id);
+ if(!deleted)
+ {
+ return std::unexpected(std::move(deleted.error()));
+ }
+ return (*transaction)->commit();
+}
diff --git a/src/series_service.h b/src/series_service.h
new file mode 100644
index 0000000..562cab7
--- /dev/null
+++ b/src/series_service.h
@@ -0,0 +1,40 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+
+#include <mw/error.hpp>
+
+#include "data.h"
+#include "game_registry.h"
+#include "markdown_renderer.h"
+
+/// Validate and coordinate dynamic series mutations.
+class SeriesService
+{
+public:
+ /// Construct a series service from its persistence and game boundaries.
+ SeriesService(
+ DataSourceInterface& data_source,
+ const GameRegistry& games);
+
+ /// Create a series and return its internal ID.
+ mw::E<std::int64_t> create(
+ 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 series_id,
+ std::string name,
+ std::string description);
+
+ /// Delete a series and its membership rows.
+ mw::E<void> remove(std::int64_t series_id);
+
+private:
+ DataSourceInterface& data_source_;
+ const GameRegistry& games_;
+ MarkdownRenderer markdown_renderer_;
+};
diff --git a/src/startup.cpp b/src/startup.cpp
index 8646751..ba820f0 100644
--- a/src/startup.cpp
+++ b/src/startup.cpp
@@ -3,6 +3,8 @@
#include <utility>
#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,
@@ -18,5 +20,40 @@ mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
{
return std::unexpected(std::move(migration_result.error()));
}
+
+ auto transaction = (*data_source)->beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ for(const GameDefinition* game : games.games())
+ {
+ auto ensured = (*transaction)->ensureGameSequence(
+ std::string(game->shortName()));
+ if(!ensured)
+ {
+ return std::unexpected(std::move(ensured.error()));
+ }
+ }
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ return std::unexpected(std::move(committed.error()));
+ }
+
+ auto persisted_games = (*data_source)->getPersistedGameNames();
+ if(!persisted_games)
+ {
+ return std::unexpected(std::move(persisted_games.error()));
+ }
+ for(const std::string& name : *persisted_games)
+ {
+ if(games.find(name) == nullptr)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database uses unregistered compiled game '" + name +
+ "'; a schema migration is required"));
+ }
+ }
return std::move(*data_source);
}
diff --git a/static/card_form.js b/static/card_form.js
index a73aa1e..4f56ae7 100644
--- a/static/card_form.js
+++ b/static/card_form.js
@@ -45,14 +45,41 @@ function updateSourceMode()
const foil_file = document.getElementById("CardFoilFile");
const front_url = document.getElementById("CardFrontUrl");
const foil_url = document.getElementById("CardFoilUrl");
- front_file.disabled = !uses_files || !replacesFront();
- foil_file.disabled = !uses_files || !replacesFoil();
- front_url.disabled = uses_files || !replacesFront();
- foil_url.disabled = uses_files || !replacesFoil();
- front_file.required = uses_files && replacesFront();
- front_url.required = !uses_files && replacesFront();
- foil_file.required = uses_files && isEditForm() && replacesFoil();
- foil_url.required = !uses_files && isEditForm() && replacesFoil();
+ const state = window.cardFormLogic.sourceInputState({
+ mode: isEditForm() ? "edit" : "create",
+ source_mode: selected_mode,
+ front_action: replacesFront() ? "replace" : "keep",
+ foil_action: replacesFoil() ? "replace" : "keep",
+ });
+ front_file.disabled = !state.front_file_enabled;
+ foil_file.disabled = !state.foil_file_enabled;
+ front_url.disabled = !state.front_url_enabled;
+ foil_url.disabled = !state.foil_url_enabled;
+ front_file.required = state.front_file_enabled && state.front_required;
+ front_url.required = state.front_url_enabled && state.front_required;
+ foil_file.required = state.foil_file_enabled && state.foil_required;
+ foil_url.required = state.foil_url_enabled && state.foil_required;
+}
+
+/** Show only series belonging to the selected compiled game. */
+function updateSeriesChoices()
+{
+ const game = document.getElementById("CardGame").value;
+ for(const group of document.querySelectorAll(".game-fields"))
+ {
+ const visible = group.dataset.game == game;
+ group.hidden = !visible;
+ for(const input of group.querySelectorAll("input"))
+ {
+ input.disabled = !visible;
+ }
+ }
+ for(const choice of document.querySelectorAll(".series-choice"))
+ {
+ const visible = game != "" && choice.dataset.game == game;
+ choice.hidden = !visible;
+ choice.querySelector("input").disabled = !visible;
+ }
}
/** Display the selected image filename beside one file input. */
@@ -135,11 +162,11 @@ async function updateLocalPreview()
/** Fetch one CORS-enabled URL into its corresponding file input. */
async function fetchImageInput(url_input, file_input, filename)
{
- const url = new URL(url_input.value);
- if(url.protocol != "http:" && url.protocol != "https:")
+ if(!window.cardFormLogic.validRemoteImageUrl(url_input.value))
{
throw(new Error("Image URLs must use HTTP or HTTPS."));
}
+ const url = new URL(url_input.value);
const response = await fetch(url, {
mode: "cors",
credentials: "omit",
@@ -211,11 +238,16 @@ async function prepareCardSubmission(event)
{
await updateLocalPreview();
}
- const resulting_foil = foil_action != "remove" &&
- (foil_action == "replace"
- ? foil_input.files.length != 0
- : page_data.foil_url != null);
- if(rendering_changed && resulting_foil)
+ const needs_thumbnail = window.cardFormLogic.foilThumbnailRequired({
+ mode: page_data.mode,
+ front_action: page_data.mode == "edit"
+ ? document.getElementById("CardFrontAction").value
+ : "replace",
+ foil_action,
+ has_current_foil: page_data.foil_url != null,
+ has_replacement_foil: foil_input.files.length != 0,
+ });
+ if(needs_thumbnail)
{
if(window.cardPreview == null)
{
@@ -288,6 +320,12 @@ function initializeCardForm()
updateLocalPreview().catch(console.error);
});
}
+ else
+ {
+ document.getElementById("CardGame").addEventListener(
+ "change", updateSeriesChoices);
+ updateSeriesChoices();
+ }
document.getElementById("CardForm").addEventListener(
"submit", prepareCardSubmission);
updateSourceMode();
diff --git a/static/card_form_logic.js b/static/card_form_logic.js
new file mode 100644
index 0000000..aaadb16
--- /dev/null
+++ b/static/card_form_logic.js
@@ -0,0 +1,66 @@
+(function exportCardFormLogic(root)
+{
+ /** Return whether a URL is an absolute HTTP or HTTPS image source. */
+ function validRemoteImageUrl(value)
+ {
+ try
+ {
+ const url = new URL(value);
+ return url.protocol == "http:" || url.protocol == "https:";
+ }
+ catch(error)
+ {
+ return false;
+ }
+ }
+
+ /** Return whether a changed resulting foil card needs a thumbnail. */
+ function foilThumbnailRequired(options)
+ {
+ const rendering_changed = options.mode == "create" ||
+ options.front_action == "replace" ||
+ options.foil_action != "keep";
+ let resulting_foil = options.has_current_foil;
+ if(options.foil_action == "remove")
+ {
+ resulting_foil = false;
+ }
+ else if(options.foil_action == "replace")
+ {
+ resulting_foil = options.has_replacement_foil;
+ }
+ return rendering_changed && resulting_foil;
+ }
+
+ /** Return enabled and required image inputs for the current form state. */
+ function sourceInputState(options)
+ {
+ const uses_files = options.source_mode == "files";
+ const needs_front = options.mode == "create" ||
+ options.front_action == "replace";
+ const replaces_foil = options.mode == "create" ||
+ options.foil_action == "replace";
+ return {
+ front_file_enabled: uses_files && needs_front,
+ foil_file_enabled: uses_files && replaces_foil,
+ front_url_enabled: !uses_files && needs_front,
+ foil_url_enabled: !uses_files && replaces_foil,
+ front_required: needs_front,
+ foil_required: options.mode == "edit" && replaces_foil,
+ };
+ }
+
+ const api = {
+ validRemoteImageUrl,
+ foilThumbnailRequired,
+ sourceInputState,
+ };
+ if(typeof module != "undefined" && module.exports != null)
+ {
+ module.exports = api;
+ }
+ else
+ {
+ root.cardFormLogic = api;
+ }
+})(globalThis);
diff --git a/static/css/styles.css b/static/css/styles.css
index 5861370..1323c00 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -833,6 +833,65 @@ h1 {
font-size: 0.8rem;
}
+.page-shell {
+ width: min(68rem, 100%);
+ margin-inline: auto;
+}
+
+.form-page {
+ width: min(40rem, 100%);
+ padding: clamp(1.5rem, 5vw, 3rem);
+ border: 1px solid rgb(255 255 255 / 82%);
+ border-radius: 2.5rem;
+ background: rgb(255 255 255 / 70%);
+ box-shadow: var(--clay-card-shadow);
+ backdrop-filter: blur(1.25rem);
+}
+
+.standalone-form {
+ display: grid;
+ gap: 1.25rem;
+ margin-top: 2rem;
+}
+
+.empty-panel,
+.series-admin-list > li {
+ padding: 1.5rem;
+ border-radius: 1.75rem;
+ background: rgb(255 255 255 / 70%);
+ box-shadow: var(--clay-card-shadow);
+}
+
+.series-admin-list {
+ display: grid;
+ gap: 1.25rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.series-admin-list > li,
+.series-admin-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1.25rem;
+}
+
+.series-admin-list h2,
+.empty-panel h2 {
+ margin: 0;
+}
+
+.series-admin-actions a {
+ color: var(--violet);
+ font-weight: 800;
+}
+
+.destructive-submit {
+ background: linear-gradient(145deg, #f87171, #be123c);
+}
+
:focus-visible {
outline: 0.25rem solid rgb(124 58 237 / 30%);
outline-offset: 0.2rem;
diff --git a/static/foil/card_preview.js b/static/foil/card_preview.js
index 9982ab8..36bbaf6 100644
--- a/static/foil/card_preview.js
+++ b/static/foil/card_preview.js
@@ -124,66 +124,6 @@ function asymptoticBound(value)
return Math.atan(value) / Math.PI;
}
-/** Find the inclusive nontransparent bounds in bottom-origin RGBA pixels. */
-function findAlphaBounds(pixels, width, height)
-{
- let min_x = width;
- let min_y = height;
- let max_x = -1;
- let max_y = -1;
- for(let y = 0; y < height; ++y)
- {
- for(let x = 0; x < width; ++x)
- {
- if(pixels[(y * width + x) * 4 + 3] != 0)
- {
- min_x = Math.min(min_x, x);
- min_y = Math.min(min_y, y);
- max_x = Math.max(max_x, x);
- max_y = Math.max(max_y, y);
- }
- }
- }
- return max_x < 0 ? null : {min_x, min_y, max_x, max_y};
-}
-
-/** Copy bounded bottom-origin WebGL pixels into top-origin ImageData. */
-function cropWebGLPixels(pixels, source_width, bounds)
-{
- const width = bounds.max_x - bounds.min_x + 1;
- const height = bounds.max_y - bounds.min_y + 1;
- const image = new ImageData(width, height);
- for(let output_y = 0; output_y < height; ++output_y)
- {
- const source_y = bounds.max_y - output_y;
- for(let output_x = 0; output_x < width; ++output_x)
- {
- const source_x = bounds.min_x + output_x;
- const source_offset =
- (source_y * source_width + source_x) * 4;
- const output_offset = (output_y * width + output_x) * 4;
- image.data.set(
- pixels.subarray(source_offset, source_offset + 4),
- output_offset);
- }
- }
- return image;
-}
-
-/** Scale dimensions proportionally so their long side is exact. */
-function scaleDimensions(width, height, long_side)
-{
- if(!Number.isInteger(long_side) || long_side < 1)
- {
- throw(new Error("The thumbnail size is invalid."));
- }
- const scale = long_side / Math.max(width, height);
- return {
- width: Math.max(1, Math.round(width * scale)),
- height: Math.max(1, Math.round(height * scale)),
- };
-}
-
/** Resolve one canvas PNG export or reject a failed encoding. */
function canvasPng(canvas)
{
@@ -371,22 +311,24 @@ function main()
gl.RGBA,
gl.UNSIGNED_BYTE,
pixels);
- const bounds = findAlphaBounds(
+ const bounds = window.cardPreviewMath.findAlphaBounds(
pixels, canvas.width, canvas.height);
if(bounds == null)
{
throw(new Error(
"The card preview is fully transparent."));
}
- const cropped = cropWebGLPixels(
+ const cropped = window.cardPreviewMath.cropWebGLPixels(
pixels, canvas.width, bounds);
const source_canvas = document.createElement("canvas");
source_canvas.width = cropped.width;
source_canvas.height = cropped.height;
+ const cropped_image = new ImageData(
+ cropped.data, cropped.width, cropped.height);
source_canvas.getContext("2d").putImageData(
- cropped, 0, 0);
+ cropped_image, 0, 0);
- const scaled = scaleDimensions(
+ const scaled = window.cardPreviewMath.scaleDimensions(
cropped.width, cropped.height, long_side);
const output_canvas = document.createElement("canvas");
output_canvas.width = scaled.width;
diff --git a/static/foil/card_preview_math.js b/static/foil/card_preview_math.js
new file mode 100644
index 0000000..fd02540
--- /dev/null
+++ b/static/foil/card_preview_math.js
@@ -0,0 +1,72 @@
+(function exportCardPreviewMath(root)
+{
+ /** Find inclusive nontransparent bounds in bottom-origin RGBA pixels. */
+ function findAlphaBounds(pixels, width, height)
+ {
+ let min_x = width;
+ let min_y = height;
+ let max_x = -1;
+ let max_y = -1;
+ for(let y = 0; y < height; ++y)
+ {
+ for(let x = 0; x < width; ++x)
+ {
+ if(pixels[(y * width + x) * 4 + 3] != 0)
+ {
+ min_x = Math.min(min_x, x);
+ min_y = Math.min(min_y, y);
+ max_x = Math.max(max_x, x);
+ max_y = Math.max(max_y, y);
+ }
+ }
+ }
+ return max_x < 0 ? null : {min_x, min_y, max_x, max_y};
+ }
+
+ /** Crop and flip bottom-origin WebGL pixels into top-origin RGBA data. */
+ function cropWebGLPixels(pixels, source_width, bounds)
+ {
+ const width = bounds.max_x - bounds.min_x + 1;
+ const height = bounds.max_y - bounds.min_y + 1;
+ const data = new Uint8ClampedArray(width * height * 4);
+ for(let output_y = 0; output_y < height; ++output_y)
+ {
+ const source_y = bounds.max_y - output_y;
+ for(let output_x = 0; output_x < width; ++output_x)
+ {
+ const source_x = bounds.min_x + output_x;
+ const source_offset =
+ (source_y * source_width + source_x) * 4;
+ const output_offset = (output_y * width + output_x) * 4;
+ data.set(
+ pixels.subarray(source_offset, source_offset + 4),
+ output_offset);
+ }
+ }
+ return {width, height, data};
+ }
+
+ /** Scale dimensions proportionally so their long side is exact. */
+ function scaleDimensions(width, height, long_side)
+ {
+ if(!Number.isInteger(long_side) || long_side < 1)
+ {
+ throw(new Error("The thumbnail size is invalid."));
+ }
+ const scale = long_side / Math.max(width, height);
+ return {
+ width: Math.max(1, Math.round(width * scale)),
+ height: Math.max(1, Math.round(height * scale)),
+ };
+ }
+
+ const api = {findAlphaBounds, cropWebGLPixels, scaleDimensions};
+ if(typeof module != "undefined" && module.exports != null)
+ {
+ module.exports = api;
+ }
+ else
+ {
+ root.cardPreviewMath = api;
+ }
+})(globalThis);
diff --git a/templates/card_delete.html b/templates/card_delete.html
new file mode 100644
index 0000000..582929f
--- /dev/null
+++ b/templates/card_delete.html
@@ -0,0 +1,15 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell form-page" aria-labelledby="DeleteCardHeading">
+ <a class="back-link" href="{{ back_url }}">← Card details</a>
+ <p class="eyebrow">Card administration</p>
+ <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 }}">
+ <button class="form-submit destructive-submit" type="submit">
+ Delete card
+ </button>
+ </form>
+</section>
+{% endblock %}
diff --git a/templates/card_form.html b/templates/card_form.html
index f07ffcd..3618fee 100644
--- a/templates/card_form.html
+++ b/templates/card_form.html
@@ -30,6 +30,14 @@
<select id="CardGame" name="game"
{% if mode == "edit" %}disabled{% endif %}>
<option value="">Loose card</option>
+ {% for item in games %}
+ <option value="{{ item.short_name }}"
+ {% if item.short_name == selected_game %}
+ selected
+ {% endif %}>
+ {{ item.display_name }}
+ </option>
+ {% endfor %}
</select>
</label>
<label class="form-field" for="CardName">
@@ -45,6 +53,46 @@
</label>
</section>
+ {% for game in games %}
+ <fieldset class="form-section game-fields"
+ data-game="{{ game.short_name }}"
+ {% if game.short_name != selected_game %}hidden{% endif %}>
+ <legend>{{ game.display_name }} details</legend>
+ {% for field in game.fields %}
+ <label class="form-field">
+ <span>{{ field.label }}</span>
+ <input name="game.{{ field.name }}"
+ type="{{ field.input_type }}"
+ {% if length(field.minimum) > 0 %}
+ min="{{ field.minimum }}"
+ {% endif %}
+ {% if field.required %}required{% endif %}
+ value="{{ field.value }}"
+ {% if game.short_name != selected_game %}
+ disabled
+ {% endif %}>
+ </label>
+ {% endfor %}
+ </fieldset>
+ {% endfor %}
+
+ {% if length(series) > 0 %}
+ <fieldset class="form-section">
+ <legend>Series</legend>
+ {% for item in series %}
+ <label class="form-field series-choice"
+ data-game="{{ item.game }}"
+ {% if item.game != selected_game %}hidden{% endif %}>
+ <input name="series_id" type="checkbox"
+ value="{{ item.id }}"
+ {% if item.selected %}checked{% endif %}
+ {% if item.game != selected_game %}disabled{% endif %}>
+ <span>{{ item.name }}</span>
+ </label>
+ {% endfor %}
+ </fieldset>
+ {% endif %}
+
<fieldset class="form-section image-source-section">
<legend>Card images</legend>
{% if mode == "edit" %}
@@ -174,6 +222,8 @@
<script src="{{ url_for("static", "foil/gl-matrix-min.js") }}"></script>
<script src="{{ url_for("static", "foil/libwebgl.js") }}"></script>
<script src="{{ url_for("static", "foil/obj.js") }}"></script>
+<script src="{{ url_for("static", "foil/card_preview_math.js") }}"></script>
<script src="{{ preview_script_url }}"></script>
+<script src="{{ url_for("static", "card_form_logic.js") }}"></script>
<script src="{{ url_for("static", "card_form.js") }}"></script>
{% endblock %}
diff --git a/templates/card_view.html b/templates/card_view.html
index 64904a5..32889d2 100644
--- a/templates/card_view.html
+++ b/templates/card_view.html
@@ -17,6 +17,7 @@
<nav class="card-actions" aria-label="Card actions">
<a class="back-link" href="{{ back_url }}">← All cards</a>
<a class="back-link" href="{{ edit_url }}">Edit card</a>
+ <a class="back-link" href="{{ delete_url }}">Delete card</a>
</nav>
<p class="card-view-id">{{ display_id }}</p>
<h1 id="CardHeading">{{ name }}</h1>
@@ -46,6 +47,12 @@
<dt>Finish</dt>
<dd>{% if has_foil %}Foil{% else %}Standard{% endif %}</dd>
</div>
+ {% for field in game_fields %}
+ <div>
+ <dt>{{ field.label }}</dt>
+ <dd>{{ field.value }}</dd>
+ </div>
+ {% endfor %}
</dl>
<section class="card-info-section" aria-labelledby="SeriesHeading">
@@ -64,7 +71,7 @@
{% if has_short_description %}
<section class="card-info-section" aria-labelledby="SummaryHeading">
<h2 id="SummaryHeading">Summary</h2>
- <p class="description-copy">{{ short_description }}</p>
+ <div class="description-copy">{{ short_description }}</div>
</section>
{% endif %}
@@ -72,7 +79,7 @@
<section class="card-info-section"
aria-labelledby="DescriptionHeading">
<h2 id="DescriptionHeading">Description</h2>
- <p class="description-copy">{{ long_description }}</p>
+ <div class="description-copy">{{ long_description }}</div>
</section>
{% endif %}
</aside>
@@ -99,5 +106,6 @@
<script src="{{ url_for("static", "foil/gl-matrix-min.js") }}"></script>
<script src="{{ url_for("static", "foil/libwebgl.js") }}"></script>
<script src="{{ url_for("static", "foil/obj.js") }}"></script>
+<script src="{{ url_for("static", "foil/card_preview_math.js") }}"></script>
<script src="{{ preview_script_url }}"></script>
{% endblock %}
diff --git a/templates/series_delete.html b/templates/series_delete.html
new file mode 100644
index 0000000..f4b025b
--- /dev/null
+++ b/templates/series_delete.html
@@ -0,0 +1,15 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell form-page" aria-labelledby="DeleteSeriesHeading">
+ <a class="back-link" href="{{ back_url }}">← All series</a>
+ <p class="eyebrow">Series administration</p>
+ <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 }}">
+ <button class="form-submit destructive-submit" type="submit">
+ Delete series
+ </button>
+ </form>
+</section>
+{% endblock %}
diff --git a/templates/series_form.html b/templates/series_form.html
new file mode 100644
index 0000000..2fc31de
--- /dev/null
+++ b/templates/series_form.html
@@ -0,0 +1,38 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell form-page" aria-labelledby="SeriesFormHeading">
+ <a class="back-link" href="{{ back_url }}">← All series</a>
+ <p class="eyebrow">Series administration</p>
+ <h1 id="SeriesFormHeading">{{ heading }}</h1>
+
+ <form class="standalone-form" method="post" action="{{ action_url }}">
+ <label class="form-field" for="SeriesGame">
+ <span>Game</span>
+ {% if mode == "create" %}
+ <select id="SeriesGame" name="game" required>
+ <option value="">Select a game</option>
+ {% for item in games %}
+ <option value="{{ item.short_name }}">
+ {{ item.display_name }}
+ </option>
+ {% endfor %}
+ </select>
+ {% else %}
+ <input id="SeriesGame" type="text" value="{{ game }}" disabled>
+ {% endif %}
+ </label>
+ <label class="form-field" for="SeriesName">
+ <span>Name</span>
+ <input id="SeriesName" name="name" type="text"
+ maxlength="200" value="{{ name }}" required>
+ </label>
+ <label class="form-field" for="SeriesDescription">
+ <span>Description</span>
+ <textarea id="SeriesDescription" name="description"
+ rows="8">{{ description }}</textarea>
+ </label>
+ <button class="form-submit" type="submit">{{ submit_label }}</button>
+ </form>
+</section>
+{% endblock %}
diff --git a/templates/series_index.html b/templates/series_index.html
new file mode 100644
index 0000000..6b94380
--- /dev/null
+++ b/templates/series_index.html
@@ -0,0 +1,46 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell series-page" aria-labelledby="SeriesHeading">
+ <header class="page-heading">
+ <div>
+ <p class="eyebrow">Administration</p>
+ <h1 id="SeriesHeading">Series</h1>
+ </div>
+ {% if can_create %}
+ <a class="nav-link nav-link-primary" href="{{ create_url }}">
+ Create series
+ </a>
+ {% endif %}
+ </header>
+
+ {% if length(series) == 0 %}
+ <div class="empty-panel">
+ <h2>No series yet</h2>
+ {% if can_create %}
+ <p>Create a series to organize cards within a game.</p>
+ {% else %}
+ <p>A compiled game must be registered before creating series.</p>
+ {% endif %}
+ </div>
+ {% else %}
+ <ul class="series-admin-list">
+ {% for item in series %}
+ <li>
+ <div>
+ <p class="eyebrow">{{ item.game }}</p>
+ <h2>{{ item.name }}</h2>
+ {% if item.has_description %}
+ <div class="description-copy">{{ item.description }}</div>
+ {% endif %}
+ </div>
+ <div class="series-admin-actions">
+ <a href="{{ item.edit_url }}">Edit</a>
+ <a href="{{ item.delete_url }}">Delete</a>
+ </div>
+ </li>
+ {% endfor %}
+ </ul>
+ {% endif %}
+</section>
+{% endblock %}
diff --git a/tests/app_integration_test.cpp b/tests/app_integration_test.cpp
new file mode 100644
index 0000000..86bd9b6
--- /dev/null
+++ b/tests/app_integration_test.cpp
@@ -0,0 +1,247 @@
+#include "app.h"
+
+#include <chrono>
+#include <filesystem>
+#include <fstream>
+#include <iterator>
+#include <memory>
+#include <string>
+
+#include <Magick++.h>
+#include <gtest/gtest.h>
+#include <httplib.h>
+
+#include "game_registry.h"
+#include "non_secret_random.h"
+#include "startup.h"
+#include "test_game.h"
+
+namespace
+{
+
+std::string readFile(const std::filesystem::path& path)
+{
+ std::ifstream input(path, std::ios::binary);
+ return std::string(
+ std::istreambuf_iterator<char>(input),
+ std::istreambuf_iterator<char>());
+}
+
+class TemporaryServerRoot
+{
+public:
+ /// Allocate private database and card-storage paths.
+ TemporaryServerRoot()
+ : path_(
+ std::filesystem::path(testing::TempDir()) /
+ ("card_server_" + std::to_string(
+ std::chrono::steady_clock::now()
+ .time_since_epoch().count())))
+ {
+ std::filesystem::create_directories(path_ / "cards/published");
+ }
+
+ /// Remove all temporary server state.
+ ~TemporaryServerRoot()
+ {
+ std::error_code error;
+ std::filesystem::remove_all(path_, error);
+ }
+
+ /// Return the temporary root path.
+ const std::filesystem::path& path() const
+ {
+ return path_;
+ }
+
+private:
+ std::filesystem::path path_;
+};
+
+} // namespace
+
+/// Verify real HTTP routing, base paths, and independent static mounts.
+TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
+{
+ Magick::InitializeMagick(nullptr);
+ Magick::ResourceLimits::listLength(2);
+ Magick::ResourceLimits::width(2048);
+ Magick::ResourceLimits::height(2048);
+ TemporaryServerRoot temporary;
+ const int port = 39000 + static_cast<int>(
+ std::chrono::steady_clock::now().time_since_epoch().count() % 1000);
+ auto base_url = mw::URL::fromStr(
+ "http://127.0.0.1:" + std::to_string(port) + "/collection/");
+ ASSERT_TRUE(base_url);
+ Config config = {
+ std::move(*base_url),
+ mw::IPSocketInfo{"127.0.0.1", port},
+ std::filesystem::path(CARD_COLLECTION_SOURCE_DIR) / "static",
+ temporary.path() / "cards.sqlite3",
+ temporary.path() / "cards",
+ 75,
+ 256,
+ };
+ auto games = std::make_unique<GameRegistry>();
+ ASSERT_TRUE(games->add(std::make_unique<TestGame>()));
+ auto data_source = prepareDataSource(config.database_path, *games);
+ ASSERT_TRUE(data_source);
+ const GameDefinition* game = games->find("test");
+ ASSERT_NE(game, nullptr);
+ auto metadata = game->validateMetadata({{"hp", "20"}, {"attack", "5"}});
+ ASSERT_TRUE(metadata);
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ auto series_id = (*transaction)->insertSeries(
+ {0, "test", "Core <Set>", "A **series**."});
+ ASSERT_TRUE(series_id);
+ auto number = (*transaction)->allocateGameNumber("test");
+ ASSERT_TRUE(number);
+ auto card_id = (*transaction)->insertCard(
+ {
+ 0,
+ {"test", *number},
+ "Card <One>",
+ std::nullopt,
+ std::nullopt,
+ 0,
+ "avif",
+ std::nullopt,
+ "avif",
+ 1,
+ },
+ game,
+ metadata->get(),
+ {*series_id});
+ ASSERT_TRUE(card_id);
+ ASSERT_TRUE((*transaction)->commit());
+ const std::filesystem::path card_directory =
+ config.card_storage_root / "published/test-1";
+ std::filesystem::create_directories(card_directory);
+ std::ofstream(card_directory / "front-art.avif") << "front";
+ std::ofstream(card_directory / "thumb.avif") << "thumbnail";
+ const std::filesystem::path upload_path =
+ temporary.path() / "upload.png";
+ Magick::Image upload(
+ Magick::Geometry(350, 490), Magick::Color("navy"));
+ upload.write("PNG:" + upload_path.string());
+ const std::string upload_bytes = readFile(upload_path);
+ App app(
+ config,
+ std::move(*data_source),
+ std::move(games),
+ std::make_unique<NonSecretRandom>(1));
+ ASSERT_TRUE(app.start());
+
+ httplib::Client client("127.0.0.1", port);
+ auto index = client.Get("/collection/");
+ 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");
+ ASSERT_NE(card, nullptr);
+ EXPECT_EQ(card->status, 200);
+ EXPECT_NE(card->body.find("Card <One>"), std::string::npos);
+ auto edit = client.Get("/collection/cards/test-1/edit");
+ ASSERT_NE(edit, nullptr);
+ EXPECT_EQ(edit->status, 200);
+ 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");
+ ASSERT_NE(series, nullptr);
+ EXPECT_EQ(series->status, 200);
+ 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);
+ auto card_asset = client.Get(
+ "/collection/static-cards/test-1/front-art.avif");
+ ASSERT_NE(card_asset, nullptr);
+ EXPECT_EQ(card_asset->status, 200);
+ EXPECT_EQ(card_asset->body, "front");
+ auto traversal = client.Get(
+ "/collection/static-cards/../cards.sqlite3");
+ ASSERT_NE(traversal, nullptr);
+ EXPECT_EQ(traversal->status, 404);
+
+ const httplib::UploadFormDataItems create_fields = {
+ {"game", "", "", ""},
+ {"name", "Uploaded card", "", ""},
+ {"rarity", "3", "", ""},
+ {"source_mode", "files", "", ""},
+ {"front", upload_bytes, "card.png", "image/png"},
+ };
+ auto created = client.Post("/collection/cards", create_fields);
+ ASSERT_NE(created, nullptr);
+ ASSERT_EQ(created->status, 303) << created->body;
+ const std::string location = created->get_header_value("Location");
+ const std::size_t path_begin = location.find("/collection/cards/");
+ ASSERT_NE(path_begin, std::string::npos);
+ const std::string created_path = location.substr(path_begin);
+ const std::string public_id = created_path.substr(
+ std::string("/collection/cards/").size());
+ EXPECT_TRUE(std::filesystem::is_directory(
+ config.card_storage_root / "published" / public_id));
+
+ const httplib::UploadFormDataItems edit_fields = {
+ {"name", "Edited upload", "", ""},
+ {"rarity", "4", "", ""},
+ {"revision", "1", "", ""},
+ {"source_mode", "files", "", ""},
+ {"front_action", "keep", "", ""},
+ {"foil_action", "keep", "", ""},
+ };
+ auto edited = client.Post(created_path, edit_fields);
+ ASSERT_NE(edited, nullptr);
+ EXPECT_EQ(edited->status, 303) << edited->body;
+ auto edited_page = client.Get(created_path);
+ 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");
+ 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 = {
+ {"game", "test"},
+ {"name", "Uploaded series"},
+ {"description", "Description"},
+ };
+ auto created_series = client.Post(
+ "/collection/series", create_series_fields);
+ ASSERT_NE(created_series, nullptr);
+ EXPECT_EQ(created_series->status, 303) << created_series->body;
+ httplib::Params edit_series_fields = {
+ {"name", "Edited series"},
+ {"description", "Changed"},
+ };
+ auto edited_series = client.Post(
+ "/collection/series/2", 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");
+ ASSERT_NE(deleted_series, nullptr);
+ EXPECT_EQ(deleted_series->status, 303) << deleted_series->body;
+
+ app.stop();
+ app.wait();
+}
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index 034f9e7..6f6be51 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -14,6 +14,7 @@
#include "data_fake.h"
#include "public_id.h"
+#include "test_game.h"
namespace
{
@@ -42,6 +43,26 @@ std::unique_ptr<DataSourceFake> emptyDataSource()
return std::make_unique<DataSourceFake>();
}
+std::unique_ptr<NonSecretRandom> deterministicRandom()
+{
+ return std::make_unique<NonSecretRandom>(1);
+}
+
+std::unique_ptr<GameRegistry> emptyGames()
+{
+ return std::make_unique<GameRegistry>();
+}
+
+std::unique_ptr<GameRegistry> testGames()
+{
+ auto games = std::make_unique<GameRegistry>();
+ if(!games->add(std::make_unique<TestGame>()))
+ {
+ throw std::runtime_error("Failed to construct test game registry");
+ }
+ return games;
+}
+
Card makeCard(std::int64_t id, std::uint64_t number, std::string name)
{
return {
@@ -65,7 +86,9 @@ TEST(AppTest, BuildsNamedUrls)
{
const App app(
makeConfig("https://example.test/collection/"),
- emptyDataSource());
+ emptyDataSource(),
+ emptyGames(),
+ deterministicRandom());
EXPECT_EQ(
app.urlFor("card-index"),
@@ -86,7 +109,9 @@ TEST(AppTest, BuildsStaticAndQueryUrls)
{
const App app(
makeConfig("https://example.test/collection/"),
- emptyDataSource());
+ emptyDataSource(),
+ emptyGames(),
+ deterministicRandom());
EXPECT_EQ(
app.urlFor(
@@ -102,7 +127,9 @@ TEST(AppTest, RejectsInvalidRoutes)
{
const App app(
makeConfig("https://example.test/"),
- emptyDataSource());
+ emptyDataSource(),
+ emptyGames(),
+ deterministicRandom());
EXPECT_THROW(app.urlFor("missing"), std::invalid_argument);
EXPECT_THROW(app.urlFor("card"), std::invalid_argument);
@@ -120,7 +147,9 @@ TEST(AppTest, RendersCardIndex)
});
App app(
makeConfig("https://example.test/collection/"),
- std::move(data_source));
+ std::move(data_source),
+ emptyGames(),
+ deterministicRandom());
App::Request request;
App::Response response;
@@ -147,7 +176,9 @@ TEST(AppTest, RendersCardCreationForm)
{
App app(
makeConfig("https://example.test/collection/"),
- emptyDataSource());
+ emptyDataSource(),
+ emptyGames(),
+ deterministicRandom());
App::Request request;
App::Response response;
@@ -179,7 +210,9 @@ TEST(AppTest, RendersCardEditForm)
std::vector<Card>{card});
App app(
makeConfig("https://example.test/collection/"),
- std::move(data_source));
+ std::move(data_source),
+ emptyGames(),
+ deterministicRandom());
auto public_id = formatPublicId(card.identity);
ASSERT_TRUE(public_id);
App::Request request;
@@ -206,29 +239,32 @@ TEST(AppTest, RendersCardEditForm)
TEST(AppTest, RendersCardView)
{
Card card = makeCard(2, 2, "<script>Moon card</script>");
+ card.identity.game_short_name = "test";
card.short_description = "A quiet <night>.";
card.long_description = "First line\nSecond line";
card.rarity = 4;
auto data_source = std::make_unique<DataSourceFake>(
std::vector<Card>{card},
std::vector<Series>{
- {7, "pkm", "Night Signals", "A series description"},
+ {7, "test", "Night Signals", "A series description"},
},
std::unordered_map<std::int64_t, std::vector<std::int64_t>>{
{2, {7}},
});
App app(
makeConfig("https://example.test/collection/"),
- std::move(data_source));
+ std::move(data_source),
+ testGames(),
+ deterministicRandom());
App::Request request;
- request.path_params.emplace("id", "pkm-2");
+ request.path_params.emplace("id", "test-2");
App::Response response;
app.handleCardView(request, response);
EXPECT_EQ(response.status, 200);
EXPECT_NE(response.body.find("id=\"GLCanvas\""), std::string::npos);
- EXPECT_NE(response.body.find("PKM-2"), std::string::npos);
+ EXPECT_NE(response.body.find("TEST-2"), std::string::npos);
EXPECT_NE(response.body.find("Night Signals"), std::string::npos);
EXPECT_NE(response.body.find("Some card assets are missing"),
std::string::npos);
@@ -244,7 +280,9 @@ TEST(AppTest, RejectsUnknownCardView)
{
App app(
makeConfig("https://example.test/"),
- emptyDataSource());
+ emptyDataSource(),
+ emptyGames(),
+ deterministicRandom());
App::Request request;
request.path_params.emplace("id", "PKM-2");
App::Response response;
diff --git a/tests/card_form_logic_test.js b/tests/card_form_logic_test.js
new file mode 100644
index 0000000..8fd54ca
--- /dev/null
+++ b/tests/card_form_logic_test.js
@@ -0,0 +1,68 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const logic = require("../static/card_form_logic.js");
+
+test("remote images require absolute HTTP or HTTPS URLs", function()
+{
+ assert.equal(logic.validRemoteImageUrl("https://example.test/card.png"),
+ true);
+ assert.equal(logic.validRemoteImageUrl("http://example.test/card.png"),
+ true);
+ assert.equal(logic.validRemoteImageUrl("javascript:alert(1)"), false);
+ assert.equal(logic.validRemoteImageUrl("/card.png"), false);
+});
+
+test("thumbnail decisions use actions and resulting finish", function()
+{
+ assert.equal(logic.foilThumbnailRequired({
+ mode: "edit",
+ front_action: "keep",
+ foil_action: "keep",
+ has_current_foil: true,
+ has_replacement_foil: false,
+ }), false);
+ assert.equal(logic.foilThumbnailRequired({
+ mode: "edit",
+ front_action: "replace",
+ foil_action: "keep",
+ has_current_foil: true,
+ has_replacement_foil: false,
+ }), true);
+ assert.equal(logic.foilThumbnailRequired({
+ mode: "edit",
+ front_action: "keep",
+ foil_action: "remove",
+ has_current_foil: true,
+ has_replacement_foil: false,
+ }), false);
+});
+
+test("source input state follows mode and replacements", function()
+{
+ assert.deepEqual(logic.sourceInputState({
+ mode: "create",
+ source_mode: "files",
+ front_action: "replace",
+ foil_action: "replace",
+ }), {
+ front_file_enabled: true,
+ foil_file_enabled: true,
+ front_url_enabled: false,
+ foil_url_enabled: false,
+ front_required: true,
+ foil_required: false,
+ });
+ assert.deepEqual(logic.sourceInputState({
+ mode: "edit",
+ source_mode: "urls",
+ front_action: "keep",
+ foil_action: "replace",
+ }), {
+ front_file_enabled: false,
+ foil_file_enabled: false,
+ front_url_enabled: false,
+ foil_url_enabled: true,
+ front_required: false,
+ foil_required: true,
+ });
+});
diff --git a/tests/card_preview_math_test.js b/tests/card_preview_math_test.js
new file mode 100644
index 0000000..ebf33fb
--- /dev/null
+++ b/tests/card_preview_math_test.js
@@ -0,0 +1,49 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+
+const preview_math = require("../static/foil/card_preview_math.js");
+
+test("scaleDimensions preserves aspect ratio", function()
+{
+ assert.deepEqual(
+ preview_math.scaleDimensions(5, 7, 256),
+ {width: 183, height: 256});
+ assert.deepEqual(
+ preview_math.scaleDimensions(8, 4, 100),
+ {width: 100, height: 50});
+});
+
+test("findAlphaBounds finds nontransparent pixels", function()
+{
+ const pixels = new Uint8Array(3 * 2 * 4);
+ pixels[(0 * 3 + 1) * 4 + 3] = 255;
+ pixels[(1 * 3 + 2) * 4 + 3] = 1;
+
+ assert.deepEqual(
+ preview_math.findAlphaBounds(pixels, 3, 2),
+ {min_x: 1, min_y: 0, max_x: 2, max_y: 1});
+ assert.equal(
+ preview_math.findAlphaBounds(new Uint8Array(8), 2, 1),
+ null);
+});
+
+test("cropWebGLPixels crops and flips rows", function()
+{
+ const pixels = new Uint8Array([
+ 1, 0, 0, 255, 2, 0, 0, 255,
+ 3, 0, 0, 255, 4, 0, 0, 255,
+ ]);
+ const cropped = preview_math.cropWebGLPixels(
+ pixels,
+ 2,
+ {min_x: 0, min_y: 0, max_x: 1, max_y: 1});
+
+ assert.equal(cropped.width, 2);
+ assert.equal(cropped.height, 2);
+ assert.deepEqual(
+ Array.from(cropped.data),
+ [
+ 3, 0, 0, 255, 4, 0, 0, 255,
+ 1, 0, 0, 255, 2, 0, 0, 255,
+ ]);
+});
diff --git a/tests/card_service_test.cpp b/tests/card_service_test.cpp
index 1b1c943..bb65c49 100644
--- a/tests/card_service_test.cpp
+++ b/tests/card_service_test.cpp
@@ -3,6 +3,7 @@
#include "game_registry.h"
#include "multipart_reader.h"
#include "startup.h"
+#include "test_game.h"
#include <chrono>
#include <filesystem>
@@ -100,8 +101,10 @@ TEST(CardServiceTest, CreatesLooseCard)
const std::filesystem::path front = staging / "upload_front";
writePng(front);
+ NonSecretRandom random(1);
CardService service(
**data_source,
+ random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
auto public_id = service.createLooseCard({
@@ -154,8 +157,10 @@ TEST(CardServiceTest, RejectsInvalidImage)
std::ofstream output(front, std::ios::binary);
output << "not an image";
}
+ NonSecretRandom random(1);
CardService service(
**data_source,
+ random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
auto public_id = service.createLooseCard({
@@ -195,8 +200,10 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
writeJpeg(foil);
writePng(thumbnail);
+ NonSecretRandom random(1);
CardService service(
**data_source,
+ random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
auto public_id = service.createLooseCard({
@@ -220,6 +227,102 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
EXPECT_TRUE(std::filesystem::is_regular_file(published / "foil.jpg"));
}
+/// Verify compiled-game creation allocates a sequence and memberships.
+TEST(CardServiceTest, CreatesCompiledGameCard)
+{
+ initializeImageMagick();
+ TemporaryCardRoot temporary;
+ const std::filesystem::path database = temporary.path() / "cards.sqlite3";
+ GameRegistry games;
+ ASSERT_TRUE(games.add(std::make_unique<TestGame>()));
+ const GameDefinition* game = games.find("test");
+ ASSERT_NE(game, nullptr);
+ auto data_source = prepareDataSource(database, games);
+ ASSERT_TRUE(data_source);
+
+ auto series_transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(series_transaction);
+ auto series_id = (*series_transaction)->insertSeries(
+ {0, "test", "Core", "Core cards"});
+ ASSERT_TRUE(series_id);
+ ASSERT_TRUE((*series_transaction)->commit());
+ auto metadata = game->validateMetadata({{"hp", "50"}, {"attack", "10"}});
+ ASSERT_TRUE(metadata);
+
+ const std::filesystem::path staging =
+ temporary.path() / ".staging/upload";
+ const std::filesystem::path front = staging / "upload_front";
+ writePng(front);
+ NonSecretRandom random(1);
+ CardService service(
+ **data_source,
+ random,
+ ImageProcessor(75, 256),
+ AssetStore(temporary.path()));
+ auto public_id = service.createGameCard(
+ {
+ "Game card",
+ std::nullopt,
+ std::nullopt,
+ 2,
+ staging,
+ front,
+ std::nullopt,
+ std::nullopt,
+ },
+ *game,
+ **metadata,
+ {*series_id});
+
+ ASSERT_TRUE(public_id) << public_id.error().msg();
+ EXPECT_EQ(*public_id, "test-1");
+ auto stored = (*data_source)->getCard({"test", 1});
+ ASSERT_TRUE(stored);
+ ASSERT_TRUE(*stored);
+ auto memberships = (*data_source)->getCardSeries((**stored).id);
+ ASSERT_TRUE(memberships);
+ EXPECT_EQ(*memberships, std::vector<std::int64_t>{*series_id});
+ auto display = (*data_source)->getGameDisplayFields(
+ *game, (**stored).id);
+ ASSERT_TRUE(display);
+ EXPECT_EQ(display->front().value, "50");
+
+ metadata = game->validateMetadata({{"hp", "80"}, {"attack", "40"}});
+ ASSERT_TRUE(metadata);
+ const std::filesystem::path edit_staging =
+ temporary.path() / ".staging/edit";
+ std::filesystem::create_directory(edit_staging);
+ auto updated = service.updateGameCard(
+ {
+ **stored,
+ 1,
+ "Updated game card",
+ std::nullopt,
+ std::nullopt,
+ 3,
+ edit_staging,
+ FrontAssetAction::KEEP,
+ FoilAssetAction::KEEP,
+ std::nullopt,
+ std::nullopt,
+ std::nullopt,
+ },
+ *game,
+ **metadata,
+ {});
+ ASSERT_TRUE(updated) << updated.error().msg();
+ stored = (*data_source)->getCard({"test", 1});
+ ASSERT_TRUE(stored);
+ ASSERT_TRUE(*stored);
+ EXPECT_EQ((**stored).name, "Updated game card");
+ display = (*data_source)->getGameDisplayFields(*game, (**stored).id);
+ ASSERT_TRUE(display);
+ EXPECT_EQ(display->front().value, "80");
+ memberships = (*data_source)->getCardSeries((**stored).id);
+ ASSERT_TRUE(memberships);
+ EXPECT_TRUE(memberships->empty());
+}
+
/// Verify metadata and artwork edits increment revisions and replace assets.
TEST(CardServiceTest, UpdatesLooseCard)
{
@@ -235,8 +338,10 @@ TEST(CardServiceTest, UpdatesLooseCard)
const std::filesystem::path original_front =
create_staging / "upload_front";
writePng(original_front);
+ NonSecretRandom random(1);
CardService service(
**data_source,
+ random,
ImageProcessor(75, 256),
AssetStore(temporary.path()));
auto public_id = service.createLooseCard({
@@ -339,6 +444,73 @@ TEST(CardServiceTest, UpdatesLooseCard)
EXPECT_FALSE(
std::filesystem::exists(published / "front-art.avif"));
EXPECT_TRUE(std::filesystem::is_regular_file(published / "thumb.avif"));
+
+ auto deleted = service.deleteCard(cards->front());
+ ASSERT_TRUE(deleted) << deleted.error().msg();
+ cards = (*data_source)->getCards();
+ ASSERT_TRUE(cards);
+ EXPECT_TRUE(cards->empty());
+ EXPECT_FALSE(std::filesystem::exists(published));
+}
+
+/// Verify startup reconciliation restores edits/deletes and removes orphans.
+TEST(AssetStoreTest, ReconcilesInterruptedTransitions)
+{
+ initializeImageMagick();
+ TemporaryCardRoot temporary;
+ const std::filesystem::path database = temporary.path() / "cards.sqlite3";
+ GameRegistry games;
+ auto data_source = prepareDataSource(database, games);
+ ASSERT_TRUE(data_source);
+ const std::filesystem::path staging =
+ temporary.path() / ".staging/upload";
+ const std::filesystem::path front = staging / "upload_front";
+ writePng(front);
+ NonSecretRandom random(1);
+ AssetStore assets(temporary.path());
+ CardService service(
+ **data_source,
+ random,
+ ImageProcessor(75, 256),
+ assets);
+ auto public_id = service.createLooseCard({
+ "Recovery card",
+ std::nullopt,
+ std::nullopt,
+ 0,
+ staging,
+ front,
+ std::nullopt,
+ std::nullopt,
+ });
+ ASSERT_TRUE(public_id);
+ const std::filesystem::path published =
+ temporary.path() / "published" / *public_id;
+
+ const std::filesystem::path replacement =
+ temporary.path() / ".staging/replacement";
+ std::filesystem::create_directory(replacement);
+ writeJpeg(replacement / "front-art.jpg");
+ writeJpeg(replacement / "thumb.jpg");
+ auto replaced = assets.replace(replacement, *public_id, 1);
+ ASSERT_TRUE(replaced);
+ ASSERT_TRUE(assets.reconcile(**data_source));
+ EXPECT_TRUE(
+ std::filesystem::is_regular_file(published / "front-art.avif"));
+ EXPECT_FALSE(
+ std::filesystem::is_regular_file(published / "front-art.jpg"));
+
+ auto trashed = assets.trash(*public_id, 1, "abc123");
+ ASSERT_TRUE(trashed);
+ EXPECT_FALSE(std::filesystem::exists(published));
+ ASSERT_TRUE(assets.reconcile(**data_source));
+ EXPECT_TRUE(std::filesystem::is_directory(published));
+
+ const std::filesystem::path orphan =
+ temporary.path() / "published/1";
+ std::filesystem::create_directory(orphan);
+ ASSERT_TRUE(assets.reconcile(**data_source));
+ EXPECT_FALSE(std::filesystem::exists(orphan));
}
/// Verify multipart binaries use server paths and empty file controls vanish.
diff --git a/tests/config_test.cpp b/tests/config_test.cpp
new file mode 100644
index 0000000..2dec367
--- /dev/null
+++ b/tests/config_test.cpp
@@ -0,0 +1,145 @@
+#include "config.h"
+#include "non_secret_random.h"
+
+#include <chrono>
+#include <filesystem>
+#include <fstream>
+#include <string>
+#include <variant>
+
+#include <gtest/gtest.h>
+
+namespace
+{
+
+class TemporaryConfigRoot
+{
+public:
+ /// Allocate a temporary configuration directory with static assets.
+ TemporaryConfigRoot()
+ : path_(
+ std::filesystem::path(testing::TempDir()) /
+ ("card_config_" + std::to_string(
+ std::chrono::steady_clock::now()
+ .time_since_epoch().count())))
+ {
+ std::filesystem::create_directories(path_ / "static");
+ }
+
+ /// Remove the complete temporary configuration directory.
+ ~TemporaryConfigRoot()
+ {
+ std::error_code error;
+ std::filesystem::remove_all(path_, error);
+ }
+
+ /// Return the temporary configuration directory.
+ const std::filesystem::path& path() const
+ {
+ return path_;
+ }
+
+private:
+ std::filesystem::path path_;
+};
+
+void writeConfig(
+ const std::filesystem::path& path,
+ const std::string& listen_fields,
+ const std::string& extra = {})
+{
+ std::ofstream output(path);
+ output
+ << "base_url = \"https://example.test/collection\"\n"
+ << listen_fields
+ << "static_root = \"static\"\n"
+ << "database_path = \"var/cards.sqlite3\"\n"
+ << "card_storage_root = \"var/cards\"\n"
+ << "avif_quality = 75\n"
+ << "thumbnail_long_side = 256\n"
+ << extra;
+}
+
+} // namespace
+
+/// Verify TCP configuration resolves relative paths beside its TOML file.
+TEST(ConfigTest, LoadsTcpConfiguration)
+{
+ TemporaryConfigRoot temporary;
+ const std::filesystem::path config_path = temporary.path() / "app.toml";
+ writeConfig(
+ config_path,
+ "listen_address = \"127.0.0.1\"\nlisten_port = 8080\n");
+
+ auto config = loadConfig(config_path);
+
+ ASSERT_TRUE(config) << config.error().msg();
+ EXPECT_EQ(
+ config->base_url.str(),
+ "https://example.test/collection/");
+ ASSERT_TRUE(std::holds_alternative<mw::IPSocketInfo>(
+ config->listen_address));
+ const mw::IPSocketInfo& address =
+ std::get<mw::IPSocketInfo>(config->listen_address);
+ EXPECT_EQ(address.address, "127.0.0.1");
+ EXPECT_EQ(address.port, 8080);
+ EXPECT_EQ(config->static_root, temporary.path() / "static");
+ EXPECT_TRUE(std::filesystem::is_directory(
+ temporary.path() / "var/cards/published"));
+}
+
+/// Verify Unix listeners do not require a TCP port.
+TEST(ConfigTest, LoadsUnixSocketConfiguration)
+{
+ TemporaryConfigRoot temporary;
+ const std::filesystem::path config_path = temporary.path() / "app.toml";
+ writeConfig(
+ config_path,
+ "listen_address = \"unix:/tmp/card-collection.sock\"\n");
+
+ auto config = loadConfig(config_path);
+
+ ASSERT_TRUE(config) << config.error().msg();
+ ASSERT_TRUE(std::holds_alternative<mw::SocketFileInfo>(
+ config->listen_address));
+ EXPECT_EQ(
+ std::get<mw::SocketFileInfo>(config->listen_address).filename,
+ "/tmp/card-collection.sock");
+}
+
+/// Verify unknown configuration keys are rejected.
+TEST(ConfigTest, RejectsUnknownKeys)
+{
+ TemporaryConfigRoot temporary;
+ const std::filesystem::path config_path = temporary.path() / "app.toml";
+ writeConfig(
+ config_path,
+ "listen_address = \"127.0.0.1\"\nlisten_port = 8080\n",
+ "misspelled_setting = true\n");
+
+ EXPECT_FALSE(loadConfig(config_path));
+}
+
+/// Verify equal deterministic seeds produce equal values and byte strings.
+TEST(NonSecretRandomTest, RepeatsDeterministicSequence)
+{
+ NonSecretRandom left(42);
+ NonSecretRandom right(42);
+
+ for(int index = 0; index < 128; ++index)
+ {
+ EXPECT_EQ(left.next(), right.next());
+ }
+ EXPECT_EQ(left.hex(32), right.hex(32));
+}
+
+/// Verify hexadecimal output has the requested lowercase encoded size.
+TEST(NonSecretRandomTest, ProducesLowercaseHex)
+{
+ NonSecretRandom random(7);
+ const std::string value = random.hex(32);
+
+ ASSERT_EQ(value.size(), 64);
+ EXPECT_EQ(value.find_first_not_of("0123456789abcdef"),
+ std::string::npos);
+}
diff --git a/tests/data_mock.h b/tests/data_mock.h
index 3afc0fc..b658f16 100644
--- a/tests/data_mock.h
+++ b/tests/data_mock.h
@@ -140,6 +140,13 @@ public:
(const GameDefinition& game, std::int64_t card_id),
(const, override));
+ /// Mock retrieval of current game-owned edit values.
+ MOCK_METHOD(
+ (mw::E<FormFields>),
+ getGameFormValues,
+ (const GameDefinition& game, std::int64_t card_id),
+ (const, override));
+
/// Mock retrieval of all series.
MOCK_METHOD(
(mw::E<std::vector<Series>>),
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
index 56fdde3..5fd3eb8 100644
--- a/tests/data_sqlite_test.cpp
+++ b/tests/data_sqlite_test.cpp
@@ -1,6 +1,7 @@
#include "data_sqlite.h"
#include "game_registry.h"
#include "startup.h"
+#include "test_game.h"
#include <chrono>
#include <cstdint>
@@ -72,8 +73,8 @@ TEST(DataSourceSQLiteTest, OpensDatabase)
EXPECT_NE(*data_source, nullptr);
}
-/// Verify unfinished persistence operations report explicit errors.
-TEST(DataSourceSQLiteTest, ReportsPlaceholderOperations)
+/// Verify a fresh unmigrated connection has no application tables.
+TEST(DataSourceSQLiteTest, RejectsReadsBeforeMigration)
{
auto data_source_result = DataSourceSQLite::fromFile(":memory:");
ASSERT_TRUE(data_source_result);
@@ -256,6 +257,128 @@ TEST(DataSourceSQLiteTest, UpdatesLooseCard)
EXPECT_EQ((**stored).revision, 2);
}
+/// Verify series can be created, edited, listed, and deleted transactionally.
+TEST(DataSourceSQLiteTest, MutatesSeries)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+ auto data_source = prepareDataSource(database.path(), games);
+ ASSERT_TRUE(data_source);
+
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ auto series_id = (*transaction)->insertSeries(
+ {0, "test", "First series", "Description"});
+ ASSERT_TRUE(series_id);
+ ASSERT_TRUE((*transaction)->commit());
+
+ auto all_series = (*data_source)->getSeries();
+ ASSERT_TRUE(all_series);
+ ASSERT_EQ(all_series->size(), 1);
+ EXPECT_EQ(all_series->front().id, *series_id);
+ auto series = (*data_source)->getSeries(*series_id);
+ ASSERT_TRUE(series);
+ ASSERT_TRUE(*series);
+ (**series).name = "Edited series";
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->updateSeries(**series));
+ ASSERT_TRUE((*transaction)->commit());
+ series = (*data_source)->getSeries(*series_id);
+ ASSERT_TRUE(series);
+ ASSERT_TRUE(*series);
+ EXPECT_EQ((**series).name, "Edited series");
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->deleteSeries(*series_id));
+ ASSERT_TRUE((*transaction)->commit());
+ series = (*data_source)->getSeries(*series_id);
+ ASSERT_TRUE(series);
+ EXPECT_FALSE(*series);
+}
+
+/// Verify compiled-game schema, numbering, metadata, and memberships.
+TEST(DataSourceSQLiteTest, PersistsCompiledGameCards)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+ ASSERT_TRUE(games.add(std::make_unique<TestGame>()));
+ const GameDefinition* game = games.find("test");
+ ASSERT_NE(game, nullptr);
+ auto data_source = prepareDataSource(database.path(), games);
+ ASSERT_TRUE(data_source);
+
+ auto series_transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(series_transaction);
+ auto series_id = (*series_transaction)->insertSeries(
+ {0, "test", "Core set", "The first set."});
+ ASSERT_TRUE(series_id);
+ ASSERT_TRUE((*series_transaction)->commit());
+
+ auto metadata = game->validateMetadata({{"hp", "60"}, {"attack", "20"}});
+ ASSERT_TRUE(metadata);
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ auto number = (*transaction)->allocateGameNumber("test");
+ ASSERT_TRUE(number);
+ Card card = {
+ 0,
+ {"test", *number},
+ "Compiled card",
+ std::nullopt,
+ std::nullopt,
+ 2,
+ "avif",
+ std::nullopt,
+ "avif",
+ 1,
+ };
+ auto card_id = (*transaction)->insertCard(
+ card, game, metadata->get(), {*series_id});
+ ASSERT_TRUE(card_id);
+ ASSERT_TRUE((*transaction)->commit());
+
+ auto stored = (*data_source)->getCard({"test", 1});
+ ASSERT_TRUE(stored);
+ ASSERT_TRUE(*stored);
+ EXPECT_EQ((**stored).id, *card_id);
+ auto memberships = (*data_source)->getCardSeries(*card_id);
+ ASSERT_TRUE(memberships);
+ EXPECT_EQ(*memberships, std::vector<std::int64_t>{*series_id});
+ auto display = (*data_source)->getGameDisplayFields(*game, *card_id);
+ ASSERT_TRUE(display);
+ ASSERT_EQ(display->size(), 2);
+ EXPECT_EQ((*display)[0].value, "60");
+ EXPECT_EQ((*display)[1].value, "20");
+ auto form_values = (*data_source)->getGameFormValues(
+ *game, *card_id);
+ ASSERT_TRUE(form_values);
+ EXPECT_EQ(form_values->at("hp"), "60");
+ EXPECT_EQ(form_values->at("attack"), "20");
+
+ metadata = game->validateMetadata({{"hp", "70"}, {"attack", "30"}});
+ ASSERT_TRUE(metadata);
+ Card updated = **stored;
+ updated.revision = 2;
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->updateCard(
+ updated, game, metadata->get(), {}));
+ ASSERT_TRUE((*transaction)->commit());
+ memberships = (*data_source)->getCardSeries(*card_id);
+ ASSERT_TRUE(memberships);
+ EXPECT_TRUE(memberships->empty());
+ display = (*data_source)->getGameDisplayFields(*game, *card_id);
+ ASSERT_TRUE(display);
+ EXPECT_EQ((*display)[0].value, "70");
+ form_values = (*data_source)->getGameFormValues(*game, *card_id);
+ ASSERT_TRUE(form_values);
+ EXPECT_EQ(form_values->at("hp"), "70");
+ EXPECT_EQ(form_values->at("attack"), "30");
+}
+
/// Verify destroying an uncommitted transaction rolls card insertion back.
TEST(DataSourceSQLiteTest, RollsBackLooseCard)
{
diff --git a/tests/markdown_renderer_test.cpp b/tests/markdown_renderer_test.cpp
new file mode 100644
index 0000000..e626999
--- /dev/null
+++ b/tests/markdown_renderer_test.cpp
@@ -0,0 +1,37 @@
+#include "markdown_renderer.h"
+
+#include <string>
+
+#include <gtest/gtest.h>
+
+/// Verify ordinary Markdown renders its structural HTML.
+TEST(MarkdownRendererTest, RendersFormatting)
+{
+ MarkdownRenderer renderer;
+ auto html = renderer.render("A **bright** card.");
+
+ ASSERT_TRUE(html) << html.error().msg();
+ EXPECT_NE(html->value().find("<strong>bright</strong>"),
+ std::string::npos);
+}
+
+/// Verify literal user HTML is escaped before MacroDown evaluation.
+TEST(MarkdownRendererTest, EscapesRawHtml)
+{
+ MarkdownRenderer renderer;
+ auto html = renderer.render("<script>alert('x')</script>");
+
+ ASSERT_TRUE(html) << html.error().msg();
+ EXPECT_EQ(html->value().find("<script>"), std::string::npos);
+ EXPECT_NE(html->value().find("<script>"), std::string::npos);
+}
+
+/// Verify unsafe and relative Markdown URLs are rejected.
+TEST(MarkdownRendererTest, RejectsUnsafeUrls)
+{
+ MarkdownRenderer renderer;
+
+ EXPECT_FALSE(renderer.render("[bad](javascript:alert(1))"));
+ EXPECT_FALSE(renderer.render("[relative](/cards/1)"));
+ EXPECT_TRUE(renderer.render("[safe](https://example.test/a?b=c)"));
+}
diff --git a/tests/test_game.h b/tests/test_game.h
new file mode 100644
index 0000000..c1fb7ee
--- /dev/null
+++ b/tests/test_game.h
@@ -0,0 +1,222 @@
+#pragma once
+
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <string_view>
+#include <tuple>
+#include <utility>
+#include <vector>
+
+#include <mw/utils.hpp>
+
+#include "game_definition.h"
+
+/// Strongly typed metadata used by the compiled-game test fixture.
+class TestGameMetadata final : public GameCardMetadata
+{
+public:
+ /// Construct test metadata values.
+ TestGameMetadata(std::int64_t hp, std::int64_t attack)
+ : hp(hp), attack(attack)
+ {}
+
+ /// Nonnegative hit-point value.
+ std::int64_t hp;
+
+ /// Nonnegative attack value.
+ std::int64_t attack;
+};
+
+/// Compiled game fixture proving extension schema and metadata hooks.
+class TestGame final : public GameDefinition
+{
+public:
+ /// Return the fixture's canonical short name.
+ std::string_view shortName() const override
+ {
+ return "test";
+ }
+
+ /// Return the fixture's human-readable name.
+ std::string_view displayName() const override
+ {
+ return "Test Game";
+ }
+
+ /// Return the fixture game description.
+ std::string_view description() const override
+ {
+ return "Compiled extension test game.";
+ }
+
+ /// Create the fixture extension table.
+ mw::E<void> createSchema(mw::SQLite& database) const override
+ {
+ return database.execute(
+ "CREATE TABLE test_cards ("
+ "card_id INTEGER PRIMARY KEY "
+ "REFERENCES cards(id) ON DELETE CASCADE, "
+ "hp INTEGER NOT NULL CHECK(hp >= 0), "
+ "attack INTEGER NOT NULL CHECK(attack >= 0));");
+ }
+
+ /// Validate the fixture's posted integer fields.
+ mw::E<std::unique_ptr<GameCardMetadata>> validateMetadata(
+ const FormFields& fields) const override
+ {
+ const auto hp_position = fields.find("hp");
+ const auto attack_position = fields.find("attack");
+ if(hp_position == fields.end() || attack_position == fields.end())
+ {
+ return std::unexpected(mw::httpError(
+ 422, "HP and attack are required"));
+ }
+ auto hp = mw::strToNumber<std::int64_t>(hp_position->second);
+ auto attack = mw::strToNumber<std::int64_t>(
+ attack_position->second);
+ if(!hp || !attack || *hp < 0 || *attack < 0)
+ {
+ return std::unexpected(mw::httpError(
+ 422, "HP and attack must be nonnegative integers"));
+ }
+ return std::unique_ptr<GameCardMetadata>(
+ new TestGameMetadata(*hp, *attack));
+ }
+
+ /// Return the fixture's numeric metadata controls.
+ std::vector<GameFormField> formFields() const override
+ {
+ return {
+ {"hp", "HP", "number", true, "0"},
+ {"attack", "Attack", "number", true, "0"},
+ };
+ }
+
+ /// Return fixture metadata as editable form values.
+ mw::E<FormFields> formValues(
+ mw::SQLite& database,
+ std::int64_t card_id) const override
+ {
+ auto statement = database.statementFromStr(
+ "SELECT hp, attack FROM test_cards WHERE card_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 = database.eval<std::int64_t, std::int64_t>(
+ std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ if(rows->size() != 1)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Test Game metadata is missing"));
+ }
+ const auto& [hp, attack] = rows->front();
+ return FormFields{
+ {"hp", std::to_string(hp)},
+ {"attack", std::to_string(attack)},
+ };
+ }
+
+ /// Insert fixture metadata for a common card ID.
+ mw::E<void> insertMetadata(
+ mw::SQLite& database,
+ std::int64_t card_id,
+ const GameCardMetadata& metadata) const override
+ {
+ const auto* values = dynamic_cast<const TestGameMetadata*>(&metadata);
+ if(values == nullptr)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Test Game received metadata of the wrong type"));
+ }
+ auto statement = database.statementFromStr(
+ "INSERT INTO test_cards (card_id, hp, attack) "
+ "VALUES (?, ?, ?);");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::int64_t,
+ std::int64_t,
+ std::int64_t>(
+ card_id, values->hp, values->attack);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ return database.execute(std::move(*statement));
+ }
+
+ /// Replace fixture metadata for a common card ID.
+ mw::E<void> updateMetadata(
+ mw::SQLite& database,
+ std::int64_t card_id,
+ const GameCardMetadata& metadata) const override
+ {
+ const auto* values = dynamic_cast<const TestGameMetadata*>(&metadata);
+ if(values == nullptr)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Test Game received metadata of the wrong type"));
+ }
+ auto statement = database.statementFromStr(
+ "UPDATE test_cards SET hp = ?, attack = ? WHERE card_id = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::int64_t,
+ std::int64_t,
+ std::int64_t>(
+ values->hp, values->attack, card_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ return database.execute(std::move(*statement));
+ }
+
+ /// Return fixture metadata formatted for card display.
+ mw::E<std::vector<DisplayField>> displayFields(
+ mw::SQLite& database,
+ std::int64_t card_id) const override
+ {
+ auto statement = database.statementFromStr(
+ "SELECT hp, attack FROM test_cards WHERE card_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 = database.eval<std::int64_t, std::int64_t>(
+ std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ if(rows->size() != 1)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Test Game metadata is missing"));
+ }
+ const auto& [hp, attack] = rows->front();
+ return std::vector<DisplayField>{
+ {"HP", std::to_string(hp)},
+ {"Attack", std::to_string(attack)},
+ };
+ }
+};