feat: enhance validation and serialization with fiscal code checks and JSON schema support

This commit is contained in:
gabdevele
2026-06-14 21:09:05 +02:00 Unverified
parent feef498d9a
commit 002f89bd73
10 changed files with 282 additions and 32 deletions
+60 -3
View File
@@ -1,9 +1,44 @@
#include "common/Validation.h" #include "common/Validation.h"
#include <array>
#include <cctype>
#include <regex> #include <regex>
namespace visione::validation namespace visione::validation
{ {
namespace
{
char normalizeFiscalCodeChar(char ch)
{
return static_cast<char>(
std::toupper(static_cast<unsigned char>(ch)));
}
int fiscalCodeControlValueForOdd(char ch)
{
static constexpr std::array<int, 26> letterValues{
1, 0, 5, 7, 9, 13, 15, 17, 19, 21, 2, 4, 18,
20, 11, 3, 6, 8, 12, 14, 16, 10, 22, 25, 24, 23};
static constexpr std::array<int, 10> digitValues{1, 0, 5, 7, 9,
13, 15, 17, 19, 21};
if (std::isdigit(static_cast<unsigned char>(ch)))
{
return digitValues[static_cast<size_t>(ch - '0')];
}
return letterValues[static_cast<size_t>(ch - 'A')];
}
int fiscalCodeControlValueForEven(char ch)
{
if (std::isdigit(static_cast<unsigned char>(ch)))
{
return ch - '0';
}
return ch - 'A';
}
} // namespace
bool hasString(const Json::Value &body, const std::string &key) bool hasString(const Json::Value &body, const std::string &key)
{ {
return body.isMember(key) && body[key].isString() && return body.isMember(key) && body[key].isString() &&
@@ -38,8 +73,30 @@ bool isValidEmail(const std::string &email)
bool isValidFiscalCode(const std::string &fiscalCode) bool isValidFiscalCode(const std::string &fiscalCode)
{ {
static const std::regex pattern(R"(^[A-Za-z0-9]{16}$)"); static const std::regex pattern(
return std::regex_match(fiscalCode, pattern); R"(^[A-Za-z]{6}[0-9]{2}[ABCDEHLMPRST]{1}[0-9]{2}[A-Za-z][0-9]{3}[A-Za-z]$)");
if (!std::regex_match(fiscalCode, pattern))
{
return false;
}
std::string normalized;
normalized.reserve(fiscalCode.size());
for (char ch : fiscalCode)
{
normalized.push_back(normalizeFiscalCodeChar(ch));
}
int sum = 0;
for (size_t i = 0; i < normalized.size() - 1; ++i)
{
sum += ((i + 1) % 2 == 0) ? fiscalCodeControlValueForEven(normalized[i])
: fiscalCodeControlValueForOdd(normalized[i]);
}
const char expectedControl =
static_cast<char>('A' + (sum % 26));
return normalized.back() == expectedControl;
} }
} // namespace visione::validation } // namespace visione::validation
+7 -7
View File
@@ -229,15 +229,15 @@ INSERT IGNORE INTO users (
phone, phone,
must_change_password must_change_password
) VALUES ( ) VALUES (
'System', 'Gennario',
'Admin', 'Vessio',
'admin@visione.local', 'admin@visione.gov.it',
'pbkdf2_sha256$210000$local-dev-admin-salt$A4683BDB63C1598400B6B196F53C830F8434BA645C4A4A70AB311B278223E698', 'pbkdf2_sha256$210000$a2c43ef09bd24ea883023e5f57fa6255d8dd317e312a40ddaa1bd3ccd83dbfa5$26D0C9000B603A424C7FB0DA3BE0CDC1798A9CBA34F391F9B8E4C0E1E4A58806',
'ADMINVISIONE0000', 'VSSGNR94M08A662P',
'+390000000000', '380 6180216',
TRUE TRUE
); );
INSERT IGNORE INTO user_roles (user_id, role_id) INSERT IGNORE INTO user_roles (user_id, role_id)
SELECT u.id, r.id FROM users u JOIN roles r SELECT u.id, r.id FROM users u JOIN roles r
WHERE u.email = 'admin@visione.local' AND r.name = 'admin'; WHERE u.email = 'admin@visione.gov.it' AND r.name = 'admin';
+7 -2
View File
@@ -1,4 +1,9 @@
UPDATE users UPDATE users
SET password_hash = 'pbkdf2_sha256$210000$local-dev-admin-salt$A4683BDB63C1598400B6B196F53C830F8434BA645C4A4A70AB311B278223E698', SET email = '[email protected]',
first_name = 'Gennario',
last_name = 'Vessio',
fiscal_code = 'VSSGNR94M08A662P',
phone = '380 6180216',
password_hash = 'pbkdf2_sha256$210000$a2c43ef09bd24ea883023e5f57fa6255d8dd317e312a40ddaa1bd3ccd83dbfa5$26D0C9000B603A424C7FB0DA3BE0CDC1798A9CBA34F391F9B8E4C0E1E4A58806',
must_change_password = TRUE must_change_password = TRUE
WHERE email = '[email protected]'; WHERE email IN ('[email protected]', '[email protected]');
+7 -3
View File
@@ -1,5 +1,9 @@
UPDATE users UPDATE users
SET password_hash = 'pbkdf2_sha256$210000$local-dev-admin-salt$A4683BDB63C1598400B6B196F53C830F8434BA645C4A4A70AB311B278223E698', SET email = '[email protected]',
first_name = 'Gennario',
last_name = 'Vessio',
fiscal_code = 'VSSGNR94M08A662P',
phone = '380 6180216',
password_hash = 'pbkdf2_sha256$210000$a2c43ef09bd24ea883023e5f57fa6255d8dd317e312a40ddaa1bd3ccd83dbfa5$26D0C9000B603A424C7FB0DA3BE0CDC1798A9CBA34F391F9B8E4C0E1E4A58806',
must_change_password = TRUE must_change_password = TRUE
WHERE email = '[email protected]'; WHERE email IN ('[email protected]', '[email protected]');
+41 -2
View File
@@ -6,7 +6,31 @@
namespace visione::repositories namespace visione::repositories
{ {
Json::Value scalarToJson(JsonFieldType type, const drogon::orm::Field &field)
{
switch (type)
{
case JsonFieldType::String:
return Json::Value(field.as<std::string>());
case JsonFieldType::Int64:
return Json::Value(Json::Int64(field.as<long long>()));
case JsonFieldType::UInt64:
return Json::Value(Json::UInt64(field.as<unsigned long long>()));
case JsonFieldType::Double:
return Json::Value(field.as<double>());
case JsonFieldType::Bool:
return Json::Value(field.as<bool>());
}
return Json::nullValue;
}
Json::Value rowToJson(const drogon::orm::Row &row) Json::Value rowToJson(const drogon::orm::Row &row)
{
static const JsonRowSchema emptySchema;
return rowToJson(row, emptySchema);
}
Json::Value rowToJson(const drogon::orm::Row &row, const JsonRowSchema &schema)
{ {
Json::Value object(Json::objectValue); Json::Value object(Json::objectValue);
for (const auto &field : row) for (const auto &field : row)
@@ -17,18 +41,33 @@ Json::Value rowToJson(const drogon::orm::Row &row)
} }
else else
{ {
object[field.name()] = field.as<std::string>(); const auto it = schema.find(field.name());
if (it == schema.end())
{
object[field.name()] = field.as<std::string>();
}
else
{
object[field.name()] = scalarToJson(it->second, field);
}
} }
} }
return object; return object;
} }
Json::Value resultToJson(const drogon::orm::Result &result) Json::Value resultToJson(const drogon::orm::Result &result)
{
static const JsonRowSchema emptySchema;
return resultToJson(result, emptySchema);
}
Json::Value resultToJson(const drogon::orm::Result &result,
const JsonRowSchema &schema)
{ {
Json::Value array(Json::arrayValue); Json::Value array(Json::arrayValue);
for (const auto &row : result) for (const auto &row : result)
{ {
array.append(rowToJson(row)); array.append(rowToJson(row, schema));
} }
return array; return array;
} }
+16 -1
View File
@@ -4,11 +4,26 @@
#include <json/json.h> #include <json/json.h>
#include <string> #include <string>
#include <unordered_map>
namespace visione::repositories namespace visione::repositories
{ {
enum class JsonFieldType
{
String,
Int64,
UInt64,
Double,
Bool
};
using JsonRowSchema = std::unordered_map<std::string, JsonFieldType>;
Json::Value scalarToJson(JsonFieldType type, const drogon::orm::Field &field);
Json::Value rowToJson(const drogon::orm::Row &row); Json::Value rowToJson(const drogon::orm::Row &row);
Json::Value rowToJson(const drogon::orm::Row &row, const JsonRowSchema &schema);
Json::Value resultToJson(const drogon::orm::Result &result); Json::Value resultToJson(const drogon::orm::Result &result);
Json::Value resultToJson(const drogon::orm::Result &result,
const JsonRowSchema &schema);
std::string nullableString(const Json::Value &value, const std::string &key); std::string nullableString(const Json::Value &value, const std::string &key);
} // namespace visione::repositories } // namespace visione::repositories
+28 -2
View File
@@ -12,6 +12,9 @@ namespace visione::services
{ {
namespace namespace
{ {
using repositories::JsonFieldType;
using repositories::JsonRowSchema;
Json::Value failure(const std::string &code) Json::Value failure(const std::string &code)
{ {
Json::Value body; Json::Value body;
@@ -58,6 +61,27 @@ int boundedPerPage(const std::optional<std::string> &value)
return 50; return 50;
} }
} }
const JsonRowSchema &moderationFlagSchema()
{
static const JsonRowSchema schema{
{"id", JsonFieldType::UInt64},
{"report_id", JsonFieldType::UInt64},
{"message_id", JsonFieldType::UInt64},
{"created_by_user_id", JsonFieldType::UInt64},
{"resolved_by_user_id", JsonFieldType::UInt64}};
return schema;
}
const JsonRowSchema &adminUserSchema()
{
static const JsonRowSchema schema{
{"id", JsonFieldType::UInt64},
{"points", JsonFieldType::Int64},
{"banned", JsonFieldType::Bool},
{"must_change_password", JsonFieldType::Bool}};
return schema;
}
} // namespace } // namespace
Json::Value AdminService::listCategories() Json::Value AdminService::listCategories()
@@ -229,7 +253,8 @@ Json::Value AdminService::listModeration(
statusValue, statusValue,
statusValue, statusValue,
perPageValue, perPageValue,
offset)); offset),
moderationFlagSchema());
return response; return response;
} }
@@ -317,7 +342,8 @@ Json::Value AdminService::listUsers(const auth::UserContext &user)
"LEFT JOIN roles r ON r.id = ur.role_id " "LEFT JOIN roles r ON r.id = ur.role_id "
"GROUP BY u.id, u.first_name, u.last_name, u.email, u.fiscal_code, " "GROUP BY u.id, u.first_name, u.last_name, u.email, u.fiscal_code, "
"u.phone, u.points, u.banned, u.must_change_password " "u.phone, u.points, u.banned, u.must_change_password "
"ORDER BY u.created_at DESC LIMIT 200")); "ORDER BY u.created_at DESC LIMIT 200"),
adminUserSchema());
return response; return response;
} }
+70 -10
View File
@@ -16,6 +16,9 @@ namespace visione::services
{ {
namespace namespace
{ {
using repositories::JsonFieldType;
using repositories::JsonRowSchema;
Json::Value failure(const std::string &code) Json::Value failure(const std::string &code)
{ {
Json::Value body; Json::Value body;
@@ -152,6 +155,57 @@ int boundedPerPage(const std::optional<std::string> &value)
} }
} }
const JsonRowSchema &reportSchema()
{
static const JsonRowSchema schema{
{"id", JsonFieldType::UInt64},
{"user_id", JsonFieldType::UInt64},
{"category_id", JsonFieldType::UInt64},
{"area_id", JsonFieldType::UInt64},
{"assigned_operator_id", JsonFieldType::UInt64},
{"latitude", JsonFieldType::Double},
{"longitude", JsonFieldType::Double}};
return schema;
}
const JsonRowSchema &reportHistorySchema()
{
static const JsonRowSchema schema{
{"id", JsonFieldType::UInt64},
{"report_id", JsonFieldType::UInt64},
{"changed_by_user_id", JsonFieldType::UInt64}};
return schema;
}
const JsonRowSchema &reportMediaSchema()
{
static const JsonRowSchema schema{
{"id", JsonFieldType::UInt64},
{"report_id", JsonFieldType::UInt64},
{"size_bytes", JsonFieldType::UInt64}};
return schema;
}
const JsonRowSchema &messageSchema()
{
static const JsonRowSchema schema{
{"id", JsonFieldType::UInt64},
{"report_id", JsonFieldType::UInt64},
{"user_id", JsonFieldType::UInt64}};
return schema;
}
const JsonRowSchema &openDataReportSchema()
{
static const JsonRowSchema schema{
{"id", JsonFieldType::UInt64},
{"category_id", JsonFieldType::UInt64},
{"area_id", JsonFieldType::UInt64},
{"latitude", JsonFieldType::Double},
{"longitude", JsonFieldType::Double}};
return schema;
}
Json::Value pagedReportResponse(const drogon::orm::Result &result, Json::Value pagedReportResponse(const drogon::orm::Result &result,
int page, int page,
int perPage) int perPage)
@@ -159,7 +213,7 @@ Json::Value pagedReportResponse(const drogon::orm::Result &result,
auto response = success(); auto response = success();
response["page"] = page; response["page"] = page;
response["per_page"] = perPage; response["per_page"] = perPage;
response["reports"] = repositories::resultToJson(result); response["reports"] = repositories::resultToJson(result, reportSchema());
return response; return response;
} }
@@ -335,7 +389,7 @@ Json::Value ReportService::listReports(const auth::UserContext &user,
} }
auto response = success(); auto response = success();
response["reports"] = repositories::resultToJson(result); response["reports"] = repositories::resultToJson(result, reportSchema());
return response; return response;
} }
@@ -429,13 +483,15 @@ Json::Value ReportService::reportDetail(const auth::UserContext &user,
} }
auto response = success(); auto response = success();
response["report"] = repositories::rowToJson(report[0]); response["report"] = repositories::rowToJson(report[0], reportSchema());
response["history"] = repositories::resultToJson(db->execSqlSync( response["history"] = repositories::resultToJson(db->execSqlSync(
"SELECT * FROM report_status_history WHERE report_id = ? ORDER BY id", "SELECT * FROM report_status_history WHERE report_id = ? ORDER BY id",
reportId)); reportId),
reportHistorySchema());
response["media"] = repositories::resultToJson(db->execSqlSync( response["media"] = repositories::resultToJson(db->execSqlSync(
"SELECT * FROM report_photos WHERE report_id = ? ORDER BY id", "SELECT * FROM report_photos WHERE report_id = ? ORDER BY id",
reportId)); reportId),
reportMediaSchema());
return response; return response;
} }
@@ -464,15 +520,17 @@ Json::Value ReportService::interventionSummary(const auth::UserContext &user,
return failure("report_not_found"); return failure("report_not_found");
} }
auto response = success(); auto response = success();
response["summary"] = repositories::rowToJson(report[0]); response["summary"] = repositories::rowToJson(report[0], reportSchema());
response["media"] = repositories::resultToJson(db->execSqlSync( response["media"] = repositories::resultToJson(db->execSqlSync(
"SELECT * FROM report_photos WHERE report_id = ? ORDER BY id", "SELECT * FROM report_photos WHERE report_id = ? ORDER BY id",
reportId)); reportId),
reportMediaSchema());
response["latest_messages"] = repositories::resultToJson(db->execSqlSync( response["latest_messages"] = repositories::resultToJson(db->execSqlSync(
"SELECT m.id, m.user_id, u.first_name, u.last_name, m.body, m.created_at " "SELECT m.id, m.user_id, u.first_name, u.last_name, m.body, m.created_at "
"FROM messages m JOIN users u ON u.id = m.user_id " "FROM messages m JOIN users u ON u.id = m.user_id "
"WHERE m.report_id = ? ORDER BY m.created_at DESC LIMIT 5", "WHERE m.report_id = ? ORDER BY m.created_at DESC LIMIT 5",
reportId)); reportId),
messageSchema());
return response; return response;
} }
@@ -628,7 +686,8 @@ Json::Value ReportService::listMessages(const auth::UserContext &user,
"SELECT m.*, u.first_name, u.last_name FROM messages m " "SELECT m.*, u.first_name, u.last_name FROM messages m "
"JOIN users u ON u.id = m.user_id WHERE m.report_id = ? " "JOIN users u ON u.id = m.user_id WHERE m.report_id = ? "
"ORDER BY m.created_at", "ORDER BY m.created_at",
reportId)); reportId),
messageSchema());
return response; return response;
} }
@@ -802,7 +861,8 @@ Json::Value ReportService::openDataReports(const std::optional<std::string> &pag
"WHERE r.status IN ('RESOLVED', 'CLOSED') " "WHERE r.status IN ('RESOLVED', 'CLOSED') "
"ORDER BY r.updated_at DESC LIMIT ? OFFSET ?", "ORDER BY r.updated_at DESC LIMIT ? OFFSET ?",
perPageValue, perPageValue,
offset)); offset),
openDataReportSchema());
return response; return response;
} }
+24 -2
View File
@@ -10,6 +10,9 @@ namespace visione::services
{ {
namespace namespace
{ {
using repositories::JsonFieldType;
using repositories::JsonRowSchema;
Json::Value failure(const std::string &code) Json::Value failure(const std::string &code)
{ {
Json::Value body; Json::Value body;
@@ -56,6 +59,23 @@ int boundedPerPage(const std::optional<std::string> &value)
return 50; return 50;
} }
} }
const JsonRowSchema &notificationSchema()
{
static const JsonRowSchema schema{
{"id", JsonFieldType::UInt64},
{"report_id", JsonFieldType::UInt64}};
return schema;
}
const JsonRowSchema &rewardEventSchema()
{
static const JsonRowSchema schema{
{"id", JsonFieldType::UInt64},
{"report_id", JsonFieldType::UInt64},
{"points", JsonFieldType::Int64}};
return schema;
}
} // namespace } // namespace
Json::Value UserService::listNotifications( Json::Value UserService::listNotifications(
@@ -77,7 +97,8 @@ Json::Value UserService::listNotifications(
"ORDER BY created_at DESC LIMIT ? OFFSET ?", "ORDER BY created_at DESC LIMIT ? OFFSET ?",
user.id, user.id,
perPageValue, perPageValue,
offset)); offset),
notificationSchema());
return response; return response;
} }
@@ -122,7 +143,8 @@ Json::Value UserService::rewards(const auth::UserContext &user,
"ORDER BY created_at DESC LIMIT ? OFFSET ?", "ORDER BY created_at DESC LIMIT ? OFFSET ?",
user.id, user.id,
perPageValue, perPageValue,
offset)); offset),
rewardEventSchema());
return response; return response;
} }
+22
View File
@@ -6,6 +6,7 @@
#include "common/GeoJson.h" #include "common/GeoJson.h"
#include "common/Crypto.h" #include "common/Crypto.h"
#include "common/Validation.h" #include "common/Validation.h"
#include "repositories/Sql.h"
#include <future> #include <future>
@@ -53,7 +54,28 @@ DROGON_TEST(ValidationRejectsMalformedIdentity)
CHECK(visione::validation::isValidEmail("[email protected]")); CHECK(visione::validation::isValidEmail("[email protected]"));
CHECK(!visione::validation::isValidEmail("not-an-email")); CHECK(!visione::validation::isValidEmail("not-an-email"));
CHECK(visione::validation::isValidFiscalCode("RSSMRA80A01H501U")); CHECK(visione::validation::isValidFiscalCode("RSSMRA80A01H501U"));
CHECK(visione::validation::isValidFiscalCode("VSSGNR94M08A662P"));
CHECK(!visione::validation::isValidFiscalCode("short")); CHECK(!visione::validation::isValidFiscalCode("short"));
CHECK(!visione::validation::isValidFiscalCode("VSSGNR94M08A662A"));
}
DROGON_TEST(SqlScalarSerializationKeepsTypes)
{
const auto json = []() {
Json::Value value(Json::objectValue);
value["id"] = Json::UInt64(7);
value["banned"] = false;
value["must_change_password"] = true;
value["points"] = Json::Int64(42);
value["phone"] = "380 6180216";
return value;
}();
CHECK(json["id"].isUInt64());
CHECK(json["banned"].isBool());
CHECK(json["must_change_password"].isBool());
CHECK(json["points"].isInt64());
CHECK(json["phone"].isString());
} }
DROGON_TEST(GeoJsonMatchesPointInsidePolygon) DROGON_TEST(GeoJsonMatchesPointInsidePolygon)