#include "app.h"
#include <algorithm>
#include <charconv>
#include <cctype>
#include <cstddef>
#include <cstdlib>
#include <filesystem>
#include <functional>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <system_error>
#include <unordered_map>
#include <utility>
#include <vector>
#include <spdlog/spdlog.h>
#include <mw/crypto.hpp>
#include <mw/utils.hpp>
#include "email_sender_file.h"
#include "email_sender_mailjet.h"
#include "form_limits.h"
#include "game_field.h"
#include "public_id.h"
#include "multipart_reader.h"
#include "secret_token.h"
namespace
{
enum class RouteKind
{
DYNAMIC,
STATIC_MOUNT
};
struct RouteDefinition
{
RouteKind kind;
std::vector<RouteSegment> segments;
};
struct IndexCard
{
const Card* card;
std::string public_id;
};
struct HtmlSubstitution
{
std::string marker;
RenderedHtml html;
};
std::unique_ptr<EmailSenderInterface> makeEmailSender(const Config& config)
{
if(config.email.transport == EmailTransport::FILE)
{
std::filesystem::path target = config.email.link_file;
if(target.empty())
{
target = config.database_path.parent_path() /
"latest-authentication-link.txt";
}
return std::make_unique<FileEmailSender>(std::move(target));
}
const char* api_key = std::getenv(
config.email.mailjet_api_key_environment.c_str());
const char* secret_key = std::getenv(
config.email.mailjet_secret_key_environment.c_str());
if(api_key == nullptr || *api_key == '\0' ||
secret_key == nullptr || *secret_key == '\0')
{
throw std::runtime_error(
"Mailjet credential environment variables are missing");
}
auto sender = std::make_unique<MailjetEmailSender>(
std::make_unique<mw::HTTPSession>(),
config.email.from_address,
config.email.from_name,
api_key,
secret_key);
auto configured = sender->configure();
if(!configured)
{
throw std::runtime_error(
"Failed to configure Mailjet transport: " +
configured.error().msg());
}
return sender;
}
std::optional<std::string> cookieValue(
const App::Request& request, std::string_view name)
{
std::optional<std::string> result;
const std::size_t header_count = request.get_header_value_count("Cookie");
for(std::size_t header_index = 0;
header_index < header_count;
++header_index)
{
const std::string header_value = request.get_header_value(
"Cookie", nullptr, header_index);
std::string_view header = header_value;
while(!header.empty())
{
const std::size_t separator = header.find(';');
std::string_view item = header.substr(0, separator);
item = mw::strip(item);
const std::size_t equals = item.find('=');
if(equals != std::string_view::npos &&
item.substr(0, equals) == name)
{
if(result)
{
return std::nullopt;
}
result = std::string(item.substr(equals + 1));
}
if(separator == std::string_view::npos)
{
break;
}
header.remove_prefix(separator + 1);
}
}
return result;
}
RouteSegment literal(std::string value)
{
return {RouteSegmentKind::LITERAL, std::move(value)};
}
RouteSegment placeholder(std::string value)
{
return {RouteSegmentKind::PLACEHOLDER, std::move(value)};
}
const std::unordered_map<std::string, RouteDefinition> ROUTES = {
{"welcome", {RouteKind::DYNAMIC, {}}},
{"authentication",
{RouteKind::DYNAMIC, {literal("authentication")}}},
{"authentication-email",
{RouteKind::DYNAMIC,
{literal("authentication"), literal("email")}}},
{"authentication-sent",
{RouteKind::DYNAMIC,
{literal("authentication"), literal("sent")}}},
{"authentication-confirm",
{RouteKind::DYNAMIC,
{literal("authentication"), literal("confirm"), placeholder("token")}}},
{"logout", {RouteKind::DYNAMIC, {literal("logout")}}},
{"onboarding",
{RouteKind::DYNAMIC,
{literal("onboarding"), literal("username")}}},
{"account", {RouteKind::DYNAMIC, {literal("account")}}},
{"account-username",
{RouteKind::DYNAMIC,
{literal("account"), literal("username")}}},
{"collection", {RouteKind::DYNAMIC, {literal("collection")}}},
{"collection-pull",
{RouteKind::DYNAMIC,
{literal("collection"), literal("pull")}}},
{"creator-cards",
{RouteKind::DYNAMIC,
{literal("creator"), literal("cards")}}},
{"card-index",
{RouteKind::DYNAMIC, {literal("admin"), literal("cards")}}},
{"admin-users",
{RouteKind::DYNAMIC, {literal("admin"), literal("users")}}},
{"admin-games",
{RouteKind::DYNAMIC, {literal("admin"), literal("games")}}},
{"game-new",
{RouteKind::DYNAMIC,
{literal("admin"), literal("games"), literal("new")}}},
{"games",
{RouteKind::DYNAMIC, {literal("admin"), literal("games")}}},
{"game-edit",
{RouteKind::DYNAMIC,
{literal("admin"), literal("games"), placeholder("short"),
literal("edit")}}},
{"game-update",
{RouteKind::DYNAMIC,
{literal("admin"), literal("games"), placeholder("short")}}},
{"game-delete",
{RouteKind::DYNAMIC,
{literal("admin"), literal("games"), placeholder("short"),
literal("delete")}}},
{"game-field-new",
{RouteKind::DYNAMIC,
{literal("admin"), literal("games"), placeholder("short"),
literal("fields"), literal("new")}}},
{"game-fields",
{RouteKind::DYNAMIC,
{literal("admin"), literal("games"), placeholder("short"),
literal("fields")}}},
{"game-field-edit",
{RouteKind::DYNAMIC,
{literal("admin"), literal("games"), placeholder("short"),
literal("fields"), placeholder("integer"), literal("edit")}}},
{"game-field-update",
{RouteKind::DYNAMIC,
{literal("admin"), literal("games"), placeholder("short"),
literal("fields"), placeholder("integer")}}},
{"game-field-delete",
{RouteKind::DYNAMIC,
{literal("admin"), literal("games"), placeholder("short"),
literal("fields"), placeholder("integer"), literal("delete")}}},
{"game-field-order",
{RouteKind::DYNAMIC,
{literal("admin"), literal("games"), placeholder("short"),
literal("field-order")}}},
{"admin-promote",
{RouteKind::DYNAMIC,
{literal("admin"), literal("users"), placeholder("integer"),
literal("promote")}}},
{"card-new", {RouteKind::DYNAMIC, {literal("cards"), literal("new")}}},
{"cards", {RouteKind::DYNAMIC, {literal("cards")}}},
{"card", {RouteKind::DYNAMIC,
{literal("cards"), placeholder("id")}}},
{"card-edit", {RouteKind::DYNAMIC,
{literal("cards"), placeholder("id"), literal("edit")}}},
{"card-delete",
{RouteKind::DYNAMIC,
{literal("cards"), placeholder("id"), literal("delete")}}},
{"series-index",
{RouteKind::DYNAMIC, {literal("admin"), literal("series")}}},
{"series-new",
{RouteKind::DYNAMIC,
{literal("admin"), literal("series"), literal("new")}}},
{"series", {RouteKind::DYNAMIC,
{literal("admin"), literal("series")}}},
{"series-edit",
{RouteKind::DYNAMIC,
{literal("admin"), literal("series"), placeholder("integer"),
literal("edit")}}},
{"series-item",
{RouteKind::DYNAMIC,
{literal("admin"), literal("series"), placeholder("integer")}}},
{"series-delete",
{RouteKind::DYNAMIC,
{literal("admin"), literal("series"), placeholder("integer"),
literal("delete")}}},
{"card-asset", {RouteKind::STATIC_MOUNT, {literal("static-cards")}}},
{"static", {RouteKind::STATIC_MOUNT, {literal("static")}}},
};
const RouteDefinition& routeDefinition(const std::string& name)
{
const auto route = ROUTES.find(name);
if(route == ROUTES.end())
{
throw std::invalid_argument("Unknown route name: " + name);
}
return route->second;
}
std::size_t argumentCount(const RouteDefinition& route)
{
std::size_t count = 0;
for(const RouteSegment& segment : route.segments)
{
if(segment.kind == RouteSegmentKind::PLACEHOLDER)
{
++count;
}
}
return count;
}
std::vector<RouteSegment> resolveRoute(
const RouteDefinition& route,
const std::vector<std::string>& arguments,
RouteSegmentKind argument_kind)
{
if(arguments.size() != argumentCount(route))
{
throw std::invalid_argument("Incorrect route argument count");
}
std::vector<RouteSegment> result;
result.reserve(route.segments.size());
std::size_t argument_index = 0;
for(const RouteSegment& segment : route.segments)
{
if(segment.kind == RouteSegmentKind::PLACEHOLDER)
{
result.push_back(
{argument_kind, arguments[argument_index++]});
}
else
{
result.push_back(segment);
}
}
return result;
}
bool indexCardLess(const IndexCard& left, const IndexCard& right)
{
if(naturalPublicIdLess(left.public_id, right.public_id))
{
return true;
}
if(naturalPublicIdLess(right.public_id, left.public_id))
{
return false;
}
return left.card->id < right.card->id;
}
bool hasFieldKey(
const GameDefinitionSnapshot& definition,
const std::string& key)
{
return std::ranges::any_of(
definition.fields,
[&key](const GameField& field)
{
return field.key == key;
});
}
void sortIndexCards(std::vector<IndexCard>& cards, bool descending)
{
if(descending)
{
std::ranges::sort(
cards,
[](const IndexCard& left, const IndexCard& right)
{
return indexCardLess(right, left);
});
return;
}
std::ranges::sort(cards, indexCardLess);
}
std::string uppercaseAscii(std::string value)
{
for(char& character : value)
{
character = static_cast<char>(
std::toupper(static_cast<unsigned char>(character)));
}
return value;
}
void respondInternalError(App::Response& response)
{
response.status = 500;
response.set_content(
"<!doctype html><title>Internal server error</title>"
"<h1>Internal server error</h1>",
"text/html; charset=utf-8");
}
void respondNotFound(App::Response& response)
{
response.status = 404;
response.set_content(
"<!doctype html><title>Card not found</title>"
"<h1>Card not found</h1>",
"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");
}
bool enforceTextBudget(
const App::Request& request, App::Response& response)
{
std::size_t total_size = 0;
for(const auto& [name, value] : request.params)
{
[[maybe_unused]] const std::string& ignored_name = name;
if(value.size() > MAX_FORM_TEXT_FIELD_SIZE)
{
response.status = 413;
response.set_content(
"A form field is too large\n",
"text/plain; charset=utf-8");
return false;
}
if(value.size() > MAX_FORM_TOTAL_TEXT_SIZE - total_size)
{
response.status = 413;
response.set_content(
"Form metadata is too large\n",
"text/plain; charset=utf-8");
return false;
}
total_size += value.size();
}
return true;
}
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,
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);
}
void respondTemplate(
inja::Environment& environment,
const std::string& filename,
const inja::json& data,
App::Response& response)
{
try
{
response.set_content(
environment.render_file(filename, data),
"text/html; charset=utf-8");
}
catch(const std::exception& error)
{
spdlog::error("Failed to render {}: {}", filename, error.what());
respondInternalError(response);
}
}
std::string roleName(UserRole role)
{
switch(role)
{
case UserRole::PLAYER:
return "Player";
case UserRole::CREATOR:
return "Creator";
case UserRole::ADMINISTRATOR:
return "Administrator";
}
return "Unknown";
}
std::optional<std::string> optionalText(
const std::unordered_map<std::string, std::string>& fields,
const std::string& name)
{
const auto position = fields.find(name);
if(position == fields.end())
{
return std::nullopt;
}
std::string value(mw::strip(position->second));
if(value.empty())
{
return std::nullopt;
}
return value;
}
std::string submittedText(
const std::unordered_map<std::string, std::string>& fields,
const std::string& name,
std::string fallback = {})
{
const auto position = fields.find(name);
return position == fields.end()
? std::move(fallback)
: position->second;
}
std::string renderableGameText(const std::string& value)
{
return validGameText(value) ? value : std::string();
}
std::string gameControlId(std::string_view field_name)
{
if(field_name == "short_name")
{
return "GameShortName";
}
if(field_name == "description")
{
return "GameDescription";
}
return "GameDisplayName";
}
std::string fieldControlId(std::string_view field_name)
{
if(field_name == "key")
{
return "FieldKey";
}
if(field_name == "type")
{
return "FieldType";
}
if(field_name == "choice")
{
return "ChoiceEditor";
}
return "FieldLabel";
}
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;
}
mw::E<std::int64_t> parseRevision(
const std::unordered_map<std::string, std::string>& fields)
{
const auto position = fields.find("revision");
if(position == fields.end())
{
return std::unexpected(mw::runtimeError("Revision is required"));
}
std::int64_t revision = 0;
const std::string& text = position->second;
const auto result = std::from_chars(
text.data(), text.data() + text.size(), revision);
if(text.empty() || result.ec != std::errc{} ||
result.ptr != text.data() + text.size() || revision < 1)
{
return std::unexpected(mw::runtimeError(
"Revision must be a positive integer"));
}
return revision;
}
mw::E<std::int64_t> parseGameRevision(
const std::unordered_map<std::string, std::string>& fields)
{
const auto position = fields.find("game_revision");
if(position == fields.end())
{
return std::unexpected(mw::httpError(
400, "Game revision is required"));
}
std::int64_t revision = 0;
const std::string& text = position->second;
const auto result = std::from_chars(
text.data(), text.data() + text.size(), revision);
if(text.empty() || result.ec != std::errc{} ||
result.ptr != text.data() + text.size() || revision < 1)
{
return std::unexpected(mw::httpError(
400, "Game revision must be a positive integer"));
}
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;
}
mw::E<std::string> scalarParameter(
const App::Request& request,
const std::string& name,
bool required = true)
{
const auto values = request.get_param_values(name);
if(values.size() > 1)
{
return std::unexpected(mw::httpError(
400, "The form field '" + name + "' was supplied more than once"));
}
if(values.empty())
{
if(required)
{
return std::unexpected(mw::httpError(
400, "The form field '" + name + "' is required"));
}
return std::string();
}
return values.front();
}
mw::E<std::int64_t> positiveParameter(
const App::Request& request, const std::string& name)
{
auto text = scalarParameter(request, name);
if(!text)
{
return std::unexpected(std::move(text.error()));
}
auto value = parsePositiveId(*text);
if(!value)
{
return std::unexpected(mw::httpError(
400, "The form field '" + name + "' must be a positive integer"));
}
return *value;
}
mw::E<GameVisibility> visibilityParameter(const App::Request& request)
{
auto value = scalarParameter(request, "visibility");
if(!value)
{
return std::unexpected(std::move(value.error()));
}
if(*value == "PUBLIC")
{
return GameVisibility::PUBLIC;
}
if(*value == "INTERNAL")
{
return GameVisibility::INTERNAL;
}
return std::unexpected(mw::httpError(
400, "The form field 'visibility' is invalid"));
}
std::optional<std::string> pathParameter(
const App::Request& request, const std::string& name)
{
const auto position = request.path_params.find(name);
if(position == request.path_params.end() || position->second.empty())
{
return std::nullopt;
}
return position->second;
}
SubmittedGameFields gameFields(
const std::unordered_map<std::string, std::string>& fields)
{
SubmittedGameFields 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)
{
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"));
}
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;
}
std::string fieldValue(
const std::vector<GameFieldValue>& values, std::int64_t field_id)
{
const auto position = std::ranges::find_if(
values,
[field_id](const GameFieldValue& value)
{
return value.field_id == field_id;
});
if(position == values.end())
{
return {};
}
if(const auto integer = std::get_if<std::int64_t>(&position->value))
{
return std::to_string(*integer);
}
return std::get<std::string>(position->value);
}
inja::json gameFormJson(
const GameDefinitionSnapshot& definition,
const std::vector<GameFieldValue>& values,
const SubmittedGameFields* submitted = nullptr,
const GameFieldValidationError* error = nullptr)
{
inja::json fields = inja::json::array();
for(const GameField& field : definition.fields)
{
inja::json choices = inja::json::array();
for(const GameChoice& choice : field.choices)
{
choices.push_back(choice.value);
}
std::string displayed_value = fieldValue(values, field.id);
if(submitted != nullptr)
{
const auto submitted_value = submitted->find(field.key);
if(submitted_value != submitted->end() &&
validGameText(submitted_value->second))
{
displayed_value = submitted_value->second;
}
}
const bool has_error = error != nullptr &&
error->field_key == field.key;
const std::string control_id =
"GameField-" + definition.game.short_name + "-" + field.key;
fields.push_back({
{"choices", std::move(choices)},
{"control_id", control_id},
{"error_id", control_id + "-error"},
{"error_message", has_error ? error->msg : std::string()},
{"has_error", has_error},
{"is_choice", field.type == GameFieldType::CHOICE},
{"is_integer", field.type == GameFieldType::INTEGER},
{"is_string", field.type == GameFieldType::STRING},
{"key", field.key},
{"label", field.label},
{"value", std::move(displayed_value)},
});
}
return {
{"display_name", definition.game.display_name},
{"fields", std::move(fields)},
{"revision", definition.game.revision},
{"short_name", definition.game.short_name},
};
}
mw::E<std::vector<GameDefinitionSnapshot>> loadGameDefinitions(
DataSourceInterface& data_source, GameContentScope scope)
{
auto games = data_source.getGames(scope);
if(!games)
{
return std::unexpected(std::move(games.error()));
}
std::vector<GameDefinitionSnapshot> definitions;
definitions.reserve(games->size());
for(const Game& game : *games)
{
auto definition = data_source.getGameDefinition(
game.short_name, scope);
if(!definition)
{
return std::unexpected(std::move(definition.error()));
}
if(!*definition)
{
return std::unexpected(mw::runtimeError(
"A listed game disappeared while rendering a form"));
}
definitions.push_back(std::move(**definition));
}
return definitions;
}
bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
{
std::error_code filesystem_error;
const bool exists = std::filesystem::is_regular_file(
path, filesystem_error);
if(filesystem_error &&
filesystem_error != std::errc::no_such_file_or_directory)
{
spdlog::warn(
"Failed to inspect an asset for card {}: {}",
card_id,
filesystem_error.message());
}
return exists;
}
} // namespace
App::App(
const Config& config,
std::unique_ptr<DataSourceInterface> data_source,
std::unique_ptr<NonSecretRandom> random)
: mw::HTTPServer(config.listen_address),
config_(config),
data_source_(std::move(data_source)),
random_(std::move(random)),
url_builder_(config.base_url),
templates_(config.static_root.parent_path() / "templates")
{
if(!data_source_ || !random_)
{
throw std::invalid_argument(
"App requires data and a random generator");
}
clock_ = std::make_unique<SystemClock>();
crypto_ = std::make_unique<mw::Crypto>();
email_sender_ = makeEmailSender(config_);
authentication_service_ = std::make_unique<AuthenticationService>(
*data_source_,
*email_sender_,
*clock_,
*crypto_,
config_.base_url,
config_.email.daily_attempt_limit);
user_service_ = std::make_unique<UserService>(*data_source_);
collection_service_ = std::make_unique<CollectionService>(
*data_source_,
*clock_,
*crypto_,
config_.maximum_accumulated_pulls);
card_service_ = std::make_unique<CardService>(
*data_source_,
*random_,
ImageProcessor(config_.avif_quality, config_.thumbnail_long_side),
AssetStore(config_.card_storage_root));
series_service_ = std::make_unique<SeriesService>(
*data_source_);
game_service_ = std::make_unique<GameService>(*data_source_);
templates_.set_html_autoescape(true);
templates_.add_callback(
"url_for",
[this](inja::Arguments& callback_arguments) -> inja::json
{
if(callback_arguments.empty() ||
!callback_arguments.front()->is_string())
{
throw std::invalid_argument(
"url_for requires a string route name");
}
std::vector<std::string> arguments;
arguments.reserve(callback_arguments.size() - 1);
for(std::size_t index = 1;
index < callback_arguments.size();
++index)
{
if(!callback_arguments[index]->is_string())
{
throw std::invalid_argument(
"url_for arguments must be strings");
}
arguments.push_back(
callback_arguments[index]->get<std::string>());
}
return urlFor(
callback_arguments.front()->get<std::string>(),
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");
game_admin_template_ = templates_.parse_template("game_admin.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::setCookie(
Response& response,
const std::string& name,
const std::string& value,
std::int64_t maximum_age,
bool strict_same_site) const
{
std::string path = config_.base_url.path();
if(path.empty())
{
path = "/";
}
std::string cookie = name + '=' + value + "; Path=" + path +
"; HttpOnly; SameSite=" +
(strict_same_site ? "Strict" : "Lax") +
"; Max-Age=" + std::to_string(maximum_age);
if(config_.base_url.scheme() == "https")
{
cookie += "; Secure";
}
response.set_header("Set-Cookie", cookie);
}
std::optional<App::RequestIdentity> App::requireIdentity(
const Request& request,
Response& response,
bool safe_get,
bool allow_onboarding)
{
auto raw_token = cookieValue(request, "card_collection_session");
std::optional<SessionContext> session;
if(raw_token)
{
auto loaded = authentication_service_->session(*raw_token);
if(!loaded)
{
spdlog::error("Failed to load session: {}", loaded.error().msg());
respondInternalError(response);
return std::nullopt;
}
session = std::move(*loaded);
}
if(!raw_token || !session)
{
if(raw_token)
{
setCookie(
response, "card_collection_session", "", 0, false);
}
response.status = safe_get ? 303 : 401;
if(safe_get)
{
response.set_header("Location", urlFor("authentication"));
}
else
{
response.set_content(
"Authentication required\n", "text/plain; charset=utf-8");
}
return std::nullopt;
}
if(!allow_onboarding && !session->user.username)
{
response.status = safe_get ? 303 : 409;
if(safe_get)
{
response.set_header("Location", urlFor("onboarding"));
}
else
{
response.set_content(
"Username onboarding required\n",
"text/plain; charset=utf-8");
}
return std::nullopt;
}
response.set_header("Cache-Control", "private, no-store");
return RequestIdentity{std::move(*session), std::move(*raw_token)};
}
bool App::verifyCsrf(
const Request& request,
const RequestIdentity& identity,
Response& response) const
{
if(request.get_param_value_count("csrf_token") != 1 ||
!constantTimeEqual(
request.get_param_value("csrf_token"),
identity.session.csrf_token))
{
response.status = 403;
response.set_content("Invalid CSRF token\n", "text/plain");
return false;
}
return true;
}
void App::addNavigationData(inja::json& data, const User& user) const
{
AuthorizationService authorization;
data["navigation"] = {
{"cards_url", urlFor(
user.role == UserRole::ADMINISTRATOR
? "card-index"
: "creator-cards")},
{"show_cards", authorization.canCreateCard(user)},
{"show_users", authorization.canAdminister(user)},
{"games_url", urlFor("admin-games")},
{"series_url", urlFor("series-index")},
{"users_url", urlFor("admin-users")},
};
}
void App::respondMutationError(
Response& response,
const mw::Error& error,
std::string_view operation,
const std::string& reload_url,
const User& user,
bool files_need_reselection)
{
const mw::HTTPError* http_error = error.as<mw::HTTPError>();
const GameFieldValidationError* field_error =
error.as<GameFieldValidationError>();
const GameDefinitionValidationError* definition_error =
error.as<GameDefinitionValidationError>();
if(field_error == nullptr && definition_error == nullptr &&
(http_error == nullptr || http_error->code >= 500))
{
spdlog::error("{}: {}", operation, error.msg());
respondInternalError(response);
return;
}
const int status = field_error == nullptr && definition_error == nullptr
? http_error->code : 422;
const bool conflict = status == 409;
inja::json data = {
{"conflict", conflict},
{"files_need_reselection", files_need_reselection},
{"message", error.msg()},
{"reload_url", reload_url},
{"title", conflict
? "Changes conflicted · Card Collection"
: "Check your changes · Card Collection"},
};
addNavigationData(data, user);
response.status = status;
respondTemplate(templates_, "mutation_error.html", data, response);
}
void App::respondCardFieldError(
Response& response,
const GameFieldValidationError& error,
const CardUpload& upload,
const RequestIdentity& identity,
const Card* card)
{
AuthorizationService authorization;
const GameContentScope scope = authorization.gameContentScope(
identity.session.user);
auto definitions = loadGameDefinitions(*data_source_, scope);
auto all_series = data_source_->getSeries(scope);
if(!definitions || !all_series)
{
respondInternalError(response);
return;
}
const std::string selected_game = card != nullptr
? card->identity.game_short_name.value_or("")
: submittedText(upload.fields, "game");
const SubmittedGameFields submitted = gameFields(upload.fields);
inja::json games = inja::json::array();
std::string error_control_id;
for(const GameDefinitionSnapshot& definition : *definitions)
{
const bool selected =
definition.game.short_name == selected_game;
if(selected && hasFieldKey(definition, error.field_key))
{
error_control_id = "GameField-" + selected_game + "-" +
error.field_key;
}
games.push_back(gameFormJson(
definition,
{},
selected ? &submitted : nullptr,
selected ? &error : nullptr));
}
inja::json series = inja::json::array();
for(const Series& item : *all_series)
{
const std::string id = std::to_string(item.id);
series.push_back({
{"game", item.game_short_name},
{"id", item.id},
{"name", item.name},
{"selected", std::ranges::find(
upload.series_ids, id) != upload.series_ids.end()},
});
}
const bool editing = card != nullptr;
std::string public_id;
std::string front_url = urlFor(
"static", {"card_placeholder.svg"});
std::string foil_url;
if(editing)
{
auto formatted = formatPublicId(card->identity);
if(!formatted)
{
respondInternalError(response);
return;
}
public_id = std::move(*formatted);
front_url = urlFor(
"card-asset",
{public_id + "/front-art." + card->front_extension},
{{"v", std::to_string(card->revision)}});
if(card->foil_extension)
{
foil_url = urlFor(
"card-asset",
{public_id + "/foil." + *card->foil_extension},
{{"v", std::to_string(card->revision)}});
}
}
inja::json data = {
{"action_url", editing
? urlFor("card-edit", {public_id})
: urlFor("cards")},
{"back_url", editing
? urlFor("card", {public_id})
: urlFor(identity.session.user.role == UserRole::ADMINISTRATOR
? "card-index" : "creator-cards")},
{"csrf_token", identity.session.csrf_token},
{"display_id", editing ? uppercaseAscii(public_id) : "New addition"},
{"error_control_id", error_control_id},
{"error_message", error.msg},
{"files_need_reselection", true},
{"foil_action", "keep"},
{"foil_url", foil_url},
{"front_url", front_url},
{"games", std::move(games)},
{"has_errors", true},
{"has_foil", editing && card->foil_extension.has_value()},
{"heading", editing ? "Edit card" : "Create card"},
{"long_description", submittedText(
upload.fields,
"long_description",
editing ? card->long_description.value_or("") : "")},
{"mode", editing ? "edit" : "create"},
{"model_url", urlFor("static", {"foil/model/card.obj"})},
{"name", submittedText(
upload.fields, "name", editing ? card->name : "")},
{"preview_script_url",
urlFor("static", {"foil/card_preview.js"})},
{"rarity", submittedText(
upload.fields,
"rarity",
editing ? std::to_string(card->rarity) : "0")},
{"revision", editing ? card->revision : 0},
{"selected_game", selected_game},
{"series", std::move(series)},
{"shader_fragment_url",
urlFor("static", {"foil/frag-shader.glsl"})},
{"shader_vertex_url",
urlFor("static", {"foil/vert-shader.glsl"})},
{"short_description", submittedText(
upload.fields,
"short_description",
editing ? card->short_description.value_or("") : "")},
{"show_rarity",
identity.session.user.role == UserRole::ADMINISTRATOR},
{"spectral_lut_url",
urlFor("static", {"foil/spectral_xyz.bin"})},
{"submit_label", editing ? "Save changes" : "Create card"},
{"thumbnail_long_side", config_.thumbnail_long_side},
{"title", editing
? "Edit " + card->name + " · Card Collection"
: "Create card · Card Collection"},
};
addNavigationData(data, identity.session.user);
try
{
response.status = 422;
response.set_content(
templates_.render(card_form_template_, data),
"text/html; charset=utf-8");
}
catch(const std::exception& render_error)
{
spdlog::error(
"Failed to re-render card validation: {}",
render_error.what());
respondInternalError(response);
}
}
void App::respondGameDefinitionError(
Response& response,
const GameDefinitionValidationError& error,
const RequestIdentity& identity,
const std::string& short_name,
const std::string& display_name,
const std::string& description,
GameVisibility visibility,
bool editing)
{
std::optional<GameDefinitionSnapshot> definition;
if(editing)
{
auto loaded = data_source_->getGameDefinition(
short_name, GameContentScope::INCLUDE_INTERNAL);
if(!loaded)
{
respondInternalError(response);
return;
}
if(!*loaded)
{
respondNotFound(response);
return;
}
definition = std::move(**loaded);
}
inja::json fields = inja::json::array();
if(definition)
{
for(std::size_t index = 0;
index < definition->fields.size();
++index)
{
const GameField& field = definition->fields[index];
std::vector<std::int64_t> up_order;
std::vector<std::int64_t> down_order;
for(const GameField& candidate : definition->fields)
{
up_order.push_back(candidate.id);
down_order.push_back(candidate.id);
}
if(index > 0)
{
std::swap(up_order[index], up_order[index - 1]);
}
if(index + 1 < down_order.size())
{
std::swap(down_order[index], down_order[index + 1]);
}
fields.push_back({
{"choice_count", field.choices.size()},
{"delete_url", urlFor(
"game-field-delete",
{short_name, std::to_string(field.id)})},
{"down_order", down_order},
{"edit_url", urlFor(
"game-field-edit",
{short_name, std::to_string(field.id)})},
{"id", field.id},
{"is_first", index == 0},
{"is_last", index + 1 == definition->fields.size()},
{"key", field.key},
{"label", field.label},
{"type", std::string(gameFieldTypeName(field.type))},
{"up_order", up_order},
});
}
}
const std::int64_t revision = definition
? definition->game.revision : 0;
inja::json data = {
{"action_url", editing
? urlFor("game-update", {short_name})
: urlFor("games")},
{"add_field_url", editing
? urlFor("game-field-new", {short_name}) : ""},
{"back_url", urlFor("admin-games")},
{"csrf_token", identity.session.csrf_token},
{"delete_url", editing
? urlFor("game-delete", {short_name}) : ""},
{"description", renderableGameText(description)},
{"display_name", renderableGameText(display_name)},
{"error_control_id", gameControlId(error.field_name)},
{"error_field", error.field_name},
{"error_message", error.msg},
{"field_order_url", editing
? urlFor("game-field-order", {short_name}) : ""},
{"fields", std::move(fields)},
{"game_revision", revision},
{"has_errors", true},
{"heading", editing
? "Edit " + definition->game.display_name
: "Create game"},
{"mode", editing ? "edit" : "create"},
{"visibility_internal", visibility == GameVisibility::INTERNAL},
{"visibility_public", visibility == GameVisibility::PUBLIC},
{"short_name", renderableGameText(short_name)},
{"submit_label", editing ? "Save game" : "Create game"},
{"title", editing
? "Edit " + definition->game.display_name +
" · Card Collection"
: "Create game · Card Collection"},
};
addNavigationData(data, identity.session.user);
response.status = 422;
respondTemplate(templates_, "game_form.html", data, response);
}
void App::respondGameFieldDefinitionError(
Response& response,
const GameDefinitionValidationError& error,
const RequestIdentity& identity,
const std::string& short_name,
std::optional<std::int64_t> field_id,
const std::string& key,
const std::string& label,
const std::string& type,
const std::vector<std::string>& choices)
{
auto loaded = data_source_->getGameDefinition(
short_name, GameContentScope::INCLUDE_INTERNAL);
if(!loaded)
{
respondInternalError(response);
return;
}
if(!*loaded)
{
respondNotFound(response);
return;
}
const GameDefinitionSnapshot& definition = **loaded;
const GameField* stored_field = nullptr;
if(field_id)
{
const auto position = std::ranges::find_if(
definition.fields,
[field_id](const GameField& field)
{
return field.id == *field_id;
});
if(position == definition.fields.end())
{
respondNotFound(response);
return;
}
stored_field = &*position;
}
inja::json choice_data = inja::json::array();
for(const std::string& value : choices)
{
const bool existing = stored_field != nullptr &&
std::ranges::any_of(
stored_field->choices,
[&value](const GameChoice& choice)
{
return choice.value == value;
});
choice_data.push_back({
{"existing", existing},
{"value", renderableGameText(value)},
});
}
const bool editing = field_id.has_value();
const std::string id = editing ? std::to_string(*field_id) : "";
const std::string displayed_key = stored_field == nullptr
? renderableGameText(key) : stored_field->key;
const std::string displayed_type = stored_field == nullptr
? type : std::string(gameFieldTypeName(stored_field->type));
inja::json data = {
{"action_url", editing
? urlFor("game-field-update", {short_name, id})
: urlFor("game-fields", {short_name})},
{"back_url", urlFor("game-edit", {short_name})},
{"choices", std::move(choice_data)},
{"csrf_token", identity.session.csrf_token},
{"delete_url", editing
? urlFor("game-field-delete", {short_name, id}) : ""},
{"error_control_id", fieldControlId(error.field_name)},
{"error_field", error.field_name},
{"error_message", error.msg},
{"field_type", displayed_type},
{"game_name", definition.game.display_name},
{"game_revision", definition.game.revision},
{"has_errors", true},
{"heading", editing
? "Edit " + stored_field->label : "Add custom field"},
{"key", displayed_key},
{"label", renderableGameText(label)},
{"mode", editing ? "edit" : "create"},
{"submit_label", editing ? "Save field" : "Add field"},
{"title", editing
? "Edit field · Card Collection"
: "Add field · Card Collection"},
};
addNavigationData(data, identity.session.user);
response.status = 422;
respondTemplate(templates_, "game_field_form.html", data, response);
}
void App::handleWelcome(const Request& request, Response& response)
{
auto token = cookieValue(request, "card_collection_session");
if(token)
{
auto session = authentication_service_->session(*token);
if(!session)
{
respondInternalError(response);
return;
}
if(*session)
{
response.status = 303;
response.set_header(
"Location",
(**session).user.username
? urlFor("collection")
: urlFor("onboarding"));
return;
}
setCookie(response, "card_collection_session", "", 0, false);
}
respondTemplate(templates_, "welcome.html", {
{"authentication_url", urlFor("authentication")},
{"example_url", urlFor("static", {"card_placeholder.svg"})},
{"title", "Card Collection"},
}, response);
}
void App::handleAuthentication(
[[maybe_unused]] const Request& request,
Response& response)
{
auto nonce = generateSecretToken(*crypto_);
if(!nonce)
{
respondInternalError(response);
return;
}
setCookie(
response, "card_collection_auth_form", nonce->value, 600, true);
response.set_header("Cache-Control", "no-store");
respondTemplate(templates_, "authentication.html", {
{"action_url", urlFor("authentication-email")},
{"form_nonce", nonce->value},
{"title", "Sign in · Card Collection"},
}, response);
}
void App::handleAuthenticationEmail(
const Request& request, Response& response)
{
const auto nonce = cookieValue(request, "card_collection_auth_form");
if(!nonce || request.get_param_value_count("form_nonce") != 1 ||
!constantTimeEqual(*nonce, request.get_param_value("form_nonce")))
{
response.status = 403;
response.set_content("Invalid form token\n", "text/plain");
return;
}
if(request.get_param_value_count("email") != 1)
{
respondBadRequest(response, "Email is required");
return;
}
auto sent = authentication_service_->requestEmail(
request.get_param_value("email"));
if(!sent)
{
const auto* limited = sent.error().as<AuthenticationRateLimitError>();
if(limited != nullptr)
{
response.status = 429;
response.set_header(
"Retry-After", std::to_string(limited->retry_after));
response.set_content(limited->msg + "\n", "text/plain");
return;
}
respondOperationError(
response, sent.error(), "Failed to send authentication email");
return;
}
setCookie(response, "card_collection_auth_form", "", 0, true);
response.status = 303;
response.set_header("Location", urlFor("authentication-sent"));
}
void App::handleAuthenticationSent(
[[maybe_unused]] const Request& request,
Response& response)
{
response.set_header("Cache-Control", "no-store");
respondTemplate(templates_, "authentication_sent.html", {
{"title", "Check your email · Card Collection"},
}, response);
}
void App::handleAuthenticationConfirm(
const Request& request, Response& response)
{
const auto parameter = request.path_params.find("token");
if(parameter == request.path_params.end())
{
respondBadRequest(response, "Invalid authentication link");
return;
}
auto challenge = authentication_service_->validate(parameter->second);
if(!challenge)
{
const auto* http_error = challenge.error().as<mw::HTTPError>();
if(http_error != nullptr && http_error->code == 400)
{
respondBadRequest(response, "Invalid authentication link");
}
else
{
spdlog::error(
"Failed to validate authentication link: {}",
challenge.error().msg());
respondInternalError(response);
}
return;
}
if(!*challenge)
{
respondBadRequest(response, "Invalid or expired authentication link");
return;
}
auto nonce = generateSecretToken(*crypto_);
if(!nonce)
{
respondInternalError(response);
return;
}
setCookie(
response, "card_collection_confirm_form", nonce->value, 600, true);
response.set_header("Cache-Control", "no-store");
response.set_header("Referrer-Policy", "no-referrer");
response.set_header(
"Content-Security-Policy", "default-src 'none'; style-src 'self'");
respondTemplate(templates_, "authentication_confirm.html", {
{"action_url",
urlFor("authentication-confirm", {parameter->second})},
{"email", (**challenge).email},
{"form_nonce", nonce->value},
{"title", "Confirm sign in · Card Collection"},
}, response);
}
void App::handleAuthenticationConfirmPost(
const Request& request, Response& response)
{
const auto parameter = request.path_params.find("token");
const auto nonce = cookieValue(request, "card_collection_confirm_form");
if(parameter == request.path_params.end() || !nonce ||
request.get_param_value_count("form_nonce") != 1 ||
!constantTimeEqual(*nonce, request.get_param_value("form_nonce")))
{
response.status = 403;
response.set_content("Invalid confirmation token\n", "text/plain");
return;
}
auto current = cookieValue(request, "card_collection_session");
auto established = authentication_service_->confirm(
parameter->second, current);
if(!established)
{
respondOperationError(
response, established.error(), "Failed to confirm authentication");
return;
}
setCookie(
response,
"card_collection_session",
established->token,
28 * 24 * 60 * 60,
false);
setCookie(response, "card_collection_confirm_form", "", 0, true);
response.status = 303;
response.set_header(
"Location",
established->user.username
? urlFor("collection")
: urlFor("onboarding"));
}
void App::handleLogout(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, false, true);
if(!identity || !verifyCsrf(request, *identity, response))
{
return;
}
auto logged_out = authentication_service_->logout(identity->raw_token);
if(!logged_out)
{
respondInternalError(response);
return;
}
setCookie(response, "card_collection_session", "", 0, false);
response.status = 303;
response.set_header("Location", urlFor("welcome"));
}
void App::handleOnboarding(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, true, true);
if(!identity)
{
return;
}
if(identity->session.user.username)
{
response.status = 303;
response.set_header("Location", urlFor("collection"));
return;
}
respondTemplate(templates_, "onboarding_username.html", {
{"action_url", urlFor("onboarding")},
{"csrf_token", identity->session.csrf_token},
{"heading", "Choose a username"},
{"submit_label", "Continue"},
{"title", "Choose username · Card Collection"},
{"username", ""},
}, response);
}
void App::handleOnboardingPost(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, false, true);
if(!identity || !verifyCsrf(request, *identity, response))
{
return;
}
if(identity->session.user.username ||
request.get_param_value_count("username") != 1)
{
respondBadRequest(response, "Username is required");
return;
}
auto updated = user_service_->setUsername(
identity->session.user.id, request.get_param_value("username"));
if(!updated)
{
respondOperationError(response, updated.error(), "Failed username");
return;
}
response.status = 303;
response.set_header("Location", urlFor("collection"));
}
void App::handleAccount(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, true);
if(!identity)
{
return;
}
inja::json template_data = {
{"csrf_token", identity->session.csrf_token},
{"email", identity->session.user.email},
{"logout_url", urlFor("logout")},
{"title", "Account · Card Collection"},
{"username", *identity->session.user.username},
{"username_url", urlFor("account-username")},
};
addNavigationData(template_data, identity->session.user);
respondTemplate(
templates_, "account.html", template_data, response);
}
void App::handleAccountUsername(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, true);
if(!identity)
{
return;
}
respondTemplate(templates_, "onboarding_username.html", {
{"action_url", urlFor("account-username")},
{"csrf_token", identity->session.csrf_token},
{"heading", "Change username"},
{"submit_label", "Save"},
{"title", "Change username · Card Collection"},
{"username", *identity->session.user.username},
}, response);
}
void App::handleAccountUsernamePost(
const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, false);
if(!identity || !verifyCsrf(request, *identity, response))
{
return;
}
if(request.get_param_value_count("username") != 1)
{
respondBadRequest(response, "Username is required");
return;
}
auto updated = user_service_->setUsername(
identity->session.user.id, request.get_param_value("username"));
if(!updated)
{
respondOperationError(response, updated.error(), "Failed username");
return;
}
response.status = 303;
response.set_header("Location", urlFor("account"));
}
void App::handleCollection(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, true);
if(!identity)
{
return;
}
auto user = collection_service_->refresh(identity->session.user.id);
AuthorizationService authorization;
auto collection = data_source_->getCollection(
identity->session.user.id,
authorization.gameContentScope(identity->session.user));
auto pool = data_source_->getPoolCards();
if(!user || !collection || !pool)
{
respondInternalError(response);
return;
}
inja::json entries = inja::json::array();
for(const CollectionEntry& entry : *collection)
{
auto public_id_result = formatPublicId(entry.card.identity);
if(!public_id_result)
{
respondInternalError(response);
return;
}
const std::string& public_id = *public_id_result;
const std::string thumbnail_name =
"thumb." + entry.card.thumbnail_extension;
const std::filesystem::path thumbnail_path =
config_.card_storage_root / "published" / public_id /
thumbnail_name;
std::error_code image_error;
const bool thumbnail_exists = std::filesystem::is_regular_file(
thumbnail_path, image_error);
const std::string thumbnail_url = thumbnail_exists
? urlFor(
"card-asset",
{public_id + '/' + thumbnail_name},
{{"v", std::to_string(entry.card.revision)}})
: urlFor("static", {"card_placeholder.svg"});
entries.push_back({
{"name", entry.card.name},
{"quantity", entry.quantity},
{"thumbnail_url", thumbnail_url},
{"url", urlFor("card", {public_id})},
});
}
const bool pull_disabled = pool->empty() || user->stored_pulls == 0;
inja::json template_data = {
{"available_pulls", user->stored_pulls},
{"csrf_token", identity->session.csrf_token},
{"entries", std::move(entries)},
{"pool_empty", pool->empty()},
{"pull_disabled", pull_disabled},
{"pull_url", urlFor("collection-pull")},
{"title", "Collection · Card Collection"},
};
addNavigationData(template_data, identity->session.user);
respondTemplate(
templates_, "collection.html", template_data, response);
}
void App::handleCollectionPull(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, false);
if(!identity || !verifyCsrf(request, *identity, response))
{
return;
}
auto pulled = collection_service_->pull(identity->session.user.id);
if(!pulled)
{
respondOperationError(response, pulled.error(), "Failed card pull");
return;
}
response.status = 303;
auto public_id = formatPublicId(pulled->card.identity);
if(!public_id)
{
respondInternalError(response);
return;
}
response.set_header(
"Location", urlFor("card", {*public_id}));
}
void App::handleAdminUsers(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, true);
if(!identity)
{
return;
}
AuthorizationService authorization;
if(!authorization.canAdminister(identity->session.user))
{
response.status = 403;
return;
}
auto users = data_source_->getUsers();
if(!users)
{
respondInternalError(response);
return;
}
inja::json template_users = inja::json::array();
for(const User& user : *users)
{
template_users.push_back({
{"can_promote", user.role == UserRole::PLAYER},
{"email", user.email},
{"promote_url", urlFor(
"admin-promote", {std::to_string(user.id)})},
{"role", roleName(user.role)},
{"username", user.username.value_or("Onboarding")},
});
}
inja::json template_data = {
{"csrf_token", identity->session.csrf_token},
{"title", "Users · Card Collection"},
{"users", std::move(template_users)},
};
addNavigationData(template_data, identity->session.user);
respondTemplate(
templates_, "user_admin.html", template_data, response);
}
void App::handleAdminGames(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, true);
if(!identity)
{
return;
}
AuthorizationService authorization;
if(!authorization.canAdminister(identity->session.user))
{
response.status = 403;
return;
}
auto games = data_source_->getGames(
GameContentScope::INCLUDE_INTERNAL);
if(!games)
{
respondInternalError(response);
return;
}
inja::json template_games = inja::json::array();
std::vector<HtmlSubstitution> html_substitutions;
for(const Game& game : *games)
{
auto definition = data_source_->getGameDefinition(
game.short_name, GameContentScope::INCLUDE_INTERNAL);
if(!definition || !*definition)
{
respondInternalError(response);
return;
}
inja::json fields = inja::json::array();
for(const GameField& field : (**definition).fields)
{
fields.push_back({
{"input_type", std::string(gameFieldTypeName(field.type))},
{"key", field.key},
{"label", field.label},
});
}
const Game& current_game = (**definition).game;
std::string description_marker;
if(!current_game.description.empty())
{
auto rendered = MarkdownRenderer().render(
current_game.description);
if(!rendered)
{
respondInternalError(response);
return;
}
description_marker =
"CARD_COLLECTION_HTML_" + random_->hex(16);
html_substitutions.push_back({
description_marker, std::move(*rendered)});
}
template_games.push_back({
{"description", description_marker},
{"display_name", current_game.display_name},
{"edit_url", urlFor(
"game-edit", {current_game.short_name})},
{"field_count", (**definition).fields.size()},
{"fields", std::move(fields)},
{"has_description", !current_game.description.empty()},
{"initial", current_game.short_name.substr(0, 1)},
{"internal",
current_game.visibility == GameVisibility::INTERNAL},
{"short_name", current_game.short_name},
});
}
inja::json template_data = {
{"create_card_url", urlFor("card-new")},
{"create_url", urlFor("game-new")},
{"games", std::move(template_games)},
{"series_url", urlFor("series-index")},
{"title", "Games · Card Collection"},
};
addNavigationData(template_data, identity->session.user);
auto content = renderWithHtml(
templates_,
game_admin_template_,
template_data,
html_substitutions);
if(!content)
{
spdlog::error(
"Failed to render game administration: {}",
content.error().msg());
respondInternalError(response);
return;
}
response.status = 200;
response.set_content(*content, "text/html; charset=utf-8");
}
void App::handleGameNew(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, true);
AuthorizationService authorization;
if(!identity)
{
return;
}
if(!authorization.canAdminister(identity->session.user))
{
response.status = 403;
return;
}
inja::json data = {
{"action_url", urlFor("games")},
{"back_url", urlFor("admin-games")},
{"csrf_token", identity->session.csrf_token},
{"description", ""},
{"display_name", ""},
{"heading", "Create game"},
{"error_control_id", ""},
{"error_field", ""},
{"error_message", ""},
{"has_errors", false},
{"mode", "create"},
{"visibility_internal", false},
{"visibility_public", true},
{"short_name", ""},
{"submit_label", "Create game"},
{"title", "Create game · Card Collection"},
};
addNavigationData(data, identity->session.user);
respondTemplate(templates_, "game_form.html", data, response);
}
void App::handleGameCreate(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, false);
if(!identity || !verifyCsrf(request, *identity, response))
{
return;
}
if(!enforceTextBudget(request, response))
{
return;
}
auto short_name = scalarParameter(request, "short_name");
auto display_name = scalarParameter(request, "display_name");
auto description = scalarParameter(request, "description", false);
auto visibility = visibilityParameter(request);
if(!short_name || !display_name || !description || !visibility)
{
const mw::Error& error = !short_name ? short_name.error()
: !display_name ? display_name.error()
: !description ? description.error() : visibility.error();
respondOperationError(response, error, "Invalid game form");
return;
}
auto created = game_service_->createGame(
identity->session.user.id, *short_name,
*display_name, *description, *visibility);
if(!created)
{
const auto* validation =
created.error().as<GameDefinitionValidationError>();
if(validation != nullptr)
{
respondGameDefinitionError(
response,
*validation,
*identity,
*short_name,
*display_name,
*description,
*visibility,
false);
return;
}
respondMutationError(
response,
created.error(),
"Failed game create",
urlFor("game-new"),
identity->session.user);
return;
}
response.status = 303;
response.set_header("Location", urlFor("game-edit", {*created}));
}
void App::handleGameEdit(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, true);
AuthorizationService authorization;
if(!identity)
{
return;
}
if(!authorization.canAdminister(identity->session.user))
{
response.status = 403;
return;
}
auto short_name = pathParameter(request, "short");
if(!short_name)
{
respondNotFound(response);
return;
}
auto definition = data_source_->getGameDefinition(
*short_name, GameContentScope::INCLUDE_INTERNAL);
if(!definition)
{
respondInternalError(response);
return;
}
if(!*definition)
{
respondNotFound(response);
return;
}
const GameDefinitionSnapshot& snapshot = **definition;
inja::json fields = inja::json::array();
for(std::size_t index = 0; index < snapshot.fields.size(); ++index)
{
const GameField& field = snapshot.fields[index];
std::vector<std::int64_t> up_order;
std::vector<std::int64_t> down_order;
for(const GameField& candidate : snapshot.fields)
{
up_order.push_back(candidate.id);
down_order.push_back(candidate.id);
}
if(index > 0)
{
std::swap(up_order[index], up_order[index - 1]);
}
if(index + 1 < down_order.size())
{
std::swap(down_order[index], down_order[index + 1]);
}
fields.push_back({
{"choice_count", field.choices.size()},
{"delete_url", urlFor(
"game-field-delete",
{*short_name, std::to_string(field.id)})},
{"down_order", down_order},
{"edit_url", urlFor(
"game-field-edit",
{*short_name, std::to_string(field.id)})},
{"id", field.id},
{"is_first", index == 0},
{"is_last", index + 1 == snapshot.fields.size()},
{"key", field.key},
{"label", field.label},
{"type", std::string(gameFieldTypeName(field.type))},
{"up_order", up_order},
});
}
inja::json data = {
{"action_url", urlFor("game-update", {*short_name})},
{"add_field_url", urlFor("game-field-new", {*short_name})},
{"back_url", urlFor("admin-games")},
{"csrf_token", identity->session.csrf_token},
{"delete_url", urlFor("game-delete", {*short_name})},
{"description", snapshot.game.description},
{"display_name", snapshot.game.display_name},
{"field_order_url", urlFor("game-field-order", {*short_name})},
{"fields", std::move(fields)},
{"game_revision", snapshot.game.revision},
{"heading", "Edit " + snapshot.game.display_name},
{"error_control_id", ""},
{"error_field", ""},
{"error_message", ""},
{"has_errors", false},
{"mode", "edit"},
{"visibility_internal",
snapshot.game.visibility == GameVisibility::INTERNAL},
{"visibility_public",
snapshot.game.visibility == GameVisibility::PUBLIC},
{"short_name", snapshot.game.short_name},
{"submit_label", "Save game"},
{"title", "Edit " + snapshot.game.display_name +
" · Card Collection"},
};
addNavigationData(data, identity->session.user);
respondTemplate(templates_, "game_form.html", data, response);
}
void App::handleGameUpdate(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, false);
if(!identity || !verifyCsrf(request, *identity, response))
{
return;
}
if(!enforceTextBudget(request, response))
{
return;
}
auto short_name = pathParameter(request, "short");
auto revision = positiveParameter(request, "game_revision");
auto display_name = scalarParameter(request, "display_name");
auto description = scalarParameter(request, "description", false);
auto visibility = visibilityParameter(request);
if(!short_name || !revision || !display_name || !description ||
!visibility)
{
if(!short_name)
{
respondNotFound(response);
}
else
{
const mw::Error& error = !revision ? revision.error()
: !display_name ? display_name.error()
: !description ? description.error()
: visibility.error();
respondOperationError(response, error, "Invalid game form");
}
return;
}
auto updated = game_service_->updateGame(
identity->session.user.id, *short_name, *revision,
*display_name, *description, *visibility);
if(!updated)
{
const auto* validation =
updated.error().as<GameDefinitionValidationError>();
if(validation != nullptr)
{
respondGameDefinitionError(
response,
*validation,
*identity,
*short_name,
*display_name,
*description,
*visibility,
true);
return;
}
respondMutationError(
response,
updated.error(),
"Failed game update",
urlFor("game-edit", {*short_name}),
identity->session.user);
return;
}
response.status = 303;
response.set_header("Location", urlFor("game-edit", {*short_name}));
}
void App::handleGameDeleteConfirm(
const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, true);
AuthorizationService authorization;
if(!identity)
{
return;
}
if(!authorization.canAdminister(identity->session.user))
{
response.status = 403;
return;
}
auto short_name = pathParameter(request, "short");
auto definition = short_name
? data_source_->getGameDefinition(
*short_name, GameContentScope::INCLUDE_INTERNAL)
: mw::E<std::optional<GameDefinitionSnapshot>>(
std::optional<GameDefinitionSnapshot>{});
if(!definition || !*definition)
{
if(!definition)
{
respondInternalError(response);
}
else
{
respondNotFound(response);
}
return;
}
inja::json data = {
{"action_url", urlFor("game-delete", {*short_name})},
{"back_url", urlFor("game-edit", {*short_name})},
{"csrf_token", identity->session.csrf_token},
{"display_name", (**definition).game.display_name},
{"game_revision", (**definition).game.revision},
{"title", "Delete " + (**definition).game.display_name +
" · Card Collection"},
};
addNavigationData(data, identity->session.user);
respondTemplate(templates_, "game_delete.html", data, response);
}
void App::handleGameDelete(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, false);
if(!identity || !verifyCsrf(request, *identity, response))
{
return;
}
if(!enforceTextBudget(request, response))
{
return;
}
auto short_name = pathParameter(request, "short");
auto revision = positiveParameter(request, "game_revision");
if(!short_name || !revision)
{
if(!short_name)
{
respondNotFound(response);
}
else
{
respondOperationError(
response, revision.error(), "Invalid game deletion");
}
return;
}
auto deleted = game_service_->removeGame(
identity->session.user.id, *short_name, *revision);
if(!deleted)
{
respondMutationError(
response,
deleted.error(),
"Failed game delete",
urlFor("game-edit", {*short_name}),
identity->session.user);
return;
}
response.status = 303;
response.set_header("Location", urlFor("admin-games"));
}
void App::handleGameFieldNew(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, true);
AuthorizationService authorization;
if(!identity)
{
return;
}
if(!authorization.canAdminister(identity->session.user))
{
response.status = 403;
return;
}
auto short_name = pathParameter(request, "short");
auto definition = short_name
? data_source_->getGameDefinition(
*short_name, GameContentScope::INCLUDE_INTERNAL)
: mw::E<std::optional<GameDefinitionSnapshot>>(
std::optional<GameDefinitionSnapshot>{});
if(!definition || !*definition)
{
if(!definition)
{
respondInternalError(response);
}
else
{
respondNotFound(response);
}
return;
}
inja::json data = {
{"action_url", urlFor("game-fields", {*short_name})},
{"back_url", urlFor("game-edit", {*short_name})},
{"choices", inja::json::array()},
{"csrf_token", identity->session.csrf_token},
{"field_type", "STRING"},
{"game_name", (**definition).game.display_name},
{"game_revision", (**definition).game.revision},
{"heading", "Add custom field"},
{"error_control_id", ""},
{"error_field", ""},
{"error_message", ""},
{"has_errors", false},
{"key", ""},
{"label", ""},
{"mode", "create"},
{"submit_label", "Add field"},
{"title", "Add field · Card Collection"},
};
addNavigationData(data, identity->session.user);
respondTemplate(templates_, "game_field_form.html", data, response);
}
void App::handleGameFieldCreate(
const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, false);
if(!identity || !verifyCsrf(request, *identity, response))
{
return;
}
if(!enforceTextBudget(request, response))
{
return;
}
auto short_name = pathParameter(request, "short");
auto revision = positiveParameter(request, "game_revision");
auto key = scalarParameter(request, "key");
auto label = scalarParameter(request, "label");
auto type_text = scalarParameter(request, "type");
if(!short_name || !revision || !key || !label || !type_text)
{
if(!short_name)
{
respondNotFound(response);
}
else
{
const mw::Error& error = !revision ? revision.error()
: !key ? key.error() : !label ? label.error()
: type_text.error();
respondOperationError(response, error, "Invalid field form");
}
return;
}
auto type = parseGameFieldType(*type_text);
if(!type)
{
respondGameFieldDefinitionError(
response,
{"type", "Unknown field type"},
*identity,
*short_name,
std::nullopt,
*key,
*label,
*type_text,
request.get_param_values("choice"));
return;
}
const std::vector<std::string> choices =
request.get_param_values("choice");
auto created = game_service_->createField(
identity->session.user.id, *short_name, *revision,
*key, *label, *type, choices);
if(!created)
{
const auto* validation =
created.error().as<GameDefinitionValidationError>();
if(validation != nullptr)
{
respondGameFieldDefinitionError(
response,
*validation,
*identity,
*short_name,
std::nullopt,
*key,
*label,
*type_text,
choices);
return;
}
respondMutationError(
response,
created.error(),
"Failed field create",
urlFor("game-field-new", {*short_name}),
identity->session.user);
return;
}
response.status = 303;
response.set_header("Location", urlFor("game-edit", {*short_name}));
}
void App::handleGameFieldEdit(
const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, true);
AuthorizationService authorization;
if(!identity)
{
return;
}
if(!authorization.canAdminister(identity->session.user))
{
response.status = 403;
return;
}
auto short_name = pathParameter(request, "short");
auto id_text = pathParameter(request, "id");
auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
if(!short_name || !field_id)
{
respondNotFound(response);
return;
}
auto definition = data_source_->getGameDefinition(
*short_name, GameContentScope::INCLUDE_INTERNAL);
if(!definition || !*definition)
{
if(!definition)
{
respondInternalError(response);
}
else
{
respondNotFound(response);
}
return;
}
const auto field = std::ranges::find_if(
(**definition).fields,
[field_id](const GameField& candidate)
{
return candidate.id == *field_id;
});
if(field == (**definition).fields.end())
{
respondNotFound(response);
return;
}
inja::json choices = inja::json::array();
for(const GameChoice& choice : field->choices)
{
choices.push_back({{"existing", true}, {"value", choice.value}});
}
inja::json data = {
{"action_url", urlFor(
"game-field-update", {*short_name, std::to_string(*field_id)})},
{"back_url", urlFor("game-edit", {*short_name})},
{"choices", std::move(choices)},
{"csrf_token", identity->session.csrf_token},
{"delete_url", urlFor(
"game-field-delete", {*short_name, std::to_string(*field_id)})},
{"field_type", std::string(gameFieldTypeName(field->type))},
{"game_name", (**definition).game.display_name},
{"game_revision", (**definition).game.revision},
{"heading", "Edit " + field->label},
{"error_control_id", ""},
{"error_field", ""},
{"error_message", ""},
{"has_errors", false},
{"key", field->key},
{"label", field->label},
{"mode", "edit"},
{"submit_label", "Save field"},
{"title", "Edit field · Card Collection"},
};
addNavigationData(data, identity->session.user);
respondTemplate(templates_, "game_field_form.html", data, response);
}
void App::handleGameFieldUpdate(
const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, false);
if(!identity || !verifyCsrf(request, *identity, response))
{
return;
}
if(!enforceTextBudget(request, response))
{
return;
}
auto short_name = pathParameter(request, "short");
auto id_text = pathParameter(request, "id");
auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
auto revision = positiveParameter(request, "game_revision");
auto label = scalarParameter(request, "label");
if(!short_name || !field_id || !revision || !label)
{
if(!short_name || !field_id)
{
respondNotFound(response);
}
else
{
respondOperationError(
response, !revision ? revision.error() : label.error(),
"Invalid field form");
}
return;
}
const std::vector<std::string> choices =
request.get_param_values("choice");
auto updated = game_service_->updateField(
identity->session.user.id, *short_name, *field_id, *revision,
*label, choices);
if(!updated)
{
const auto* validation =
updated.error().as<GameDefinitionValidationError>();
if(validation != nullptr)
{
respondGameFieldDefinitionError(
response,
*validation,
*identity,
*short_name,
*field_id,
"",
*label,
"",
choices);
return;
}
respondMutationError(
response,
updated.error(),
"Failed field update",
urlFor(
"game-field-edit",
{*short_name, std::to_string(*field_id)}),
identity->session.user);
return;
}
response.status = 303;
response.set_header("Location", urlFor("game-edit", {*short_name}));
}
void App::handleGameFieldDeleteConfirm(
const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, true);
AuthorizationService authorization;
if(!identity)
{
return;
}
if(!authorization.canAdminister(identity->session.user))
{
response.status = 403;
return;
}
auto short_name = pathParameter(request, "short");
auto id_text = pathParameter(request, "id");
auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
if(!short_name || !field_id)
{
respondNotFound(response);
return;
}
auto definition = data_source_->getGameDefinition(
*short_name, GameContentScope::INCLUDE_INTERNAL);
if(!definition || !*definition)
{
if(!definition)
{
respondInternalError(response);
}
else
{
respondNotFound(response);
}
return;
}
const auto field = std::ranges::find_if(
(**definition).fields,
[field_id](const GameField& candidate)
{
return candidate.id == *field_id;
});
if(field == (**definition).fields.end())
{
respondNotFound(response);
return;
}
inja::json data = {
{"action_url", urlFor(
"game-field-delete", {*short_name, std::to_string(*field_id)})},
{"back_url", urlFor(
"game-field-edit", {*short_name, std::to_string(*field_id)})},
{"csrf_token", identity->session.csrf_token},
{"field_label", field->label},
{"game_revision", (**definition).game.revision},
{"title", "Delete field · Card Collection"},
};
addNavigationData(data, identity->session.user);
respondTemplate(templates_, "game_field_delete.html", data, response);
}
void App::handleGameFieldDelete(
const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, false);
if(!identity || !verifyCsrf(request, *identity, response))
{
return;
}
if(!enforceTextBudget(request, response))
{
return;
}
auto short_name = pathParameter(request, "short");
auto id_text = pathParameter(request, "id");
auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
auto revision = positiveParameter(request, "game_revision");
if(!short_name || !field_id || !revision)
{
if(!short_name || !field_id)
{
respondNotFound(response);
}
else
{
respondOperationError(
response, revision.error(), "Invalid field deletion");
}
return;
}
auto deleted = game_service_->removeField(
identity->session.user.id, *short_name, *field_id, *revision);
if(!deleted)
{
respondMutationError(
response,
deleted.error(),
"Failed field delete",
urlFor(
"game-field-edit",
{*short_name, std::to_string(*field_id)}),
identity->session.user);
return;
}
response.status = 303;
response.set_header("Location", urlFor("game-edit", {*short_name}));
}
void App::handleGameFieldOrder(
const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, false);
if(!identity || !verifyCsrf(request, *identity, response))
{
return;
}
if(!enforceTextBudget(request, response))
{
return;
}
auto short_name = pathParameter(request, "short");
auto revision = positiveParameter(request, "game_revision");
std::vector<std::int64_t> field_ids;
for(const std::string& text : request.get_param_values("field_id"))
{
auto field_id = parsePositiveId(text);
if(!field_id)
{
response.status = 422;
response.set_content("Invalid field order\n", "text/plain");
return;
}
field_ids.push_back(*field_id);
}
if(!short_name || !revision)
{
if(!short_name)
{
respondNotFound(response);
}
else
{
respondOperationError(
response, revision.error(), "Invalid field order");
}
return;
}
auto reordered = game_service_->reorderFields(
identity->session.user.id, *short_name, *revision, field_ids);
if(!reordered)
{
respondMutationError(
response,
reordered.error(),
"Failed reorder",
urlFor("game-edit", {*short_name}),
identity->session.user);
return;
}
response.status = 303;
response.set_header("Location", urlFor("game-edit", {*short_name}));
}
void App::handleAdminPromote(const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, false);
if(!identity || !verifyCsrf(request, *identity, response))
{
return;
}
const auto parameter = request.path_params.find("id");
const auto target = parameter == request.path_params.end()
? std::nullopt
: parsePositiveId(parameter->second);
if(!target)
{
respondNotFound(response);
return;
}
auto promoted = user_service_->promote(
identity->session.user.id, *target);
if(!promoted)
{
respondOperationError(response, promoted.error(), "Failed promotion");
return;
}
response.status = 303;
response.set_header("Location", urlFor("admin-users"));
}
void App::handleCardNew(
const Request& request,
Response& response)
{
auto identity = requireIdentity(request, response, true);
AuthorizationService authorization;
if(!identity)
{
return;
}
if(!authorization.canCreateCard(identity->session.user))
{
response.status = 403;
return;
}
inja::json games = inja::json::array();
const GameContentScope scope = authorization.gameContentScope(
identity->session.user);
auto definitions = loadGameDefinitions(*data_source_, scope);
if(!definitions)
{
spdlog::error(
"Failed to load games for card form: {}",
definitions.error().msg());
respondInternalError(response);
return;
}
for(const GameDefinitionSnapshot& definition : *definitions)
{
games.push_back(gameFormJson(definition, {}));
}
inja::json series = inja::json::array();
auto series_result = data_source_->getSeries(scope);
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},
});
}
inja::json template_data = {
{"action_url", urlFor("cards")},
{"back_url",
urlFor(identity->session.user.role == UserRole::ADMINISTRATOR
? "card-index"
: "creator-cards")},
{"csrf_token", identity->session.csrf_token},
{"display_id", "New addition"},
{"foil_action", "keep"},
{"foil_url", ""},
{"front_url", urlFor("static", {"card_placeholder.svg"})},
{"error_control_id", ""},
{"error_message", ""},
{"files_need_reselection", false},
{"game_revision", 0},
{"games", std::move(games)},
{"has_errors", false},
{"has_foil", false},
{"heading", "Create card"},
{"long_description", ""},
{"mode", "create"},
{"model_url", urlFor("static", {"foil/model/card.obj"})},
{"name", ""},
{"preview_script_url",
urlFor("static", {"foil/card_preview.js"})},
{"shader_fragment_url",
urlFor("static", {"foil/frag-shader.glsl"})},
{"shader_vertex_url",
urlFor("static", {"foil/vert-shader.glsl"})},
{"spectral_lut_url",
urlFor("static", {"foil/spectral_xyz.bin"})},
{"rarity", 0},
{"show_rarity",
identity->session.user.role == UserRole::ADMINISTRATOR},
{"revision", 0},
{"selected_game", ""},
{"series", std::move(series)},
{"short_description", ""},
{"submit_label", "Create card"},
{"thumbnail_long_side", config_.thumbnail_long_side},
{"title", "Create card · Card Collection"},
};
addNavigationData(template_data, identity->session.user);
try
{
response.status = 200;
response.set_content(
templates_.render(card_form_template_, template_data),
"text/html; charset=utf-8");
}
catch(const std::exception& error)
{
spdlog::error(
"Failed to render the create-card form: {}",
error.what());
respondInternalError(response);
}
}
void App::handleCardEdit(
const Request& request,
Response& response)
{
auto actor = requireIdentity(request, response, true);
if(!actor)
{
return;
}
const auto id_parameter = request.path_params.find("id");
if(id_parameter == request.path_params.end())
{
respondNotFound(response);
return;
}
auto identity = parsePublicId(id_parameter->second);
if(!identity)
{
respondNotFound(response);
return;
}
AuthorizationService authorization;
const GameContentScope scope = authorization.gameContentScope(
actor->session.user);
auto card_result = data_source_->getCard(*identity, scope);
if(!card_result)
{
spdlog::error(
"Failed to load card {} for editing: {}",
id_parameter->second,
card_result.error().msg());
respondInternalError(response);
return;
}
if(!*card_result)
{
respondNotFound(response);
return;
}
const Card& card = **card_result;
if(!authorization.canEditCard(actor->session.user, card))
{
respondNotFound(response);
return;
}
auto public_id_result = formatPublicId(card.identity);
if(!public_id_result)
{
respondInternalError(response);
return;
}
const std::string& public_id = *public_id_result;
const std::string front_name =
"front-art." + card.front_extension;
const std::string front_url = urlFor(
"card-asset",
{public_id + "/" + front_name},
{{"v", std::to_string(card.revision)}});
std::string foil_url;
if(card.foil_extension)
{
foil_url = urlFor(
"card-asset",
{public_id + "/foil." + *card.foil_extension},
{{"v", std::to_string(card.revision)}});
}
std::vector<GameFieldValue> current_game_values;
std::optional<GameDefinitionSnapshot> selected_game;
if(card.identity.game_short_name)
{
auto values = data_source_->getCardFieldValues(card.id, scope);
if(!values)
{
spdlog::error(
"Failed to load game fields for card {}: {}",
card.id,
values.error().msg());
respondInternalError(response);
return;
}
if(!*values)
{
respondInternalError(response);
return;
}
selected_game = std::move((**values).definition);
current_game_values = std::move((**values).values);
}
inja::json games = inja::json::array();
auto definitions = loadGameDefinitions(*data_source_, scope);
if(!definitions)
{
respondInternalError(response);
return;
}
for(const GameDefinitionSnapshot& definition : *definitions)
{
const bool selected = card.identity.game_short_name &&
definition.game.short_name == *card.identity.game_short_name;
const GameDefinitionSnapshot& displayed_definition =
selected && selected_game ? *selected_game : definition;
games.push_back(gameFormJson(
displayed_definition,
selected ? current_game_values : std::vector<GameFieldValue>{}));
}
auto all_series = data_source_->getSeries(scope);
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()},
});
}
inja::json template_data = {
{"action_url", urlFor("card-edit", {public_id})},
{"back_url", urlFor("card", {public_id})},
{"csrf_token", actor->session.csrf_token},
{"display_id", uppercaseAscii(public_id)},
{"foil_action", "keep"},
{"foil_url", foil_url},
{"front_url", front_url},
{"error_control_id", ""},
{"error_message", ""},
{"files_need_reselection", false},
{"games", std::move(games)},
{"has_errors", false},
{"has_foil", card.foil_extension.has_value()},
{"heading", "Edit card"},
{"long_description", card.long_description.value_or("")},
{"mode", "edit"},
{"model_url", urlFor("static", {"foil/model/card.obj"})},
{"name", card.name},
{"preview_script_url",
urlFor("static", {"foil/card_preview.js"})},
{"rarity", card.rarity},
{"show_rarity",
actor->session.user.role == UserRole::ADMINISTRATOR},
{"revision", card.revision},
{"game_revision", selected_game
? selected_game->game.revision
: 0},
{"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",
urlFor("static", {"foil/vert-shader.glsl"})},
{"short_description", card.short_description.value_or("")},
{"spectral_lut_url",
urlFor("static", {"foil/spectral_xyz.bin"})},
{"submit_label", "Save changes"},
{"thumbnail_long_side", config_.thumbnail_long_side},
{"title", "Edit " + card.name + " · Card Collection"},
};
addNavigationData(template_data, actor->session.user);
try
{
response.status = 200;
response.set_content(
templates_.render(card_form_template_, template_data),
"text/html; charset=utf-8");
}
catch(const std::exception& error)
{
spdlog::error(
"Failed to render the edit form for card {}: {}",
card.id,
error.what());
respondInternalError(response);
}
}
void App::handleCardCreate(
const Request& request,
Response& response,
const ContentReader& content_reader)
{
auto actor = requireIdentity(request, response, false);
AuthorizationService authorization;
if(!actor)
{
return;
}
if(!authorization.canCreateCard(actor->session.user))
{
response.status = 403;
return;
}
if(!request.is_multipart_form_data())
{
respondBadRequest(response, "Expected a multipart form upload");
return;
}
MultipartReader multipart_reader(config_.card_storage_root);
auto upload = multipart_reader.read(content_reader);
if(!upload)
{
respondMutationError(
response,
upload.error(),
"Failed to receive a card upload",
urlFor("card-new"),
actor->session.user,
true);
return;
}
const auto csrf = upload->fields.find("csrf_token");
if(csrf == upload->fields.end() ||
!constantTimeEqual(csrf->second, actor->session.csrf_token))
{
response.status = 403;
response.set_content("Invalid CSRF token\n", "text/plain");
return;
}
const auto game = upload->fields.find("game");
const std::string game_short_name = game == upload->fields.end()
? 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" &&
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()
: std::string(mw::strip(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;
}
const bool rarity_was_submitted = upload->fields.contains("rarity");
mw::E<std::int64_t> rarity = std::int64_t{0};
if(rarity_was_submitted)
{
rarity = parseRarity(upload->fields);
}
if(!rarity)
{
respondBadRequest(response, rarity.error().msg());
return;
}
CreateCardInput input = {
name,
optionalText(upload->fields, "short_description"),
optionalText(upload->fields, "long_description"),
*rarity,
upload->staging_directory,
*upload->front,
upload->foil,
upload->thumbnail,
rarity_was_submitted,
};
mw::E<std::string> created = std::unexpected(
mw::runtimeError("Card creation was not dispatched"));
if(game_short_name.empty())
{
if(upload->fields.contains("game_revision") ||
!gameFields(upload->fields).empty() ||
!upload->series_ids.empty())
{
respondMutationError(
response,
mw::httpError(
422,
"Loose cards cannot have game fields or series"),
"Failed to validate loose card fields",
urlFor("card-new"),
actor->session.user,
true);
return;
}
created = card_service_->createLooseCard(
actor->session.user.id, std::move(input));
}
else
{
auto game_revision = parseGameRevision(upload->fields);
if(!game_revision)
{
respondMutationError(
response,
game_revision.error(),
"Failed to parse game revision",
urlFor("card-new"),
actor->session.user,
true);
return;
}
auto memberships = seriesMemberships(upload->series_ids);
if(!memberships)
{
respondMutationError(
response,
memberships.error(),
"Failed to validate series memberships",
urlFor("card-new"),
actor->session.user,
true);
return;
}
created = card_service_->createGameCard(
actor->session.user.id,
std::move(input),
game_short_name,
*game_revision,
gameFields(upload->fields),
*memberships);
}
if(!created)
{
const auto* field_error =
created.error().as<GameFieldValidationError>();
if(field_error != nullptr)
{
respondCardFieldError(
response, *field_error, *upload, *actor);
return;
}
respondMutationError(
response,
created.error(),
"Failed to create a card",
urlFor("card-new"),
actor->session.user,
true);
return;
}
response.status = 303;
response.set_header("Location", urlFor("card", {*created}));
}
void App::handleCardUpdate(
const Request& request,
Response& response,
const ContentReader& content_reader)
{
auto actor = requireIdentity(request, response, false);
if(!actor)
{
return;
}
const auto id_parameter = request.path_params.find("id");
if(id_parameter == request.path_params.end())
{
respondNotFound(response);
return;
}
auto identity = parsePublicId(id_parameter->second);
if(!identity)
{
respondNotFound(response);
return;
}
AuthorizationService authorization;
const GameContentScope scope = authorization.gameContentScope(
actor->session.user);
auto card_result = data_source_->getCard(*identity, scope);
if(!card_result)
{
spdlog::error(
"Failed to load card {} for update: {}",
id_parameter->second,
card_result.error().msg());
respondInternalError(response);
return;
}
if(!*card_result)
{
respondNotFound(response);
return;
}
if(!authorization.canEditCard(actor->session.user, **card_result))
{
respondNotFound(response);
return;
}
const Card form_card = **card_result;
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)
{
respondMutationError(
response,
upload.error(),
"Failed to receive a card edit",
urlFor("card-edit", {id_parameter->second}),
actor->session.user,
true);
return;
}
const auto csrf = upload->fields.find("csrf_token");
if(csrf == upload->fields.end() ||
!constantTimeEqual(csrf->second, actor->session.csrf_token))
{
response.status = 403;
response.set_content("Invalid CSRF token\n", "text/plain");
return;
}
if(upload->fields.contains("game"))
{
respondBadRequest(response, "Card identity cannot be edited");
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()
: std::string(mw::strip(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;
}
const bool rarity_was_submitted = upload->fields.contains("rarity");
mw::E<std::int64_t> rarity = (**card_result).rarity;
if(rarity_was_submitted)
{
rarity = parseRarity(upload->fields);
}
if(!rarity)
{
respondBadRequest(response, rarity.error().msg());
return;
}
auto revision = parseRevision(upload->fields);
if(!revision)
{
respondBadRequest(response, revision.error().msg());
return;
}
const auto front_position = upload->fields.find("front_action");
FrontAssetAction front_action;
if(front_position == upload->fields.end())
{
respondBadRequest(response, "Front artwork action is required");
return;
}
if(front_position->second == "keep")
{
front_action = FrontAssetAction::KEEP;
}
else if(front_position->second == "replace")
{
front_action = FrontAssetAction::REPLACE;
}
else
{
respondBadRequest(response, "Unknown front artwork action");
return;
}
const auto foil_position = upload->fields.find("foil_action");
FoilAssetAction foil_action;
if(foil_position == upload->fields.end())
{
respondBadRequest(response, "Foil control action is required");
return;
}
if(foil_position->second == "keep")
{
foil_action = FoilAssetAction::KEEP;
}
else if(foil_position->second == "replace")
{
foil_action = FoilAssetAction::REPLACE;
}
else if(foil_position->second == "remove")
{
foil_action = FoilAssetAction::REMOVE;
}
else
{
respondBadRequest(response, "Unknown foil control action");
return;
}
UpdateLooseCardInput input = {
std::move(**card_result),
*revision,
name,
optionalText(upload->fields, "short_description"),
optionalText(upload->fields, "long_description"),
*rarity,
upload->staging_directory,
front_action,
foil_action,
upload->front,
upload->foil,
upload->thumbnail,
rarity_was_submitted,
};
mw::E<std::string> updated = std::unexpected(
mw::runtimeError("Card update was not dispatched"));
if(!input.current_card.identity.game_short_name)
{
if(upload->fields.contains("game_revision") ||
!gameFields(upload->fields).empty() ||
!upload->series_ids.empty())
{
respondMutationError(
response,
mw::httpError(
422,
"Loose cards cannot have game fields or series"),
"Failed to validate loose card fields",
urlFor("card-edit", {id_parameter->second}),
actor->session.user,
true);
return;
}
updated = card_service_->updateLooseCard(
actor->session.user.id, std::move(input));
}
else
{
auto game_revision = parseGameRevision(upload->fields);
if(!game_revision)
{
respondMutationError(
response,
game_revision.error(),
"Failed to parse game revision",
urlFor("card-edit", {id_parameter->second}),
actor->session.user,
true);
return;
}
auto membership_ids = seriesMemberships(upload->series_ids);
if(!membership_ids)
{
respondMutationError(
response,
membership_ids.error(),
"Failed to validate series memberships",
urlFor("card-edit", {id_parameter->second}),
actor->session.user,
true);
return;
}
updated = card_service_->updateGameCard(
actor->session.user.id,
std::move(input),
*game_revision,
gameFields(upload->fields),
*membership_ids);
}
if(!updated)
{
const auto* field_error =
updated.error().as<GameFieldValidationError>();
if(field_error != nullptr)
{
respondCardFieldError(
response, *field_error, *upload, *actor, &form_card);
return;
}
respondMutationError(
response,
updated.error(),
"Failed to update a card",
urlFor("card-edit", {id_parameter->second}),
actor->session.user,
true);
return;
}
response.status = 303;
response.set_header("Location", urlFor("card", {*updated}));
}
void App::handleCardView(
const Request& request,
Response& response)
{
auto actor = requireIdentity(request, response, true);
if(!actor)
{
return;
}
const auto id_parameter = request.path_params.find("id");
if(id_parameter == request.path_params.end())
{
respondNotFound(response);
return;
}
auto identity = parsePublicId(id_parameter->second);
if(!identity)
{
respondNotFound(response);
return;
}
AuthorizationService authorization;
const GameContentScope scope = authorization.gameContentScope(
actor->session.user);
auto card_result = data_source_->getCard(*identity, scope);
if(!card_result)
{
spdlog::error(
"Failed to load card {}: {}",
id_parameter->second,
card_result.error().msg());
respondInternalError(response);
return;
}
if(!*card_result)
{
respondNotFound(response);
return;
}
const Card& card = **card_result;
auto owns = data_source_->userOwnsCard(actor->session.user.id, card.id);
if(!owns)
{
respondInternalError(response);
return;
}
if(!authorization.canViewCard(actor->session.user, card, *owns))
{
respondNotFound(response);
return;
}
auto pool_cards = data_source_->getPoolCards();
if(!pool_cards)
{
respondInternalError(response);
return;
}
CardPoolService pool_service;
const auto pool = pool_service.calculate(*pool_cards);
std::string probability = "Not currently in the pull pool";
const auto pool_entry = std::ranges::find_if(
pool,
[&card](const CardPoolEntry& entry)
{
return entry.card.id == card.id;
});
if(pool_entry != pool.end())
{
probability = formatProbability(pool_entry->probability);
}
auto public_id_result = formatPublicId(card.identity);
if(!public_id_result)
{
spdlog::error(
"Failed to format card {}: {}",
card.id,
public_id_result.error().msg());
respondInternalError(response);
return;
}
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-art." + card.front_extension;
const bool front_exists = isRegularFile(
asset_root / front_name, card.id);
const std::string front_url = front_exists
? urlFor(
"card-asset",
{public_id + "/" + front_name},
{{"v", std::to_string(card.revision)}})
: urlFor("static", {"card_placeholder.svg"});
std::optional<std::string> foil_url;
bool foil_exists = false;
if(card.foil_extension)
{
const std::string foil_name = "foil." + *card.foil_extension;
foil_exists = isRegularFile(asset_root / foil_name, card.id);
if(foil_exists)
{
foil_url = urlFor(
"card-asset",
{public_id + "/" + foil_name},
{{"v", std::to_string(card.revision)}});
}
}
inja::json game_fields = inja::json::array();
std::string game_name = "Loose card";
if(card.identity.game_short_name)
{
auto fields = data_source_->getCardFieldValues(card.id, scope);
if(!fields)
{
spdlog::error(
"Failed to load game fields for card {}: {}",
card.id, fields.error().msg());
respondInternalError(response);
return;
}
if(!*fields)
{
respondInternalError(response);
return;
}
game_name = (**fields).definition.game.display_name;
for(const GameFieldValue& value : (**fields).values)
{
const auto definition = std::ranges::find_if(
(**fields).definition.fields,
[&value](const GameField& field)
{
return field.id == value.field_id;
});
if(definition == (**fields).definition.fields.end())
{
respondInternalError(response);
return;
}
game_fields.push_back({
{"label", definition->label},
{"value", fieldValue({value}, value.field_id)},
});
}
}
auto membership_result = data_source_->getCardSeries(card.id);
if(!membership_result)
{
spdlog::error(
"Failed to load series for card {}: {}",
card.id,
membership_result.error().msg());
respondInternalError(response);
return;
}
inja::json series = inja::json::array();
for(std::int64_t series_id : *membership_result)
{
auto series_result = data_source_->getSeries(series_id, scope);
if(!series_result)
{
spdlog::error(
"Failed to load series {} for card {}: {}",
series_id,
card.id,
series_result.error().msg());
respondInternalError(response);
return;
}
if(*series_result)
{
series.push_back((**series_result).name);
}
}
std::vector<std::string> missing_assets;
if(!front_exists)
{
missing_assets.emplace_back("front artwork");
}
if(card.foil_extension && !foil_exists)
{
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)});
}
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", 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", long_description_marker},
{"missing_assets", missing_assets},
{"model_url", urlFor("static", {"foil/model/card.obj"})},
{"name", card.name},
{"preview_script_url",
urlFor("static", {"foil/card_preview.js"})},
{"probability", probability},
{"rarity", card.rarity},
{"series", std::move(series)},
{"shader_fragment_url",
urlFor("static", {"foil/frag-shader.glsl"})},
{"shader_vertex_url",
urlFor("static", {"foil/vert-shader.glsl"})},
{"short_description", short_description_marker},
{"spectral_lut_url",
urlFor("static", {"foil/spectral_xyz.bin"})},
{"title", card.name + " · Card Collection"},
};
addNavigationData(template_data, actor->session.user);
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)
{
auto actor = requireIdentity(request, response, true);
AuthorizationService authorization;
if(!actor)
{
return;
}
if(!authorization.canDeleteCard(actor->session.user))
{
response.status = 403;
return;
}
const auto parameter = request.path_params.find("id");
if(parameter == request.path_params.end())
{
respondNotFound(response);
return;
}
auto identity = parsePublicId(parameter->second);
if(!identity)
{
respondNotFound(response);
return;
}
auto card = data_source_->getCard(
*identity, GameContentScope::INCLUDE_INTERNAL);
if(!card || !*card)
{
if(!card)
{
spdlog::error(
"Failed to load card for deletion: {}",
card.error().msg());
respondInternalError(response);
}
else
{
respondNotFound(response);
}
return;
}
inja::json template_data = {
{"action_url", urlFor("card-delete", {parameter->second})},
{"back_url", urlFor("card", {parameter->second})},
{"csrf_token", actor->session.csrf_token},
{"name", (**card).name},
{"title", "Delete card · Card Collection"},
};
addNavigationData(template_data, actor->session.user);
try
{
response.status = 200;
response.set_content(
templates_.render(card_delete_template_, template_data),
"text/html; charset=utf-8");
}
catch(const std::exception& error)
{
spdlog::error("Failed to render card deletion: {}", error.what());
respondInternalError(response);
}
}
void App::handleCardDelete(
const Request& request,
Response& response)
{
auto actor = requireIdentity(request, response, false);
if(!actor || !verifyCsrf(request, *actor, response))
{
return;
}
const auto parameter = request.path_params.find("id");
if(parameter == request.path_params.end())
{
respondNotFound(response);
return;
}
auto identity = parsePublicId(parameter->second);
if(!identity)
{
respondNotFound(response);
return;
}
auto card = data_source_->getCard(
*identity, GameContentScope::INCLUDE_INTERNAL);
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(
actor->session.user.id, **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,
const QueryParameters& query) const
{
const RouteDefinition& route = routeDefinition(name);
if(route.kind == RouteKind::STATIC_MOUNT)
{
if(arguments.size() != 1)
{
throw std::invalid_argument(
"Incorrect static route argument count");
}
return url_builder_.absoluteFromRelativePath(
route.segments, arguments.front(), query);
}
return url_builder_.absolute(
resolveRoute(route, arguments, RouteSegmentKind::DYNAMIC), query);
}
void App::handleCardIndex(
const Request& request,
Response& response)
{
auto actor = requireIdentity(request, response, true);
AuthorizationService authorization;
if(!actor)
{
return;
}
const bool creator_route = request.path == getPath("creator-cards");
if((creator_route &&
!authorization.canCreateCard(actor->session.user)) ||
(!creator_route &&
!authorization.canAdminister(actor->session.user)))
{
response.status = 403;
return;
}
auto cards_result = creator_route
? data_source_->getCardsByCreator(
actor->session.user.id, GameContentScope::PUBLIC_ONLY)
: data_source_->getCards(GameContentScope::INCLUDE_INTERNAL);
if(!cards_result)
{
spdlog::error(
"Failed to load the card index: {}",
cards_result.error().msg());
respondInternalError(response);
return;
}
std::vector<IndexCard> cards;
cards.reserve(cards_result->size());
for(const Card& card : *cards_result)
{
auto public_id = formatPublicId(card.identity);
if(!public_id)
{
spdlog::error(
"Failed to format card {} for the index: {}",
card.id,
public_id.error().msg());
respondInternalError(response);
return;
}
cards.push_back({&card, std::move(*public_id)});
}
const bool descending =
request.has_param("direction") &&
request.get_param_value("direction") == "desc";
sortIndexCards(cards, descending);
std::unordered_map<std::string, GameVisibility> game_visibility;
if(!creator_route)
{
auto games = data_source_->getGames(
GameContentScope::INCLUDE_INTERNAL);
if(!games)
{
respondInternalError(response);
return;
}
for(const Game& game : *games)
{
game_visibility.emplace(game.short_name, game.visibility);
}
}
inja::json template_cards = inja::json::array();
for(const IndexCard& index_card : cards)
{
const Card& card = *index_card.card;
const std::string thumbnail_name =
"thumb." + card.thumbnail_extension;
const std::filesystem::path thumbnail_path =
config_.card_storage_root / "published" /
index_card.public_id / thumbnail_name;
std::string thumbnail_url;
std::error_code filesystem_error;
const bool thumbnail_exists = std::filesystem::is_regular_file(
thumbnail_path, filesystem_error);
if(filesystem_error &&
filesystem_error != std::errc::no_such_file_or_directory)
{
spdlog::warn(
"Failed to inspect the thumbnail for card {}: {}",
card.id,
filesystem_error.message());
}
if(thumbnail_exists)
{
thumbnail_url = urlFor(
"card-asset",
{index_card.public_id + "/" + thumbnail_name},
{{"v", std::to_string(card.revision)}});
}
else
{
thumbnail_url = urlFor("static", {"card_placeholder.svg"});
}
bool internal = false;
if(!creator_route && card.identity.game_short_name)
{
const auto visibility = game_visibility.find(
*card.identity.game_short_name);
if(visibility == game_visibility.end())
{
respondInternalError(response);
return;
}
internal = visibility->second == GameVisibility::INTERNAL;
}
template_cards.push_back({
{"display_id", uppercaseAscii(index_card.public_id)},
{"internal", internal},
{"name", card.name},
{"thumbnail_url", std::move(thumbnail_url)},
{"url", urlFor("card", {index_card.public_id})},
});
}
const std::string index_route = creator_route
? "creator-cards"
: "card-index";
inja::json template_data = {
{"ascending_url",
urlFor(index_route, {}, {{"sort", "id"}, {"direction", "asc"}})},
{"administrator", !creator_route},
{"cards", std::move(template_cards)},
{"create_url", urlFor("card-new")},
{"descending", descending},
{"descending_url",
urlFor(index_route, {}, {{"sort", "id"}, {"direction", "desc"}})},
{"games_url", urlFor("admin-games")},
{"series_url", urlFor("series-index")},
{"title", "Card Collection"},
{"users_url", urlFor("admin-users")},
};
addNavigationData(template_data, actor->session.user);
try
{
response.status = 200;
response.set_content(
templates_.render(card_index_template_, template_data),
"text/html; charset=utf-8");
}
catch(const std::exception& error)
{
spdlog::error("Failed to render the card index: {}", error.what());
respondInternalError(response);
}
}
void App::handleSeriesIndex(
const Request& request,
Response& response)
{
auto actor = requireIdentity(request, response, true);
AuthorizationService authorization;
if(!actor)
{
return;
}
if(!authorization.canAdminister(actor->session.user))
{
response.status = 403;
return;
}
auto series_result = data_source_->getSeries(
GameContentScope::INCLUDE_INTERNAL);
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;
auto games_result = data_source_->getGames(
GameContentScope::INCLUDE_INTERNAL);
if(!games_result)
{
respondInternalError(response);
return;
}
for(const Series& item : *series_result)
{
const auto game = std::ranges::find_if(
*games_result,
[&item](const Game& candidate)
{
return candidate.short_name == 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 == games_result->end()
? uppercaseAscii(item.game_short_name)
: game->display_name},
{"internal", game != games_result->end() &&
game->visibility == GameVisibility::INTERNAL},
{"name", item.name},
});
}
inja::json template_data = {
{"can_create", !games_result->empty()},
{"create_url", urlFor("series-new")},
{"series", std::move(series)},
{"title", "Series · Card Collection"},
};
addNavigationData(template_data, actor->session.user);
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(
const Request& request,
Response& response)
{
auto actor = requireIdentity(request, response, true);
AuthorizationService authorization;
if(!actor)
{
return;
}
if(!authorization.canAdminister(actor->session.user))
{
response.status = 403;
return;
}
inja::json games = inja::json::array();
auto games_result = data_source_->getGames(
GameContentScope::INCLUDE_INTERNAL);
if(!games_result)
{
respondInternalError(response);
return;
}
for(const Game& game : *games_result)
{
games.push_back({
{"display_name", game.display_name},
{"internal", game.visibility == GameVisibility::INTERNAL},
{"short_name", game.short_name},
});
}
inja::json template_data = {
{"action_url", urlFor("series")},
{"back_url", urlFor("series-index")},
{"csrf_token", actor->session.csrf_token},
{"description", ""},
{"game", ""},
{"games", std::move(games)},
{"heading", "Create series"},
{"mode", "create"},
{"name", ""},
{"submit_label", "Create series"},
{"title", "Create series · Card Collection"},
};
addNavigationData(template_data, actor->session.user);
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)
{
auto actor = requireIdentity(request, response, false);
if(!actor || !verifyCsrf(request, *actor, response))
{
return;
}
if(!request.has_param("game") || !request.has_param("name"))
{
respondBadRequest(response, "Game and series name are required");
return;
}
auto created = series_service_->create(
actor->session.user.id,
request.get_param_value("game"),
request.get_param_value("name"),
request.has_param("description")
? 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)
{
auto actor = requireIdentity(request, response, true);
AuthorizationService authorization;
if(!actor)
{
return;
}
if(!authorization.canAdminister(actor->session.user))
{
response.status = 403;
return;
}
const auto parameter = request.path_params.find("id");
const auto id = parameter == request.path_params.end()
? std::nullopt
: parsePositiveId(parameter->second);
if(!id)
{
respondNotFound(response);
return;
}
auto series_result = data_source_->getSeries(
*id, GameContentScope::INCLUDE_INTERNAL);
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;
auto game = data_source_->getGameDefinition(
series.game_short_name, GameContentScope::INCLUDE_INTERNAL);
if(!game || !*game)
{
respondInternalError(response);
return;
}
inja::json template_data = {
{"action_url", urlFor("series-item", {std::to_string(*id)})},
{"back_url", urlFor("series-index")},
{"csrf_token", actor->session.csrf_token},
{"description", series.description},
{"game", (**game).game.display_name},
{"games", inja::json::array()},
{"heading", "Edit series"},
{"mode", "edit"},
{"name", series.name},
{"submit_label", "Save changes"},
{"title", "Edit " + series.name + " · Card Collection"},
};
addNavigationData(template_data, actor->session.user);
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)
{
auto actor = requireIdentity(request, response, false);
if(!actor || !verifyCsrf(request, *actor, response))
{
return;
}
const auto parameter = request.path_params.find("id");
const auto id = parameter == request.path_params.end()
? std::nullopt
: parsePositiveId(parameter->second);
if(!id || !request.has_param("name"))
{
respondBadRequest(response, "Valid series ID and name are required");
return;
}
auto updated = series_service_->update(
actor->session.user.id,
*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)
{
auto actor = requireIdentity(request, response, true);
AuthorizationService authorization;
if(!actor)
{
return;
}
if(!authorization.canAdminister(actor->session.user))
{
response.status = 403;
return;
}
const auto parameter = request.path_params.find("id");
const auto id = parameter == request.path_params.end()
? std::nullopt
: parsePositiveId(parameter->second);
if(!id)
{
respondNotFound(response);
return;
}
auto series_result = data_source_->getSeries(
*id, GameContentScope::INCLUDE_INTERNAL);
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;
}
inja::json template_data = {
{"action_url", urlFor("series-delete", {std::to_string(*id)})},
{"back_url", urlFor("series-index")},
{"csrf_token", actor->session.csrf_token},
{"name", (**series_result).name},
{"title", "Delete series · Card Collection"},
};
addNavigationData(template_data, actor->session.user);
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)
{
auto actor = requireIdentity(request, response, false);
if(!actor || !verifyCsrf(request, *actor, response))
{
return;
}
const auto parameter = request.path_params.find("id");
const auto id = parameter == request.path_params.end()
? std::nullopt
: parsePositiveId(parameter->second);
if(!id)
{
respondBadRequest(response, "A valid series ID is required");
return;
}
auto removed = series_service_->remove(actor->session.user.id, *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()
{
server.set_post_routing_handler(
[]([[maybe_unused]] const httplib::Request& request,
httplib::Response& response)
{
if(!response.has_header("Content-Security-Policy"))
{
response.set_header(
"Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; "
"script-src 'self' 'unsafe-inline'; style-src 'self'; "
"object-src 'none'; base-uri 'self'; "
"frame-ancestors 'none'");
}
if(!response.has_header("Referrer-Policy"))
{
response.set_header("Referrer-Policy", "same-origin");
}
response.set_header("X-Content-Type-Options", "nosniff");
});
const std::filesystem::path published_cards =
config_.card_storage_root / "published";
if(!server.set_mount_point(
getMountPath("static"), config_.static_root.string()))
{
spdlog::error("Failed to mount application static files");
}
if(!server.set_mount_point(
getMountPath("card-asset"), published_cards.string()))
{
spdlog::error("Failed to mount published card assets");
}
server.Get(
getPath("welcome"),
std::bind_front(&App::handleWelcome, this));
server.Get(
getPath("authentication"),
std::bind_front(&App::handleAuthentication, this));
server.Post(
getPath("authentication-email"),
std::bind_front(&App::handleAuthenticationEmail, this));
server.Get(
getPath("authentication-sent"),
std::bind_front(&App::handleAuthenticationSent, this));
server.Get(
getPath("authentication-confirm", {"token"}),
std::bind_front(&App::handleAuthenticationConfirm, this));
server.Post(
getPath("authentication-confirm", {"token"}),
std::bind_front(&App::handleAuthenticationConfirmPost, this));
server.Post(
getPath("logout"),
std::bind_front(&App::handleLogout, this));
server.Get(
getPath("onboarding"),
std::bind_front(&App::handleOnboarding, this));
server.Post(
getPath("onboarding"),
std::bind_front(&App::handleOnboardingPost, this));
server.Get(
getPath("account"),
std::bind_front(&App::handleAccount, this));
server.Get(
getPath("account-username"),
std::bind_front(&App::handleAccountUsername, this));
server.Post(
getPath("account-username"),
std::bind_front(&App::handleAccountUsernamePost, this));
server.Get(
getPath("collection"),
std::bind_front(&App::handleCollection, this));
server.Post(
getPath("collection-pull"),
std::bind_front(&App::handleCollectionPull, this));
server.Get(
getPath("admin-users"),
std::bind_front(&App::handleAdminUsers, this));
server.Get(
getPath("admin-games"),
std::bind_front(&App::handleAdminGames, this));
server.Get(
getPath("game-new"),
std::bind_front(&App::handleGameNew, this));
server.Post(
getPath("games"),
std::bind_front(&App::handleGameCreate, this));
server.Get(
getPath("game-edit", {"short"}),
std::bind_front(&App::handleGameEdit, this));
server.Post(
getPath("game-update", {"short"}),
std::bind_front(&App::handleGameUpdate, this));
server.Get(
getPath("game-delete", {"short"}),
std::bind_front(&App::handleGameDeleteConfirm, this));
server.Post(
getPath("game-delete", {"short"}),
std::bind_front(&App::handleGameDelete, this));
server.Get(
getPath("game-field-new", {"short"}),
std::bind_front(&App::handleGameFieldNew, this));
server.Post(
getPath("game-fields", {"short"}),
std::bind_front(&App::handleGameFieldCreate, this));
server.Get(
getPath("game-field-edit", {"short", "id"}),
std::bind_front(&App::handleGameFieldEdit, this));
server.Post(
getPath("game-field-update", {"short", "id"}),
std::bind_front(&App::handleGameFieldUpdate, this));
server.Get(
getPath("game-field-delete", {"short", "id"}),
std::bind_front(&App::handleGameFieldDeleteConfirm, this));
server.Post(
getPath("game-field-delete", {"short", "id"}),
std::bind_front(&App::handleGameFieldDelete, this));
server.Post(
getPath("game-field-order", {"short"}),
std::bind_front(&App::handleGameFieldOrder, this));
server.Post(
getPath("admin-promote", {"id"}),
std::bind_front(&App::handleAdminPromote, this));
server.Get(
getPath("card-index"),
std::bind_front(&App::handleCardIndex, this));
server.Get(
getPath("creator-cards"),
std::bind_front(&App::handleCardIndex, this));
server.Get(
getPath("card-new"),
std::bind_front(&App::handleCardNew, this));
server.Get(
getPath("card-edit", {"id"}),
std::bind_front(&App::handleCardEdit, this));
server.Get(
getPath("card", {"id"}),
std::bind_front(&App::handleCardView, this));
server.Post(
getPath("cards"),
std::bind_front(&App::handleCardCreate, this));
server.Post(
getPath("card-edit", {"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(
const std::string& name,
const std::vector<std::string>& argument_names) const
{
const RouteDefinition& route = routeDefinition(name);
if(route.kind != RouteKind::DYNAMIC)
{
throw std::invalid_argument("Static mount has no handler path");
}
return url_builder_.requestPath(resolveRoute(
route, argument_names, RouteSegmentKind::PLACEHOLDER));
}
std::string App::getMountPath(const std::string& name) const
{
const RouteDefinition& route = routeDefinition(name);
if(route.kind != RouteKind::STATIC_MOUNT)
{
throw std::invalid_argument("Dynamic route has no mount path");
}
return url_builder_.requestPath(route.segments);
}