Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0d42059..55e8d01 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -17,9 +17,13 @@ include(cmake/dependencies.cmake)
add_executable(
card_collection
src/app.cpp
+ src/asset_store.cpp
+ src/card_service.cpp
src/data.cpp
src/data_sqlite.cpp
+ src/image_processor.cpp
src/main.cpp
+ src/multipart_reader.cpp
src/public_id.cpp
src/startup.cpp
src/url_builder.cpp
@@ -53,6 +57,8 @@ target_link_libraries(
card_collection
PRIVATE
ImageMagick::Magick++
+ ImageMagick::MagickWand
+ ImageMagick::MagickCore
MacroDown::MacroDown
mw::http-server
mw::mw
@@ -90,7 +96,11 @@ if(CARD_COLLECTION_BUILD_TESTS)
add_executable(
app_test
src/app.cpp
+ src/asset_store.cpp
+ src/card_service.cpp
src/data_fake.cpp
+ src/image_processor.cpp
+ src/multipart_reader.cpp
src/public_id.cpp
src/url_builder.cpp
tests/app_test.cpp
@@ -107,6 +117,9 @@ if(CARD_COLLECTION_BUILD_TESTS)
app_test
PRIVATE
GTest::gtest_main
+ ImageMagick::Magick++
+ ImageMagick::MagickWand
+ ImageMagick::MagickCore
mw::http-server
mw::url
pantor::inja
@@ -164,4 +177,41 @@ if(CARD_COLLECTION_BUILD_TESTS)
spdlog::spdlog
)
gtest_discover_tests(data_sqlite_test)
+
+ add_executable(
+ card_service_test
+ src/asset_store.cpp
+ src/card_service.cpp
+ src/data.cpp
+ src/data_sqlite.cpp
+ src/image_processor.cpp
+ src/multipart_reader.cpp
+ src/public_id.cpp
+ src/startup.cpp
+ tests/card_service_test.cpp
+ )
+ target_compile_features(card_service_test PRIVATE cxx_std_23)
+ set_target_properties(
+ card_service_test
+ PROPERTIES CXX_EXTENSIONS OFF
+ )
+ target_include_directories(
+ card_service_test
+ PRIVATE
+ ${libmw_SOURCE_DIR}/includes
+ src
+ )
+ target_link_libraries(
+ card_service_test
+ PRIVATE
+ GTest::gtest_main
+ ImageMagick::Magick++
+ ImageMagick::MagickWand
+ ImageMagick::MagickCore
+ mw::http-server
+ mw::mw
+ mw::sqlite
+ spdlog::spdlog
+ )
+ gtest_discover_tests(card_service_test)
endif()
diff --git a/cmake/dependencies.cmake b/cmake/dependencies.cmake
index 9c4d9ed..3483b88 100644
--- a/cmake/dependencies.cmake
+++ b/cmake/dependencies.cmake
@@ -87,4 +87,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
FetchContent_MakeAvailable(googletest)
endif()
-find_package(ImageMagick 7 REQUIRED COMPONENTS Magick++)
+find_package(
+ ImageMagick 7 REQUIRED
+ COMPONENTS Magick++ MagickWand MagickCore
+)
diff --git a/designs/design-0-prototype.md b/designs/design-0-prototype.md
index bce5f31..875e7fd 100644
--- a/designs/design-0-prototype.md
+++ b/designs/design-0-prototype.md
@@ -1022,7 +1022,7 @@ CREATE TABLE cards (
front_extension TEXT NOT NULL
CHECK(front_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
foil_extension TEXT
- CHECK(foil_extension IN ('webp', 'avif')),
+ CHECK(foil_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
thumbnail_extension TEXT NOT NULL
CHECK(thumbnail_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
revision INTEGER NOT NULL DEFAULT 1 CHECK(revision >= 1),
@@ -1066,11 +1066,11 @@ CREATE TABLE card_series (
```
PNG is absent from stored extensions because every accepted PNG is normalized
-to AVIF before publication. JPEG is absent only from foil extensions because
-foil-control textures require alpha. A nonfoil thumbnail is a resized artwork
-image and may be opaque, so JPEG is valid for thumbnails. The server
-canonicalizes `.jpeg` and `.jpg` input to one chosen extension, preferably
-`jpg`, before insertion.
+to AVIF before publication. Front artwork, foil-control textures, and
+thumbnails accept the same stored formats. WebGL treats a missing alpha
+channel in a foil-control texture as fully opaque. The server canonicalizes
+`.jpeg` and `.jpg` input to one chosen extension, preferably `jpg`, before
+insertion.
SQLite partial unique indexes enforce the two identity namespaces without
making null game values conflict. See
@@ -1324,8 +1324,7 @@ For every upload:
6. Require exactly one decoded image. Reject animation and image sequences.
7. Require the decoded `magick()` value to agree with the sniffed format.
8. Read `columns()`, `rows()`, and alpha state from the decoded image. Reject
- zero dimensions or a long side greater than 2048. Only the foil-control
- role requires alpha.
+ zero dimensions or a long side greater than 2048.
9. For PNG, transform the decoded image to sRGB and encode a single still AVIF
using configured quality. Preserve its alpha channel.
10. For JPEG, WebP, and AVIF, retain the original bytes after the successful
@@ -1337,14 +1336,13 @@ only a header would let malformed files reach browsers and reverse proxies.
### Format-specific rules
-- JPEG is valid for artwork and thumbnails. It is invalid for foil control
- because it has no alpha.
+- JPEG is valid for artwork, foil controls, and thumbnails.
- PNG is decoded by Magick++ and always converted to AVIF. An APNG produces
more than one image and is rejected.
- WebP is fully decoded by Magick++. Animated WebP produces more than one
- image and is rejected. Only the foil-control role requires alpha.
+ image and is rejected.
- AVIF is fully decoded by Magick++. An AVIF sequence produces more than one
- image and is rejected. Only the foil-control role requires alpha.
+ image and is rejected.
Decoded allocation sizes must be checked before multiplication. With a
2048-pixel maximum on each dimension, an RGBA8 buffer is at most 16 MiB, but
@@ -1799,6 +1797,8 @@ Magick++, SQL, and filesystem errors go only to logs.
- `front-art.jpeg` if retained as a supported canonical variant;
- `front-art.webp`;
- `front-art.avif`;
+- `foil.jpg`;
+- `foil.jpeg` if retained as a supported canonical variant;
- `foil.webp`;
- `foil.avif`;
- `thumb.jpg`;
@@ -2149,10 +2149,10 @@ Use GoogleTest and temporary directories created per test. Tests must cover:
- startup rejection when a required coder capability is unavailable through
an injected capability-check adapter;
- valid JPEG, PNG, WebP, and AVIF artwork;
-- valid PNG, WebP, and AVIF foil controls;
+- valid JPEG, PNG, WebP, and AVIF foil controls;
- valid opaque JPEG, WebP, and AVIF thumbnails;
- PNG-to-AVIF conversion and alpha preservation;
-- JPEG foil rejection;
+- acceptance of foil controls without alpha;
- acceptance of thumbnails without alpha;
- plain-thumbnail AVIF generation from each accepted artwork format;
- exact 183-by-256 plain-thumbnail dimensions with the default setting;
diff --git a/src/app.cpp b/src/app.cpp
index af5f2ee..3c7b3e7 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -1,6 +1,7 @@
#include "app.h"
#include <algorithm>
+#include <charconv>
#include <cctype>
#include <cstddef>
#include <filesystem>
@@ -8,6 +9,7 @@
#include <memory>
#include <stdexcept>
#include <string>
+#include <string_view>
#include <system_error>
#include <unordered_map>
#include <utility>
@@ -16,6 +18,7 @@
#include <spdlog/spdlog.h>
#include "public_id.h"
+#include "multipart_reader.h"
namespace
{
@@ -183,6 +186,85 @@ void respondNotFound(App::Response& response)
"text/html; charset=utf-8");
}
+void respondBadRequest(
+ App::Response& response,
+ const std::string& message)
+{
+ response.status = 400;
+ response.set_content(message + "\n", "text/plain; charset=utf-8");
+}
+
+void respondOperationError(
+ App::Response& response,
+ const mw::Error& error,
+ std::string_view operation)
+{
+ const mw::HTTPError* http_error = error.as<mw::HTTPError>();
+ if(http_error != nullptr && http_error->code < 500)
+ {
+ response.status = http_error->code;
+ response.set_content(
+ http_error->msg + "\n", "text/plain; charset=utf-8");
+ return;
+ }
+ spdlog::error("{}: {}", operation, error.msg());
+ 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)
+{
+ const auto position = fields.find(name);
+ if(position == fields.end())
+ {
+ return std::nullopt;
+ }
+ std::string value = trimAscii(position->second);
+ if(value.empty())
+ {
+ return std::nullopt;
+ }
+ return value;
+}
+
+mw::E<std::int64_t> parseRarity(
+ const std::unordered_map<std::string, std::string>& fields)
+{
+ const auto position = fields.find("rarity");
+ if(position == fields.end())
+ {
+ return std::unexpected(mw::runtimeError("Rarity is required"));
+ }
+ std::int64_t rarity = 0;
+ const std::string& text = position->second;
+ const auto result = std::from_chars(
+ text.data(), text.data() + text.size(), rarity);
+ if(text.empty() || result.ec != std::errc{} ||
+ result.ptr != text.data() + text.size() || rarity < 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Rarity must be a nonnegative integer"));
+ }
+ return rarity;
+}
+
bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
{
std::error_code filesystem_error;
@@ -215,6 +297,11 @@ App::App(
throw std::invalid_argument("App requires a data source");
}
+ card_service_ = std::make_unique<CardService>(
+ *data_source_,
+ ImageProcessor(config_.avif_quality, config_.thumbnail_long_side),
+ AssetStore(config_.card_storage_root));
+
templates_.set_html_autoescape(true);
templates_.add_callback(
"url_for",
@@ -288,6 +375,95 @@ void App::handleCardNew(
}
}
+void App::handleCardCreate(
+ const Request& request,
+ Response& response,
+ const ContentReader& content_reader)
+{
+ if(!request.is_multipart_form_data())
+ {
+ respondBadRequest(response, "Expected a multipart form upload");
+ return;
+ }
+
+ MultipartReader multipart_reader(config_.card_storage_root);
+ auto upload = multipart_reader.read(content_reader);
+ if(!upload)
+ {
+ respondOperationError(
+ response, upload.error(), "Failed to receive a card upload");
+ return;
+ }
+
+ 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 auto source_mode = upload->fields.find("source_mode");
+ if(source_mode != upload->fields.end() &&
+ source_mode->second != "files" &&
+ source_mode->second != "urls")
+ {
+ respondBadRequest(
+ response, "Unknown image source mode");
+ return;
+ }
+ const auto name_position = upload->fields.find("name");
+ const std::string name = name_position == upload->fields.end()
+ ? std::string()
+ : trimAscii(name_position->second);
+ if(name.empty())
+ {
+ respondBadRequest(response, "Card name is required");
+ return;
+ }
+ if(name.size() > 200)
+ {
+ respondBadRequest(response, "Card name is too long");
+ return;
+ }
+ if(!upload->front)
+ {
+ 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)
+ {
+ respondBadRequest(response, rarity.error().msg());
+ return;
+ }
+
+ auto created = card_service_->createLooseCard({
+ name,
+ optionalText(upload->fields, "short_description"),
+ optionalText(upload->fields, "long_description"),
+ *rarity,
+ upload->staging_directory,
+ *upload->front,
+ upload->foil,
+ upload->thumbnail,
+ });
+ if(!created)
+ {
+ respondOperationError(
+ response, created.error(), "Failed to create a card");
+ return;
+ }
+
+ response.status = 303;
+ response.set_header("Location", urlFor("card", {*created}));
+}
+
void App::handleCardView(
const Request& request,
Response& response)
@@ -335,7 +511,7 @@ void App::handleCardView(
const std::string& public_id = *public_id_result;
const std::filesystem::path asset_root =
config_.card_storage_root / "published" / public_id;
- const std::string front_name = "front." + card.front_extension;
+ const std::string front_name = "front-art." + card.front_extension;
const bool front_exists = isRegularFile(
asset_root / front_name, card.id);
const std::string front_url = front_exists
@@ -592,6 +768,9 @@ void App::setup()
server.Get(
getPath("card", {"id"}),
std::bind_front(&App::handleCardView, this));
+ server.Post(
+ getPath("cards"),
+ std::bind_front(&App::handleCardCreate, this));
}
std::string App::getPath(
diff --git a/src/app.h b/src/app.h
index eb9ab3f..684cc25 100644
--- a/src/app.h
+++ b/src/app.h
@@ -7,6 +7,7 @@
#include <inja/inja.hpp>
#include <mw/http_server.hpp>
+#include "card_service.h"
#include "config.h"
#include "data.h"
#include "url_builder.h"
@@ -21,6 +22,9 @@ public:
/// HTTP response type supplied by libmw.
using Response = mw::HTTPServer::Response;
+ /// Streaming request-body reader supplied by cpp-httplib.
+ using ContentReader = httplib::ContentReader;
+
/// Disable construction without validated configuration.
App() = delete;
@@ -41,6 +45,12 @@ public:
/// Render the create-card form.
void handleCardNew(const Request& request, Response& response);
+ /// Accept, process, and persist a new card upload.
+ void handleCardCreate(
+ const Request& request,
+ Response& response,
+ const ContentReader& content_reader);
+
/// Render one read-only card page.
void handleCardView(const Request& request, Response& response);
@@ -58,6 +68,7 @@ private:
Config config_;
std::unique_ptr<DataSourceInterface> data_source_;
+ std::unique_ptr<CardService> card_service_;
UrlBuilder url_builder_;
inja::Environment templates_;
inja::Template card_form_template_;
diff --git a/src/asset_store.cpp b/src/asset_store.cpp
new file mode 100644
index 0000000..c560e8d
--- /dev/null
+++ b/src/asset_store.cpp
@@ -0,0 +1,65 @@
+#include "asset_store.h"
+
+#include <filesystem>
+#include <string>
+#include <system_error>
+#include <utility>
+
+#include <spdlog/spdlog.h>
+
+AssetStore::AssetStore(std::filesystem::path card_storage_root)
+ : published_root_(
+ std::move(card_storage_root) / "published")
+{}
+
+mw::E<void> AssetStore::publish(
+ const std::filesystem::path& staging_directory,
+ const std::string& public_id) const
+{
+ std::error_code filesystem_error;
+ std::filesystem::create_directories(
+ published_root_, filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to create the published asset root: " +
+ filesystem_error.message()));
+ }
+
+ const std::filesystem::path destination = published_root_ / public_id;
+ if(std::filesystem::exists(destination, filesystem_error))
+ {
+ return std::unexpected(mw::runtimeError(
+ "The destination card asset directory already exists"));
+ }
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to inspect the published asset directory: " +
+ filesystem_error.message()));
+ }
+
+ std::filesystem::rename(
+ staging_directory, destination, filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to publish the card assets: " +
+ filesystem_error.message()));
+ }
+ return {};
+}
+
+void AssetStore::removePublished(const std::string& public_id) const
+{
+ std::error_code filesystem_error;
+ std::filesystem::remove_all(
+ published_root_ / public_id, filesystem_error);
+ if(filesystem_error)
+ {
+ spdlog::error(
+ "Failed to remove rolled-back assets for card {}: {}",
+ public_id,
+ filesystem_error.message());
+ }
+}
diff --git a/src/asset_store.h b/src/asset_store.h
new file mode 100644
index 0000000..ea528a0
--- /dev/null
+++ b/src/asset_store.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#include <filesystem>
+#include <string>
+
+#include <mw/error.hpp>
+
+/// Atomically publish and remove complete card asset directories.
+class AssetStore
+{
+public:
+ /// Construct an asset store below the configured private root.
+ explicit AssetStore(std::filesystem::path card_storage_root);
+
+ /// Rename a complete staging directory into its public location.
+ mw::E<void> publish(
+ const std::filesystem::path& staging_directory,
+ const std::string& public_id) const;
+
+ /// Remove one precisely identified published directory after rollback.
+ void removePublished(const std::string& public_id) const;
+
+private:
+ std::filesystem::path published_root_;
+};
diff --git a/src/card.h b/src/card.h
index ab2fc31..a6cc3e2 100644
--- a/src/card.h
+++ b/src/card.h
@@ -54,7 +54,7 @@ enum class CardAssetType
/// Full-size front artwork.
FRONT_ART,
- /// Optional alpha-bearing foil-control texture.
+ /// Optional foil-control texture.
FOIL_CONTROL,
/// Static card-index thumbnail.
diff --git a/src/card_service.cpp b/src/card_service.cpp
new file mode 100644
index 0000000..aa648bd
--- /dev/null
+++ b/src/card_service.cpp
@@ -0,0 +1,159 @@
+#include "card_service.h"
+
+#include <cstdint>
+#include <filesystem>
+#include <limits>
+#include <optional>
+#include <random>
+#include <string>
+#include <utility>
+
+#include "public_id.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);
+}
+
+} // namespace
+
+CardService::CardService(
+ DataSourceInterface& data_source,
+ ImageProcessor image_processor,
+ AssetStore asset_store)
+ : data_source_(data_source),
+ image_processor_(std::move(image_processor)),
+ asset_store_(std::move(asset_store))
+{}
+
+mw::E<std::string> CardService::createLooseCard(
+ CreateLooseCardInput input)
+{
+ auto front = image_processor_.process(
+ input.front, CardAssetType::FRONT_ART);
+ if(!front)
+ {
+ return std::unexpected(std::move(front.error()));
+ }
+
+ std::optional<ProcessedImage> foil;
+ if(input.foil)
+ {
+ auto processed = image_processor_.process(
+ *input.foil, CardAssetType::FOIL_CONTROL);
+ if(!processed)
+ {
+ return std::unexpected(std::move(processed.error()));
+ }
+ foil = std::move(*processed);
+ }
+
+ ProcessedImage thumbnail;
+ if(foil)
+ {
+ if(!input.thumbnail)
+ {
+ return std::unexpected(mw::httpError(
+ 422,
+ "A rendered thumbnail is required for a foil card"));
+ }
+ auto processed = image_processor_.process(
+ *input.thumbnail, CardAssetType::THUMBNAIL);
+ if(!processed)
+ {
+ return std::unexpected(std::move(processed.error()));
+ }
+ thumbnail = std::move(*processed);
+ }
+ else
+ {
+ if(input.thumbnail)
+ {
+ return std::unexpected(mw::httpError(
+ 422,
+ "A plain card must not include an uploaded thumbnail"));
+ }
+ auto generated = image_processor_.generatePlainThumbnail(
+ *front,
+ input.staging_directory / "thumb.avif");
+ if(!generated)
+ {
+ return std::unexpected(std::move(generated.error()));
+ }
+ thumbnail = std::move(*generated);
+ }
+
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+
+ std::optional<std::uint32_t> number;
+ for(int attempt = 0; attempt < MAX_ID_ATTEMPTS; ++attempt)
+ {
+ const std::uint32_t candidate = randomLooseNumber();
+ auto exists = (*transaction)->looseNumberExists(candidate);
+ if(!exists)
+ {
+ return std::unexpected(std::move(exists.error()));
+ }
+ if(!*exists)
+ {
+ number = candidate;
+ break;
+ }
+ }
+ if(!number)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to allocate a unique loose-card number"));
+ }
+
+ Card card = {
+ 0,
+ {std::nullopt, *number},
+ std::move(input.name),
+ std::move(input.short_description),
+ std::move(input.long_description),
+ input.rarity,
+ front->extension,
+ foil ? std::optional<std::string>(foil->extension) : std::nullopt,
+ thumbnail.extension,
+ 1,
+ };
+ auto inserted = (*transaction)->insertCard(
+ card, nullptr, nullptr, {});
+ if(!inserted)
+ {
+ return std::unexpected(std::move(inserted.error()));
+ }
+
+ auto public_id = formatPublicId(card.identity);
+ if(!public_id)
+ {
+ return std::unexpected(std::move(public_id.error()));
+ }
+ auto published = asset_store_.publish(
+ input.staging_directory, *public_id);
+ if(!published)
+ {
+ return std::unexpected(std::move(published.error()));
+ }
+
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ asset_store_.removePublished(*public_id);
+ return std::unexpected(std::move(committed.error()));
+ }
+ return *public_id;
+}
diff --git a/src/card_service.h b/src/card_service.h
new file mode 100644
index 0000000..443fdfd
--- /dev/null
+++ b/src/card_service.h
@@ -0,0 +1,60 @@
+#pragma once
+
+#include <cstdint>
+#include <filesystem>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <mw/error.hpp>
+
+#include "asset_store.h"
+#include "data.h"
+#include "image_processor.h"
+
+/// Validated common fields and staged files for a new loose card.
+struct CreateLooseCardInput
+{
+ /// Human-readable card name.
+ std::string name;
+
+ /// Optional short Markdown description.
+ std::optional<std::string> short_description;
+
+ /// Optional long Markdown description.
+ std::optional<std::string> long_description;
+
+ /// Nonnegative rarity value.
+ std::int64_t rarity;
+
+ /// Complete private staging directory for this request.
+ std::filesystem::path staging_directory;
+
+ /// Staged front artwork path.
+ std::filesystem::path front;
+
+ /// Optional staged foil-control path.
+ std::optional<std::filesystem::path> foil;
+
+ /// Optional browser-rendered thumbnail for a foil card.
+ std::optional<std::filesystem::path> thumbnail;
+};
+
+/// Coordinate image processing, persistence, and asset publication.
+class CardService
+{
+public:
+ /// Construct a card creation service from its owned boundaries.
+ CardService(
+ DataSourceInterface& data_source,
+ ImageProcessor image_processor,
+ AssetStore asset_store);
+
+ /// Create a loose card and return its canonical public ID.
+ mw::E<std::string> createLooseCard(CreateLooseCardInput input);
+
+private:
+ DataSourceInterface& data_source_;
+ ImageProcessor image_processor_;
+ AssetStore asset_store_;
+};
diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
index 1120bb7..f1ca818 100644
--- a/src/data_sqlite.cpp
+++ b/src/data_sqlite.cpp
@@ -50,7 +50,8 @@ const std::array<std::string_view, 7> SCHEMA_VERSION_1_STATEMENTS = {
front_extension TEXT NOT NULL
CHECK(front_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
foil_extension TEXT
- CHECK(foil_extension IN ('webp', 'avif')),
+ CHECK(foil_extension IN
+ ('jpg', 'jpeg', 'webp', 'avif')),
thumbnail_extension TEXT NOT NULL
CHECK(thumbnail_extension IN
('jpg', 'jpeg', 'webp', 'avif')),
@@ -120,6 +121,287 @@ const std::array<std::string_view, 7> SCHEMA_VERSION_1_STATEMENTS = {
)sql",
};
+class DataSourceSQLiteTransaction final
+ : public DataSourceTransactionInterface
+{
+public:
+ /// Adopt an active immediate transaction and its connection lock.
+ DataSourceSQLiteTransaction(
+ mw::SQLite& connection,
+ std::unique_lock<std::mutex> lock)
+ : connection_(connection),
+ lock_(std::move(lock))
+ {}
+
+ /// Roll back an uncommitted transaction and release its connection lock.
+ ~DataSourceSQLiteTransaction() override
+ {
+ if(!committed_)
+ {
+ auto rollback = connection_.execute("ROLLBACK;");
+ if(!rollback)
+ {
+ spdlog::error(
+ "Failed to roll back card transaction: {}",
+ rollback.error().msg());
+ }
+ }
+ }
+
+ /// Prevent copying transaction ownership and its connection lock.
+ DataSourceSQLiteTransaction(
+ const DataSourceSQLiteTransaction&) = delete;
+
+ /// Prevent copy assignment of transaction ownership.
+ DataSourceSQLiteTransaction& operator=(
+ const DataSourceSQLiteTransaction&) = delete;
+
+ /// Allocate and persist the next never-reused number for one game.
+ mw::E<std::uint64_t> allocateGameNumber(
+ const std::string& game_short_name) override
+ {
+ auto statement = connection_.statementFromStr(
+ "UPDATE game_sequences "
+ "SET last_number = last_number + 1 "
+ "WHERE game_short_name = ? "
+ "RETURNING last_number;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::string>(game_short_name);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto rows = connection_.eval<std::int64_t>(
+ std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ if(rows->size() != 1 || std::get<0>(rows->front()) <= 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Game sequence is missing or invalid"));
+ }
+ return static_cast<std::uint64_t>(
+ std::get<0>(rows->front()));
+ }
+
+ /// Ensure a registered game has a persistent sequence row.
+ mw::E<void> ensureGameSequence(
+ const std::string& game_short_name) override
+ {
+ auto statement = connection_.statementFromStr(
+ "INSERT OR IGNORE INTO game_sequences "
+ "(game_short_name, last_number) VALUES (?, 0);");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::string>(game_short_name);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ return connection_.execute(std::move(*statement));
+ }
+
+ /// Return whether a loose-card number already exists.
+ mw::E<bool> looseNumberExists(std::uint32_t number) override
+ {
+ auto statement = connection_.statementFromStr(
+ "SELECT EXISTS("
+ "SELECT 1 FROM cards "
+ "WHERE game_short_name IS NULL AND card_number = ?);");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::int64_t>(number);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto result = connection_.evalToValue<int>(
+ std::move(*statement));
+ if(!result)
+ {
+ return std::unexpected(std::move(result.error()));
+ }
+ return *result != 0;
+ }
+
+ /// Re-read a card while the transaction lock is held.
+ mw::E<std::optional<Card>> getCardForUpdate(
+ [[maybe_unused]] std::int64_t card_id) override
+ {
+ return std::unexpected(notImplemented("transactional card reads"));
+ }
+
+ /// Insert common and series-membership rows for a loose card.
+ mw::E<std::int64_t> insertCard(
+ const Card& card,
+ const GameDefinition* game,
+ const GameCardMetadata* metadata,
+ const std::vector<std::int64_t>& series_ids) override
+ {
+ if(card.id != 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "A new card cannot already have an internal ID"));
+ }
+ if((game == nullptr) != (metadata == nullptr))
+ {
+ return std::unexpected(mw::runtimeError(
+ "Game definition and metadata must be provided together"));
+ }
+ if(card.identity.game_short_name)
+ {
+ if(game == nullptr)
+ {
+ return std::unexpected(mw::runtimeError(
+ "A game card requires compiled game metadata"));
+ }
+ return std::unexpected(notImplemented(
+ "game-specific card insertion"));
+ }
+ if(game != nullptr)
+ {
+ return std::unexpected(mw::runtimeError(
+ "A loose card cannot have compiled game metadata"));
+ }
+ if(card.identity.card_number >
+ std::numeric_limits<std::uint32_t>::max())
+ {
+ return std::unexpected(mw::runtimeError(
+ "Loose card number exceeds the 32-bit namespace"));
+ }
+
+ auto insert = connection_.statementFromStr(
+ "INSERT INTO cards ("
+ "game_short_name, card_number, name, short_description, "
+ "long_description, rarity, front_extension, foil_extension, "
+ "thumbnail_extension, revision) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);");
+ if(!insert)
+ {
+ return std::unexpected(std::move(insert.error()));
+ }
+ auto bind = insert->bind(
+ card.identity.game_short_name,
+ static_cast<std::int64_t>(card.identity.card_number),
+ card.name,
+ card.short_description,
+ card.long_description,
+ card.rarity,
+ card.front_extension,
+ card.foil_extension,
+ card.thumbnail_extension,
+ card.revision);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto execute = connection_.execute(std::move(*insert));
+ if(!execute)
+ {
+ return std::unexpected(std::move(execute.error()));
+ }
+ const std::int64_t card_id = connection_.lastInsertRowID();
+
+ for(std::int64_t series_id : series_ids)
+ {
+ auto membership = connection_.statementFromStr(
+ "INSERT INTO card_series (card_id, series_id) "
+ "VALUES (?, ?);");
+ if(!membership)
+ {
+ return std::unexpected(std::move(membership.error()));
+ }
+ auto membership_bind = membership->bind<std::int64_t,
+ std::int64_t>(
+ card_id, series_id);
+ if(!membership_bind)
+ {
+ return std::unexpected(
+ std::move(membership_bind.error()));
+ }
+ auto membership_execute = connection_.execute(
+ std::move(*membership));
+ if(!membership_execute)
+ {
+ return std::unexpected(
+ std::move(membership_execute.error()));
+ }
+ }
+ return card_id;
+ }
+
+ /// Replace card rows after validation by the service layer.
+ mw::E<void> updateCard(
+ [[maybe_unused]] const Card& card,
+ [[maybe_unused]] const GameDefinition* game,
+ [[maybe_unused]] const GameCardMetadata* metadata,
+ [[maybe_unused]] const std::vector<std::int64_t>& series_ids)
+ override
+ {
+ return std::unexpected(notImplemented("card updates"));
+ }
+
+ /// Delete a card and its dependent database rows.
+ mw::E<void> deleteCard(
+ [[maybe_unused]] std::int64_t card_id) override
+ {
+ return std::unexpected(notImplemented("card deletion"));
+ }
+
+ /// Insert a series and return its internal ID.
+ mw::E<std::int64_t> insertSeries(
+ [[maybe_unused]] const Series& series) override
+ {
+ return std::unexpected(notImplemented("series insertion"));
+ }
+
+ /// Replace a series name and description without changing its game.
+ mw::E<void> updateSeries(
+ [[maybe_unused]] const Series& series) override
+ {
+ return std::unexpected(notImplemented("series updates"));
+ }
+
+ /// Delete a series and its membership rows.
+ mw::E<void> deleteSeries(
+ [[maybe_unused]] std::int64_t series_id) override
+ {
+ return std::unexpected(notImplemented("series deletion"));
+ }
+
+ /// Commit the transaction and release its connection lock.
+ mw::E<void> commit() override
+ {
+ if(committed_)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Transaction has already been committed"));
+ }
+ auto result = connection_.execute("COMMIT;");
+ if(!result)
+ {
+ return std::unexpected(std::move(result.error()));
+ }
+ committed_ = true;
+ lock_.unlock();
+ return {};
+ }
+
+private:
+ mw::SQLite& connection_;
+ std::unique_lock<std::mutex> lock_;
+ bool committed_ = false;
+};
+
} // namespace
DataSourceSQLite::DataSourceSQLite(
@@ -199,7 +481,14 @@ mw::E<void> DataSourceSQLite::migrateSchema0To1(
mw::E<std::unique_ptr<DataSourceTransactionInterface>>
DataSourceSQLite::beginTransaction()
{
- return std::unexpected(notImplemented("transactions"));
+ std::unique_lock lock(mutex_);
+ auto begin = connection_->execute("BEGIN IMMEDIATE;");
+ if(!begin)
+ {
+ return std::unexpected(std::move(begin.error()));
+ }
+ return std::unique_ptr<DataSourceTransactionInterface>(
+ new DataSourceSQLiteTransaction(*connection_, std::move(lock)));
}
mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
@@ -273,9 +562,75 @@ mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
}
mw::E<std::optional<Card>> DataSourceSQLite::getCard(
- [[maybe_unused]] const CardIdentity& identity) const
+ const CardIdentity& identity) const
{
- return std::unexpected(notImplemented("card reads"));
+ std::lock_guard lock(mutex_);
+ auto statement = connection_->statementFromStr(
+ "SELECT id, game_short_name, card_number, name, "
+ "short_description, long_description, rarity, "
+ "front_extension, foil_extension, thumbnail_extension, "
+ "revision FROM cards WHERE "
+ "((? IS NULL AND game_short_name IS NULL) OR "
+ "game_short_name = ?) AND card_number = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(
+ identity.game_short_name,
+ identity.game_short_name,
+ static_cast<std::int64_t>(identity.card_number));
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto rows = connection_->eval<
+ std::int64_t,
+ std::optional<std::string>,
+ std::int64_t,
+ std::string,
+ std::optional<std::string>,
+ std::optional<std::string>,
+ std::int64_t,
+ std::string,
+ std::optional<std::string>,
+ std::string,
+ std::int64_t>(std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ if(rows->empty())
+ {
+ return std::optional<Card>{};
+ }
+
+ auto& [
+ id,
+ game_short_name,
+ card_number,
+ name,
+ short_description,
+ long_description,
+ rarity,
+ front_extension,
+ foil_extension,
+ thumbnail_extension,
+ revision] = rows->front();
+ Card card = {
+ id,
+ {std::move(game_short_name),
+ static_cast<std::uint64_t>(card_number)},
+ std::move(name),
+ std::move(short_description),
+ std::move(long_description),
+ rarity,
+ std::move(front_extension),
+ std::move(foil_extension),
+ std::move(thumbnail_extension),
+ revision,
+ };
+ return std::optional<Card>(std::move(card));
}
mw::E<std::vector<DisplayField>>
@@ -298,9 +653,33 @@ mw::E<std::optional<Series>> DataSourceSQLite::getSeries(
}
mw::E<std::vector<std::int64_t>> DataSourceSQLite::getCardSeries(
- [[maybe_unused]] std::int64_t card_id) const
+ std::int64_t card_id) const
{
- return std::unexpected(notImplemented("card-series reads"));
+ std::lock_guard lock(mutex_);
+ auto statement = connection_->statementFromStr(
+ "SELECT series_id FROM card_series "
+ "WHERE card_id = ? ORDER BY series_id;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind<std::int64_t>(card_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto rows = connection_->eval<std::int64_t>(std::move(*statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ std::vector<std::int64_t> result;
+ result.reserve(rows->size());
+ for(auto& [series_id] : *rows)
+ {
+ result.push_back(series_id);
+ }
+ return result;
}
mw::E<std::vector<std::string>>
diff --git a/src/image_processor.cpp b/src/image_processor.cpp
new file mode 100644
index 0000000..e5c7686
--- /dev/null
+++ b/src/image_processor.cpp
@@ -0,0 +1,275 @@
+#include "image_processor.h"
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <filesystem>
+#include <fstream>
+#include <limits>
+#include <list>
+#include <string>
+#include <string_view>
+#include <system_error>
+
+#include <Magick++.h>
+#include <spdlog/spdlog.h>
+
+namespace
+{
+
+inline constexpr std::uint32_t MAX_IMAGE_SIDE = 2048;
+
+struct ImageFormat
+{
+ std::string extension;
+ std::string coder;
+};
+
+mw::E<ImageFormat> sniffFormat(const std::filesystem::path& input)
+{
+ std::array<unsigned char, 32> bytes{};
+ std::ifstream stream(input, std::ios::binary);
+ if(!stream)
+ {
+ return std::unexpected(
+ mw::runtimeError("The uploaded image could not be opened"));
+ }
+ stream.read(
+ reinterpret_cast<char*>(bytes.data()),
+ static_cast<std::streamsize>(bytes.size()));
+ const std::size_t size = static_cast<std::size_t>(stream.gcount());
+
+ const std::array<unsigned char, 8> png = {
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a};
+ if(size >= png.size() &&
+ std::equal(png.begin(), png.end(), bytes.begin()))
+ {
+ return ImageFormat{"png", "PNG"};
+ }
+ if(size >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 &&
+ bytes[2] == 0xff)
+ {
+ return ImageFormat{"jpg", "JPEG"};
+ }
+ if(size >= 12 &&
+ std::string_view(
+ reinterpret_cast<const char*>(bytes.data()), 4) == "RIFF" &&
+ std::string_view(
+ reinterpret_cast<const char*>(bytes.data() + 8), 4) == "WEBP")
+ {
+ return ImageFormat{"webp", "WEBP"};
+ }
+ if(size >= 16 &&
+ std::string_view(
+ reinterpret_cast<const char*>(bytes.data() + 4), 4) == "ftyp")
+ {
+ for(std::size_t offset = 8; offset + 4 <= size; offset += 4)
+ {
+ const std::string_view brand(
+ reinterpret_cast<const char*>(bytes.data() + offset), 4);
+ if(brand == "avif" || brand == "avis")
+ {
+ return ImageFormat{"avif", "AVIF"};
+ }
+ }
+ }
+ return std::unexpected(mw::httpError(
+ 415,
+ "Card images must be JPEG, PNG, WebP, or AVIF files"));
+}
+
+std::string basename(CardAssetType type)
+{
+ switch(type)
+ {
+ case CardAssetType::FRONT_ART:
+ return "front-art";
+ case CardAssetType::FOIL_CONTROL:
+ return "foil";
+ case CardAssetType::THUMBNAIL:
+ return "thumb";
+ }
+ return "image";
+}
+
+mw::E<Magick::Image> decodeSingleImage(
+ const std::filesystem::path& input,
+ const ImageFormat& format)
+{
+ try
+ {
+ std::list<Magick::Image> images;
+ Magick::readImages(
+ &images, format.coder + ":" + input.string());
+ if(images.size() != 1)
+ {
+ return std::unexpected(mw::httpError(
+ 422,
+ "Animated and multi-frame images are not supported"));
+ }
+ Magick::Image image = std::move(images.front());
+ if(image.magick() != format.coder)
+ {
+ return std::unexpected(mw::httpError(
+ 415,
+ "The uploaded image does not match its file signature"));
+ }
+ if(image.columns() == 0 || image.rows() == 0 ||
+ image.columns() > MAX_IMAGE_SIDE ||
+ image.rows() > MAX_IMAGE_SIDE)
+ {
+ return std::unexpected(mw::httpError(
+ 422,
+ "Card images must be at most 2048 pixels on each side"));
+ }
+ return image;
+ }
+ catch(const Magick::Exception& error)
+ {
+ spdlog::warn("ImageMagick rejected an upload: {}", error.what());
+ return std::unexpected(mw::httpError(
+ 422,
+ "The uploaded image could not be decoded"));
+ }
+}
+
+mw::E<void> renameImage(
+ const std::filesystem::path& input,
+ const std::filesystem::path& output)
+{
+ std::error_code filesystem_error;
+ std::filesystem::rename(input, output, filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to normalize an uploaded image: " +
+ filesystem_error.message()));
+ }
+ return {};
+}
+
+} // namespace
+
+ImageProcessor::ImageProcessor(
+ int avif_quality,
+ std::uint32_t thumbnail_long_side)
+ : avif_quality_(avif_quality),
+ thumbnail_long_side_(thumbnail_long_side)
+{}
+
+mw::E<ProcessedImage> ImageProcessor::process(
+ const std::filesystem::path& input,
+ CardAssetType type) const
+{
+ auto format = sniffFormat(input);
+ if(!format)
+ {
+ return std::unexpected(std::move(format.error()));
+ }
+ auto decoded = decodeSingleImage(input, *format);
+ if(!decoded)
+ {
+ return std::unexpected(std::move(decoded.error()));
+ }
+ const bool convert_to_avif = format->extension == "png";
+ const std::string extension = convert_to_avif
+ ? "avif"
+ : format->extension;
+ const std::filesystem::path output =
+ input.parent_path() / (basename(type) + "." + extension);
+
+ if(convert_to_avif)
+ {
+ try
+ {
+ decoded->autoOrient();
+ decoded->colorSpace(Magick::sRGBColorspace);
+ decoded->strip();
+ decoded->quality(static_cast<std::size_t>(avif_quality_));
+ decoded->write("AVIF:" + output.string());
+ }
+ catch(const Magick::Exception& error)
+ {
+ std::error_code remove_error;
+ std::filesystem::remove(output, remove_error);
+ spdlog::error(
+ "ImageMagick failed to convert an uploaded PNG: {}",
+ error.what());
+ return std::unexpected(mw::runtimeError(
+ "Failed to process the uploaded image"));
+ }
+ std::error_code remove_error;
+ if(!std::filesystem::remove(input, remove_error) || remove_error)
+ {
+ std::filesystem::remove(output, remove_error);
+ return std::unexpected(mw::runtimeError(
+ "Failed to replace the converted upload"));
+ }
+ }
+ else if(input != output)
+ {
+ auto renamed = renameImage(input, output);
+ if(!renamed)
+ {
+ return std::unexpected(std::move(renamed.error()));
+ }
+ }
+
+ return ProcessedImage{
+ output,
+ extension,
+ static_cast<std::uint32_t>(decoded->columns()),
+ static_cast<std::uint32_t>(decoded->rows()),
+ decoded->alpha(),
+ };
+}
+
+mw::E<ProcessedImage> ImageProcessor::generatePlainThumbnail(
+ const ProcessedImage& artwork,
+ const std::filesystem::path& output) const
+{
+ auto format = sniffFormat(artwork.path);
+ if(!format)
+ {
+ return std::unexpected(std::move(format.error()));
+ }
+ auto decoded = decodeSingleImage(artwork.path, *format);
+ if(!decoded)
+ {
+ return std::unexpected(std::move(decoded.error()));
+ }
+
+ const std::uint64_t scaled_width =
+ static_cast<std::uint64_t>(thumbnail_long_side_) * 5 + 3;
+ const std::uint32_t width = static_cast<std::uint32_t>(scaled_width / 7);
+ try
+ {
+ decoded->autoOrient();
+ Magick::Geometry thumbnail_geometry(width, thumbnail_long_side_);
+ thumbnail_geometry.aspect(true);
+ decoded->resize(thumbnail_geometry);
+ decoded->colorSpace(Magick::sRGBColorspace);
+ decoded->strip();
+ decoded->quality(static_cast<std::size_t>(avif_quality_));
+ decoded->write("AVIF:" + output.string());
+ }
+ catch(const Magick::Exception& error)
+ {
+ std::error_code remove_error;
+ std::filesystem::remove(output, remove_error);
+ spdlog::error(
+ "ImageMagick failed to generate a card thumbnail: {}",
+ error.what());
+ return std::unexpected(mw::runtimeError(
+ "Failed to generate the card thumbnail"));
+ }
+
+ return ProcessedImage{
+ output,
+ "avif",
+ width,
+ thumbnail_long_side_,
+ decoded->alpha(),
+ };
+}
diff --git a/src/image_processor.h b/src/image_processor.h
new file mode 100644
index 0000000..0c362a7
--- /dev/null
+++ b/src/image_processor.h
@@ -0,0 +1,50 @@
+#pragma once
+
+#include <cstdint>
+#include <filesystem>
+#include <string>
+
+#include <mw/error.hpp>
+
+#include "card.h"
+
+/// Metadata for one validated, normalized card image.
+struct ProcessedImage
+{
+ /// Canonical path of the normalized image.
+ std::filesystem::path path;
+
+ /// Lowercase extension stored in the card row.
+ std::string extension;
+
+ /// Decoded image width in pixels.
+ std::uint32_t width;
+
+ /// Decoded image height in pixels.
+ std::uint32_t height;
+
+ /// Whether the decoded image contains an alpha channel.
+ bool has_alpha;
+};
+
+/// Validate and normalize untrusted card image uploads.
+class ImageProcessor
+{
+public:
+ /// Construct an image processor with output settings.
+ ImageProcessor(int avif_quality, std::uint32_t thumbnail_long_side);
+
+ /// Validate and normalize an uploaded image for its logical role.
+ mw::E<ProcessedImage> process(
+ const std::filesystem::path& input,
+ CardAssetType type) const;
+
+ /// Generate the fixed-size index thumbnail for a plain card.
+ mw::E<ProcessedImage> generatePlainThumbnail(
+ const ProcessedImage& artwork,
+ const std::filesystem::path& output) const;
+
+private:
+ int avif_quality_;
+ std::uint32_t thumbnail_long_side_;
+};
diff --git a/src/main.cpp b/src/main.cpp
index a145721..473b719 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,3 +1,4 @@
+#include <array>
#include <chrono>
#include <csignal>
#include <filesystem>
@@ -5,6 +6,7 @@
#include <thread>
#include <utility>
+#include <Magick++.h>
#include <spdlog/spdlog.h>
#include "app.h"
@@ -25,6 +27,40 @@ void handleSignal([[maybe_unused]] int signal)
STOP_REQUESTED = 1;
}
+mw::E<void> prepareImageMagick()
+{
+ const std::array<std::string, 4> coders = {
+ "JPEG", "PNG", "WEBP", "AVIF"};
+ try
+ {
+ for(const std::string& name : coders)
+ {
+ const Magick::CoderInfo coder(name);
+ if(!coder.isReadable())
+ {
+ return std::unexpected(mw::runtimeError(
+ "ImageMagick coder " + name + " is not readable"));
+ }
+ if(name == "AVIF" && !coder.isWritable())
+ {
+ return std::unexpected(mw::runtimeError(
+ "ImageMagick coder AVIF is not writable"));
+ }
+ }
+ }
+ catch(const Magick::Exception& error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to inspect ImageMagick capabilities: " +
+ std::string(error.what())));
+ }
+
+ Magick::ResourceLimits::listLength(2);
+ Magick::ResourceLimits::width(2048);
+ Magick::ResourceLimits::height(2048);
+ return {};
+}
+
mw::E<Config> makeDevelopmentConfig()
{
const std::string base_url_text =
@@ -61,8 +97,18 @@ mw::E<Config> makeDevelopmentConfig()
} // namespace
/// Start the Card Collection development server with its SQLite database.
-int main()
+int main(int argc, char** argv)
{
+ Magick::InitializeMagick(argc > 0 ? argv[0] : nullptr);
+ auto image_magick = prepareImageMagick();
+ if(!image_magick)
+ {
+ spdlog::error(
+ "Failed to initialize image processing: {}",
+ image_magick.error().msg());
+ return 1;
+ }
+
auto config = makeDevelopmentConfig();
if(!config)
{
diff --git a/src/multipart_reader.cpp b/src/multipart_reader.cpp
new file mode 100644
index 0000000..0a37ec9
--- /dev/null
+++ b/src/multipart_reader.cpp
@@ -0,0 +1,265 @@
+#include "multipart_reader.h"
+
+#include <atomic>
+#include <chrono>
+#include <cstddef>
+#include <cstdint>
+#include <filesystem>
+#include <fstream>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <system_error>
+#include <unordered_set>
+#include <utility>
+
+namespace
+{
+
+inline constexpr std::size_t MAX_TEXT_FIELD_SIZE = 1024 * 1024;
+inline constexpr std::size_t MAX_IMAGE_SIZE = 32 * 1024 * 1024;
+inline constexpr std::size_t MAX_TOTAL_IMAGE_SIZE = 72 * 1024 * 1024;
+
+std::atomic<std::uint64_t> NEXT_STAGING_ID = 0;
+
+bool isTextField(std::string_view name)
+{
+ static const std::unordered_set<std::string> names = {
+ "game",
+ "name",
+ "rarity",
+ "short_description",
+ "long_description",
+ "source_mode",
+ "front_url",
+ "foil_url",
+ "series_id",
+ };
+ return names.contains(std::string(name));
+}
+
+std::optional<std::string> stagedFilename(std::string_view name)
+{
+ if(name == "front")
+ {
+ return "upload_front";
+ }
+ if(name == "foil")
+ {
+ return "upload_foil";
+ }
+ if(name == "thumbnail")
+ {
+ return "upload_thumbnail";
+ }
+ return std::nullopt;
+}
+
+mw::E<std::filesystem::path> createStagingDirectory(
+ const std::filesystem::path& card_storage_root)
+{
+ const std::filesystem::path staging_root =
+ card_storage_root / ".staging";
+ std::error_code filesystem_error;
+ std::filesystem::create_directories(staging_root, filesystem_error);
+ if(filesystem_error)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to create the upload staging root: " +
+ filesystem_error.message()));
+ }
+
+ for(int attempt = 0; attempt < 32; ++attempt)
+ {
+ const auto ticks = std::chrono::steady_clock::now()
+ .time_since_epoch()
+ .count();
+ const std::uint64_t sequence = NEXT_STAGING_ID.fetch_add(1);
+ const std::filesystem::path directory = staging_root /
+ (std::to_string(ticks) + "-" + std::to_string(sequence));
+ filesystem_error.clear();
+ if(std::filesystem::create_directory(directory, filesystem_error))
+ {
+ return directory;
+ }
+ if(filesystem_error &&
+ filesystem_error != std::errc::file_exists)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Failed to create an upload staging directory: " +
+ filesystem_error.message()));
+ }
+ }
+ return std::unexpected(mw::runtimeError(
+ "Failed to allocate an upload staging directory"));
+}
+
+} // namespace
+
+MultipartReader::MultipartReader(std::filesystem::path card_storage_root)
+ : card_storage_root_(std::move(card_storage_root))
+{}
+
+MultipartReader::~MultipartReader()
+{
+ if(staging_directory_.empty())
+ {
+ return;
+ }
+ std::error_code filesystem_error;
+ std::filesystem::remove_all(staging_directory_, filesystem_error);
+}
+
+mw::E<CardUpload> MultipartReader::read(
+ const httplib::ContentReader& content_reader)
+{
+ auto staging = createStagingDirectory(card_storage_root_);
+ if(!staging)
+ {
+ return std::unexpected(std::move(staging.error()));
+ }
+ staging_directory_ = *staging;
+
+ CardUpload upload;
+ upload.staging_directory = staging_directory_;
+ std::ofstream binary_stream;
+ std::string* text_value = nullptr;
+ bool ignore_part = false;
+ std::size_t part_size = 0;
+ std::size_t total_image_size = 0;
+ std::string error_message;
+ int error_status = 400;
+
+ const bool read = content_reader(
+ [&](const httplib::FormData& part)
+ {
+ binary_stream.close();
+ text_value = nullptr;
+ ignore_part = false;
+ part_size = 0;
+
+ if(isTextField(part.name))
+ {
+ if(part.name == "series_id")
+ {
+ upload.series_ids.emplace_back();
+ text_value = &upload.series_ids.back();
+ return true;
+ }
+ auto [position, inserted] =
+ upload.fields.try_emplace(part.name);
+ if(!inserted)
+ {
+ error_message =
+ "The form field '" + part.name +
+ "' was supplied more than once";
+ return false;
+ }
+ text_value = &position->second;
+ return true;
+ }
+
+ const auto filename = stagedFilename(part.name);
+ if(!filename)
+ {
+ error_message =
+ "The form contains an unknown field: " + part.name;
+ return false;
+ }
+ if(part.filename.empty())
+ {
+ ignore_part = true;
+ return true;
+ }
+ std::optional<std::filesystem::path>* target = nullptr;
+ if(part.name == "front")
+ {
+ target = &upload.front;
+ }
+ else if(part.name == "foil")
+ {
+ target = &upload.foil;
+ }
+ else
+ {
+ target = &upload.thumbnail;
+ }
+ if(*target)
+ {
+ error_message =
+ "The image field '" + part.name +
+ "' was supplied more than once";
+ return false;
+ }
+ *target = staging_directory_ / *filename;
+ binary_stream.open(**target, std::ios::binary);
+ if(!binary_stream)
+ {
+ error_message = "Failed to stage an uploaded image";
+ error_status = 500;
+ return false;
+ }
+ return true;
+ },
+ [&](const char* data, std::size_t size)
+ {
+ part_size += size;
+ if(ignore_part)
+ {
+ total_image_size += size;
+ if(part_size > MAX_IMAGE_SIZE ||
+ total_image_size > MAX_TOTAL_IMAGE_SIZE)
+ {
+ error_message = "An uploaded image is too large";
+ error_status = 413;
+ return false;
+ }
+ return true;
+ }
+ if(text_value != nullptr)
+ {
+ if(part_size > MAX_TEXT_FIELD_SIZE)
+ {
+ error_message = "A form field is too large";
+ error_status = 413;
+ return false;
+ }
+ text_value->append(data, size);
+ return true;
+ }
+ if(!binary_stream)
+ {
+ error_message = "The multipart request is malformed";
+ return false;
+ }
+ total_image_size += size;
+ if(part_size > MAX_IMAGE_SIZE ||
+ total_image_size > MAX_TOTAL_IMAGE_SIZE)
+ {
+ error_message = "An uploaded image is too large";
+ error_status = 413;
+ return false;
+ }
+ binary_stream.write(
+ data, static_cast<std::streamsize>(size));
+ if(!binary_stream)
+ {
+ error_message = "Failed to stage an uploaded image";
+ error_status = 500;
+ return false;
+ }
+ return true;
+ });
+ binary_stream.close();
+
+ if(!read)
+ {
+ if(error_message.empty())
+ {
+ error_message = "Failed to read the multipart request";
+ }
+ return std::unexpected(mw::httpError(
+ error_status, error_message));
+ }
+ return upload;
+}
diff --git a/src/multipart_reader.h b/src/multipart_reader.h
new file mode 100644
index 0000000..9e9c221
--- /dev/null
+++ b/src/multipart_reader.h
@@ -0,0 +1,56 @@
+#pragma once
+
+#include <filesystem>
+#include <optional>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include <httplib.h>
+#include <mw/error.hpp>
+
+/// Streamed fields and server-owned files from one card upload.
+struct CardUpload
+{
+ /// Private directory containing the staged binary fields.
+ std::filesystem::path staging_directory;
+
+ /// Singleton text fields indexed by their form names.
+ std::unordered_map<std::string, std::string> fields;
+
+ /// Repeated series membership values in request order.
+ std::vector<std::string> series_ids;
+
+ /// Staged front artwork, when supplied.
+ std::optional<std::filesystem::path> front;
+
+ /// Staged foil-control image, when supplied.
+ std::optional<std::filesystem::path> foil;
+
+ /// Staged browser-rendered thumbnail, when supplied.
+ std::optional<std::filesystem::path> thumbnail;
+};
+
+/// Stream a multipart card request into a private staging directory.
+class MultipartReader
+{
+public:
+ /// Construct a reader rooted below the private card storage directory.
+ explicit MultipartReader(std::filesystem::path card_storage_root);
+
+ /// Remove any staging directory which has not been published.
+ ~MultipartReader();
+
+ /// Prevent two readers from owning the same staging directory.
+ MultipartReader(const MultipartReader&) = delete;
+
+ /// Prevent reassignment of staging-directory cleanup ownership.
+ MultipartReader& operator=(const MultipartReader&) = delete;
+
+ /// Parse and stream one multipart request.
+ mw::E<CardUpload> read(const httplib::ContentReader& content_reader);
+
+private:
+ std::filesystem::path card_storage_root_;
+ std::filesystem::path staging_directory_;
+};
diff --git a/static/card_form.js b/static/card_form.js
index 080bcc1..bced84a 100644
--- a/static/card_form.js
+++ b/static/card_form.js
@@ -28,6 +28,123 @@ function updateFileMetadata(input, output)
output.textContent = file == null ? "No file selected" : file.name;
}
+/** Put one browser Blob into a file input for multipart submission. */
+function setInputFile(input, blob, filename)
+{
+ const transfer = new DataTransfer();
+ transfer.items.add(new File([blob], filename, {type: blob.type}));
+ input.files = transfer.files;
+}
+
+/** Refresh the WebGL preview from the currently selected local files. */
+function updateLocalPreview()
+{
+ const preview = window.cardPreview;
+ if(preview == null)
+ {
+ return Promise.resolve();
+ }
+ const front = document.getElementById("CardFrontFile").files[0];
+ const foil = document.getElementById("CardFoilFile").files[0];
+ return preview.setFiles(front, foil);
+}
+
+/** 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:")
+ {
+ throw(new Error("Image URLs must use HTTP or HTTPS."));
+ }
+ const response = await fetch(url, {
+ mode: "cors",
+ credentials: "omit",
+ });
+ if(!response.ok)
+ {
+ throw(new Error(
+ `Failed to fetch ${url}: HTTP ${response.status}`));
+ }
+ setInputFile(file_input, await response.blob(), filename);
+}
+
+/** Prepare URL images and the foil thumbnail before native submission. */
+async function prepareCardSubmission(event)
+{
+ const form = event.currentTarget;
+ if(form.dataset.prepared == "true")
+ {
+ return;
+ }
+ event.preventDefault();
+ if(form.dataset.state == "preparing")
+ {
+ return;
+ }
+ form.dataset.state = "preparing";
+ const submitter = event.submitter;
+ const submit_button = form.querySelector('button[type="submit"]');
+ submit_button.disabled = true;
+ const status = document.getElementById("CardFormStatus");
+ status.textContent = "Preparing card images…";
+
+ try
+ {
+ const mode = document.querySelector(
+ 'input[name="source_mode"]:checked').value;
+ const front_input = document.getElementById("CardFrontFile");
+ const foil_input = document.getElementById("CardFoilFile");
+ if(mode == "urls")
+ {
+ foil_input.value = "";
+ await fetchImageInput(
+ document.getElementById("CardFrontUrl"),
+ front_input,
+ "front-upload");
+ const foil_url = document.getElementById("CardFoilUrl");
+ if(foil_url.value != "")
+ {
+ await fetchImageInput(foil_url, foil_input, "foil-upload");
+ }
+ front_input.disabled = false;
+ foil_input.disabled = false;
+ }
+
+ await updateLocalPreview();
+ if(foil_input.files.length != 0)
+ {
+ if(window.cardPreview == null)
+ {
+ throw(new Error("The foil preview is not ready."));
+ }
+ const thumbnail = await window.cardPreview.captureThumbnail();
+ const thumbnail_input = document.getElementById(
+ "CardThumbnailFile");
+ setInputFile(thumbnail_input, thumbnail, "thumbnail.png");
+ thumbnail_input.disabled = false;
+ }
+ form.dataset.prepared = "true";
+ form.dataset.state = "ready";
+ submit_button.disabled = false;
+ if(submitter == null)
+ {
+ form.requestSubmit();
+ }
+ else
+ {
+ form.requestSubmit(submitter);
+ }
+ }
+ catch(error)
+ {
+ console.error(error);
+ status.textContent = error.message;
+ form.dataset.state = "idle";
+ submit_button.disabled = false;
+ }
+}
+
/** Connect the image-source controls after the form is available. */
function initializeCardForm()
{
@@ -44,13 +161,17 @@ function initializeCardForm()
updateFileMetadata(
front_input,
document.getElementById("CardFrontFileMeta"));
+ updateLocalPreview().catch(console.error);
});
foil_input.addEventListener("change", function updateFoilMetadata()
{
updateFileMetadata(
foil_input,
document.getElementById("CardFoilFileMeta"));
+ updateLocalPreview().catch(console.error);
});
+ document.getElementById("CardForm").addEventListener(
+ "submit", prepareCardSubmission);
updateSourceMode();
}
diff --git a/static/foil/card_preview.js b/static/foil/card_preview.js
index 0beb3bd..9982ab8 100644
--- a/static/foil/card_preview.js
+++ b/static/foil/card_preview.js
@@ -21,7 +21,9 @@ class ArtworkMaterial
constructor(gl, artwork_url)
{
this.gl = gl;
- this.artwork = new Texture(gl, artwork_url, {flip_y: true});
+ this.artwork = artwork_url instanceof Texture
+ ? artwork_url
+ : new Texture(gl, artwork_url, {flip_y: true});
this.ready = this.artwork.ready;
}
@@ -122,6 +124,83 @@ 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)
+{
+ return new Promise(function encode(resolve, reject)
+ {
+ canvas.toBlob(function finish(blob)
+ {
+ if(blob == null)
+ {
+ reject(new Error("Failed to encode the card thumbnail."));
+ return;
+ }
+ resolve(blob);
+ }, "image/png");
+ });
+}
+
/** Internal compile-time shader variants for renderer quality. */
const RENDER_QUALITY = Object.freeze({
LOW: Object.freeze({
@@ -209,11 +288,122 @@ function main()
});
const light = new DiskLight(
- [0, 0, 2.0],
- [0, 0, -2.0],
+ [0, 1.0, 2.0],
+ [0, -1.0, -2.0],
1,
3.6);
const camera_rotation = [0.0, 0.0];
+ let material_update = Promise.resolve();
+
+ /** Replace the preview material with selected local image files. */
+ function setFiles(front_file, foil_file)
+ {
+ if(front_file == null)
+ {
+ return material_update;
+ }
+ material_update = material_update.catch(function recover()
+ {
+ return undefined;
+ }).then(async function update()
+ {
+ const old_material = card.front_material;
+ let new_material = null;
+ if(foil_file == null)
+ {
+ const artwork = await Texture.fromFile(
+ gl, front_file, {flip_y: true});
+ new_material = new ArtworkMaterial(gl, artwork);
+ }
+ else
+ {
+ new_material = await PhysicalFoilMaterial.fromFiles(
+ gl,
+ front_file,
+ foil_file,
+ [0, 0, 0, 255],
+ page_data.spectral_lut_url);
+ }
+ await new_material.ready;
+ for(const model of card.scene.models)
+ {
+ if(model.material == old_material)
+ {
+ model.material = new_material;
+ }
+ }
+ card.front_material = new_material;
+ old_material.dispose();
+ showPreviewStatus(
+ foil_file == null ? "Standard finish" : "Foil finish",
+ "success");
+ }).catch(function reportUpdateFailure(error)
+ {
+ console.error(error);
+ showPreviewStatus(error.message, "error");
+ throw(error);
+ });
+ return material_update;
+ }
+
+ /** Capture a cropped neutral PNG after pending textures load. */
+ async function captureThumbnail(
+ long_side = page_data.thumbnail_long_side)
+ {
+ await material_update;
+ const previous_rotation = camera_rotation.slice();
+ camera_rotation[0] = 0.0;
+ camera_rotation[1] = 0.0;
+ try
+ {
+ renderer.draw(
+ card.scene,
+ calculateMatrices(canvas, camera_rotation),
+ light);
+
+ const pixels = new Uint8Array(
+ canvas.width * canvas.height * 4);
+ gl.readPixels(
+ 0,
+ 0,
+ canvas.width,
+ canvas.height,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ pixels);
+ const bounds = findAlphaBounds(
+ pixels, canvas.width, canvas.height);
+ if(bounds == null)
+ {
+ throw(new Error(
+ "The card preview is fully transparent."));
+ }
+ const cropped = cropWebGLPixels(
+ pixels, canvas.width, bounds);
+ const source_canvas = document.createElement("canvas");
+ source_canvas.width = cropped.width;
+ source_canvas.height = cropped.height;
+ source_canvas.getContext("2d").putImageData(
+ cropped, 0, 0);
+
+ const scaled = scaleDimensions(
+ cropped.width, cropped.height, long_side);
+ const output_canvas = document.createElement("canvas");
+ output_canvas.width = scaled.width;
+ output_canvas.height = scaled.height;
+ output_canvas.getContext("2d").drawImage(
+ source_canvas, 0, 0, scaled.width, scaled.height);
+ return await canvasPng(output_canvas);
+ }
+ finally
+ {
+ camera_rotation[0] = previous_rotation[0];
+ camera_rotation[1] = previous_rotation[1];
+ }
+ }
+
+ /** Form-facing preview controls for local files and thumbnails. */
+ window.cardPreview = {setFiles, captureThumbnail};
/** Draw one animation frame using the latest pointer rotation. */
function render()
diff --git a/static/foil/libwebgl.js b/static/foil/libwebgl.js
index 9163324..4739f81 100644
--- a/static/foil/libwebgl.js
+++ b/static/foil/libwebgl.js
@@ -515,6 +515,11 @@ class SpectralLut
this.pending_reported = false;
this.url = url;
this.gl = gl;
+ this.ready = new Promise(function storeCompletion(resolve, reject)
+ {
+ this.resolve_load = resolve;
+ this.reject_load = reject;
+ }.bind(this));
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.texture);
@@ -541,8 +546,9 @@ class SpectralLut
if(request.status != 200 && request.status != 0)
{
this.failed = true;
- throw(new Error(
+ this.reject_load(new Error(
`Failed to load ${this.url}: HTTP ${request.status}`));
+ return;
}
if(request.response == null || request.response.byteLength != 6416)
{
@@ -550,9 +556,10 @@ class SpectralLut
const actual_size = request.response == null
? 0
: request.response.byteLength;
- throw(new Error(
+ this.reject_load(new Error(
`Malformed ${this.url}: expected 6416 bytes, got `
+ actual_size));
+ return;
}
const gl = this.gl;
@@ -561,6 +568,7 @@ class SpectralLut
gl.RGBA, gl.FLOAT,
new Float32Array(request.response));
this.loaded = true;
+ this.resolve_load(this);
for(const listener of this.load_listeners)
{
listener(this);
@@ -571,7 +579,8 @@ class SpectralLut
handleError()
{
this.failed = true;
- throw(new Error(`Failed to load spectral table: ${this.url}`));
+ this.reject_load(new Error(
+ `Failed to load spectral table: ${this.url}`));
}
/** Run a callback after successful validation and upload. */
@@ -632,6 +641,7 @@ class PhysicalFoilMaterial
this.ready = Promise.all([
this.artwork.ready,
this.foil_control.ready,
+ this.spectral_lut.ready,
]).then(function finishLoadedMaterial()
{
return this;
diff --git a/templates/card_form.html b/templates/card_form.html
index 2f2476b..9065b24 100644
--- a/templates/card_form.html
+++ b/templates/card_form.html
@@ -56,6 +56,8 @@
</div>
<div id="FileSourceFields" class="source-fields">
+ <input id="CardThumbnailFile" name="thumbnail"
+ type="file" hidden disabled>
<label class="form-field image-field"
for="CardFrontFile">
<span>Front artwork</span>
@@ -68,7 +70,7 @@
for="CardFoilFile">
<span>Foil control <em>Optional</em></span>
<input id="CardFoilFile" name="foil" type="file"
- accept="image/png,image/webp,image/avif">
+ accept="image/png,image/jpeg,image/webp,.avif">
<small id="CardFoilFileMeta">No file selected</small>
</label>
</div>
diff --git a/tests/card_service_test.cpp b/tests/card_service_test.cpp
new file mode 100644
index 0000000..0b3eb51
--- /dev/null
+++ b/tests/card_service_test.cpp
@@ -0,0 +1,268 @@
+#include "card_service.h"
+#include "data_sqlite.h"
+#include "game_registry.h"
+#include "multipart_reader.h"
+#include "startup.h"
+
+#include <chrono>
+#include <filesystem>
+#include <fstream>
+#include <iterator>
+#include <memory>
+#include <optional>
+#include <string>
+#include <system_error>
+
+#include <Magick++.h>
+#include <gtest/gtest.h>
+
+namespace
+{
+
+class TemporaryCardRoot
+{
+public:
+ /// Allocate a unique temporary root for a card-service test.
+ TemporaryCardRoot()
+ : path_(
+ std::filesystem::path(testing::TempDir()) /
+ ("card_service_" + std::to_string(
+ std::chrono::steady_clock::now()
+ .time_since_epoch().count())))
+ {
+ std::filesystem::create_directories(path_ / ".staging/upload");
+ }
+
+ /// Remove all temporary database and asset files.
+ ~TemporaryCardRoot()
+ {
+ std::error_code error;
+ std::filesystem::remove_all(path_, error);
+ }
+
+ /// Return the temporary root.
+ const std::filesystem::path& path() const
+ {
+ return path_;
+ }
+
+private:
+ std::filesystem::path path_;
+};
+
+void initializeImageMagick()
+{
+ static const bool initialized = []
+ {
+ Magick::InitializeMagick(nullptr);
+ Magick::ResourceLimits::listLength(2);
+ Magick::ResourceLimits::width(2048);
+ Magick::ResourceLimits::height(2048);
+ return true;
+ }();
+ ASSERT_TRUE(initialized);
+}
+
+void writePng(const std::filesystem::path& path)
+{
+ Magick::Image image(Magick::Geometry(350, 490), Magick::Color("navy"));
+ image.write("PNG:" + path.string());
+}
+
+void writeJpeg(const std::filesystem::path& path)
+{
+ Magick::Image image(Magick::Geometry(350, 490), Magick::Color("navy"));
+ image.write("JPEG:" + path.string());
+}
+
+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>());
+}
+
+} // namespace
+
+/// Verify card creation publishes normalized assets with its committed row.
+TEST(CardServiceTest, CreatesLooseCard)
+{
+ 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);
+
+ CardService service(
+ **data_source,
+ ImageProcessor(75, 256),
+ AssetStore(temporary.path()));
+ auto public_id = service.createLooseCard({
+ "Moon card",
+ std::optional<std::string>("Short"),
+ std::nullopt,
+ 4,
+ staging,
+ front,
+ std::nullopt,
+ std::nullopt,
+ });
+
+ ASSERT_TRUE(public_id) << public_id.error().msg();
+ auto cards = (*data_source)->getCards();
+ ASSERT_TRUE(cards);
+ ASSERT_EQ(cards->size(), 1);
+ EXPECT_EQ(cards->front().name, "Moon card");
+ EXPECT_EQ(cards->front().front_extension, "avif");
+ EXPECT_EQ(cards->front().thumbnail_extension, "avif");
+ auto stored_card = (*data_source)->getCard(cards->front().identity);
+ ASSERT_TRUE(stored_card);
+ ASSERT_TRUE(*stored_card);
+ EXPECT_EQ((**stored_card).id, cards->front().id);
+ auto memberships = (*data_source)->getCardSeries(cards->front().id);
+ ASSERT_TRUE(memberships);
+ EXPECT_TRUE(memberships->empty());
+ const std::filesystem::path published =
+ temporary.path() / "published" / *public_id;
+ EXPECT_TRUE(
+ std::filesystem::is_regular_file(published / "front-art.avif"));
+ EXPECT_TRUE(std::filesystem::is_regular_file(published / "thumb.avif"));
+ EXPECT_FALSE(std::filesystem::exists(staging));
+}
+
+/// Verify invalid image bytes leave both persistence domains unchanged.
+TEST(CardServiceTest, RejectsInvalidImage)
+{
+ 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";
+ {
+ std::ofstream output(front, std::ios::binary);
+ output << "not an image";
+ }
+ CardService service(
+ **data_source,
+ ImageProcessor(75, 256),
+ AssetStore(temporary.path()));
+ auto public_id = service.createLooseCard({
+ "Bad card",
+ std::nullopt,
+ std::nullopt,
+ 0,
+ staging,
+ front,
+ std::nullopt,
+ std::nullopt,
+ });
+
+ EXPECT_FALSE(public_id);
+ auto cards = (*data_source)->getCards();
+ ASSERT_TRUE(cards);
+ EXPECT_TRUE(cards->empty());
+ EXPECT_FALSE(std::filesystem::exists(temporary.path() / "published"));
+}
+
+/// Verify an opaque JPEG is accepted as a foil-control texture.
+TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
+{
+ 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";
+ const std::filesystem::path foil = staging / "upload_foil";
+ const std::filesystem::path thumbnail = staging / "upload_thumbnail";
+ writeJpeg(front);
+ writeJpeg(foil);
+ writePng(thumbnail);
+
+ CardService service(
+ **data_source,
+ ImageProcessor(75, 256),
+ AssetStore(temporary.path()));
+ auto public_id = service.createLooseCard({
+ "Opaque foil",
+ std::nullopt,
+ std::nullopt,
+ 0,
+ staging,
+ front,
+ foil,
+ thumbnail,
+ });
+
+ ASSERT_TRUE(public_id) << public_id.error().msg();
+ auto cards = (*data_source)->getCards();
+ ASSERT_TRUE(cards);
+ ASSERT_EQ(cards->size(), 1);
+ EXPECT_EQ(cards->front().foil_extension, "jpg");
+ const std::filesystem::path published =
+ temporary.path() / "published" / *public_id;
+ EXPECT_TRUE(std::filesystem::is_regular_file(published / "foil.jpg"));
+}
+
+/// Verify multipart binaries use server paths and empty file controls vanish.
+TEST(MultipartReaderTest, StreamsExpectedFields)
+{
+ initializeImageMagick();
+ TemporaryCardRoot temporary;
+ const std::filesystem::path source = temporary.path() / "source.png";
+ writePng(source);
+ const std::string image_bytes = readFile(source);
+
+ const httplib::ContentReader content_reader(
+ [](httplib::ContentReceiver)
+ {
+ return false;
+ },
+ [&](httplib::FormDataHeader header,
+ httplib::ContentReceiver receiver)
+ {
+ httplib::FormData name;
+ name.name = "name";
+ if(!header(name) || !receiver("Streamed card", 13))
+ {
+ return false;
+ }
+ httplib::FormData front;
+ front.name = "front";
+ front.filename = "../../browser-name.png";
+ if(!header(front) ||
+ !receiver(image_bytes.data(), image_bytes.size()))
+ {
+ return false;
+ }
+ httplib::FormData empty_foil;
+ empty_foil.name = "foil";
+ return header(empty_foil);
+ });
+
+ MultipartReader reader(temporary.path());
+ auto upload = reader.read(content_reader);
+
+ ASSERT_TRUE(upload);
+ EXPECT_EQ(upload->fields.at("name"), "Streamed card");
+ ASSERT_TRUE(upload->front);
+ EXPECT_EQ(upload->front->filename(), "upload_front");
+ EXPECT_EQ(readFile(*upload->front), image_bytes);
+ EXPECT_FALSE(upload->foil);
+}
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
index 4990556..00a9c9b 100644
--- a/tests/data_sqlite_test.cpp
+++ b/tests/data_sqlite_test.cpp
@@ -3,9 +3,11 @@
#include "startup.h"
#include <chrono>
+#include <cstdint>
#include <filesystem>
#include <memory>
#include <string>
+#include <utility>
#include <gtest/gtest.h>
@@ -43,6 +45,22 @@ private:
std::filesystem::path path_;
};
+Card makeLooseCard(std::uint32_t number, std::string name)
+{
+ return {
+ 0,
+ {std::nullopt, number},
+ std::move(name),
+ std::nullopt,
+ std::nullopt,
+ 0,
+ "jpg",
+ std::nullopt,
+ "avif",
+ 1,
+ };
+}
+
} // namespace
/// Verify the SQLite factory owns an opened connection.
@@ -62,11 +80,8 @@ TEST(DataSourceSQLiteTest, ReportsPlaceholderOperations)
std::unique_ptr<DataSourceSQLite> data_source =
std::move(*data_source_result);
- EXPECT_FALSE(data_source->beginTransaction());
- EXPECT_FALSE(data_source->getCard({std::nullopt, 1}));
EXPECT_FALSE(data_source->getSeries());
EXPECT_FALSE(data_source->getSeries(1));
- EXPECT_FALSE(data_source->getCardSeries(1));
EXPECT_FALSE(data_source->getPersistedGameNames());
}
@@ -143,6 +158,12 @@ TEST(DataSourceSQLiteTest, ReturnsCards)
EXPECT_EQ((*cards)[1].foil_extension, "webp");
EXPECT_EQ((*cards)[1].thumbnail_extension, "avif");
EXPECT_EQ((*cards)[1].revision, 3);
+
+ auto loose_card = (*data_source)->getCard({std::nullopt, 35});
+ ASSERT_TRUE(loose_card);
+ ASSERT_TRUE(*loose_card);
+ EXPECT_EQ((**loose_card).id, 1);
+ EXPECT_EQ((**loose_card).name, "Loose card");
}
/// Verify invalid persisted card identities fail the complete read.
@@ -166,3 +187,100 @@ TEST(DataSourceSQLiteTest, RejectsInvalidCards)
ASSERT_TRUE(data_source);
EXPECT_FALSE((*data_source)->getCards());
}
+
+/// Verify a committed transaction persists a complete loose card.
+TEST(DataSourceSQLiteTest, InsertsLooseCard)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+ auto data_source = prepareDataSource(database.path(), games);
+ ASSERT_TRUE(data_source);
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+
+ auto exists_before = (*transaction)->looseNumberExists(42);
+ ASSERT_TRUE(exists_before);
+ EXPECT_FALSE(*exists_before);
+ auto card_id = (*transaction)->insertCard(
+ makeLooseCard(42, "New card"), nullptr, nullptr, {});
+ ASSERT_TRUE(card_id);
+ EXPECT_GT(*card_id, 0);
+ auto exists_after = (*transaction)->looseNumberExists(42);
+ ASSERT_TRUE(exists_after);
+ EXPECT_TRUE(*exists_after);
+ ASSERT_TRUE((*transaction)->commit());
+
+ auto cards = (*data_source)->getCards();
+ ASSERT_TRUE(cards);
+ ASSERT_EQ(cards->size(), 1);
+ EXPECT_EQ(cards->front().id, *card_id);
+ EXPECT_EQ(cards->front().identity.card_number, 42);
+ EXPECT_EQ(cards->front().name, "New card");
+}
+
+/// Verify destroying an uncommitted transaction rolls card insertion back.
+TEST(DataSourceSQLiteTest, RollsBackLooseCard)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+ auto data_source = prepareDataSource(database.path(), games);
+ ASSERT_TRUE(data_source);
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->insertCard(
+ makeLooseCard(91, "Temporary card"), nullptr, nullptr, {}));
+
+ transaction->reset();
+
+ auto cards = (*data_source)->getCards();
+ ASSERT_TRUE(cards);
+ EXPECT_TRUE(cards->empty());
+}
+
+/// Verify game sequence numbers increase and survive committed transactions.
+TEST(DataSourceSQLiteTest, AllocatesGameNumbers)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+ auto data_source = prepareDataSource(database.path(), games);
+ ASSERT_TRUE(data_source);
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->ensureGameSequence("pkm"));
+
+ auto first = (*transaction)->allocateGameNumber("pkm");
+ auto second = (*transaction)->allocateGameNumber("pkm");
+ ASSERT_TRUE(first);
+ ASSERT_TRUE(second);
+ EXPECT_EQ(*first, 1);
+ EXPECT_EQ(*second, 2);
+ ASSERT_TRUE((*transaction)->commit());
+
+ auto next_transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(next_transaction);
+ auto third = (*next_transaction)->allocateGameNumber("pkm");
+ ASSERT_TRUE(third);
+ EXPECT_EQ(*third, 3);
+}
+
+/// Verify the loose-card unique index rejects a repeated number.
+TEST(DataSourceSQLiteTest, RejectsDuplicateLooseNumber)
+{
+ TemporaryDatabase database;
+ GameRegistry games;
+ auto data_source = prepareDataSource(database.path(), games);
+ ASSERT_TRUE(data_source);
+ auto first_transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(first_transaction);
+ ASSERT_TRUE((*first_transaction)->insertCard(
+ makeLooseCard(8, "First"), nullptr, nullptr, {}));
+ ASSERT_TRUE((*first_transaction)->commit());
+
+ auto second_transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(second_transaction);
+ auto exists = (*second_transaction)->looseNumberExists(8);
+ ASSERT_TRUE(exists);
+ EXPECT_TRUE(*exists);
+ EXPECT_FALSE((*second_transaction)->insertCard(
+ makeLooseCard(8, "Duplicate"), nullptr, nullptr, {}));
+}