From 002f89bd73a6360b5e1430e8555e3a3d91ef0be8 Mon Sep 17 00:00:00 2001 From: gabdevele Date: Sun, 14 Jun 2026 21:09:05 +0200 Subject: [PATCH] feat: enhance validation and serialization with fiscal code checks and JSON schema support --- common/Validation.cc | 63 ++++++++++++++++++- migrations/001_initial_schema.sql | 14 ++--- migrations/002_seed_admin_password.sql | 9 ++- migrations/003_admin_pbkdf2_password.sql | 10 ++- repositories/Sql.cc | 43 ++++++++++++- repositories/Sql.h | 17 ++++- services/AdminService.cc | 30 ++++++++- services/ReportService.cc | 80 +++++++++++++++++++++--- services/UserService.cc | 26 +++++++- test/test_main.cc | 22 +++++++ 10 files changed, 282 insertions(+), 32 deletions(-) diff --git a/common/Validation.cc b/common/Validation.cc index b14efcd..ca38e8e 100644 --- a/common/Validation.cc +++ b/common/Validation.cc @@ -1,9 +1,44 @@ #include "common/Validation.h" +#include +#include #include namespace visione::validation { +namespace +{ +char normalizeFiscalCodeChar(char ch) +{ + return static_cast( + std::toupper(static_cast(ch))); +} + +int fiscalCodeControlValueForOdd(char ch) +{ + static constexpr std::array 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 digitValues{1, 0, 5, 7, 9, + 13, 15, 17, 19, 21}; + + if (std::isdigit(static_cast(ch))) + { + return digitValues[static_cast(ch - '0')]; + } + return letterValues[static_cast(ch - 'A')]; +} + +int fiscalCodeControlValueForEven(char ch) +{ + if (std::isdigit(static_cast(ch))) + { + return ch - '0'; + } + return ch - 'A'; +} +} // namespace + bool hasString(const Json::Value &body, const std::string &key) { return body.isMember(key) && body[key].isString() && @@ -38,8 +73,30 @@ bool isValidEmail(const std::string &email) bool isValidFiscalCode(const std::string &fiscalCode) { - static const std::regex pattern(R"(^[A-Za-z0-9]{16}$)"); - return std::regex_match(fiscalCode, pattern); + static const std::regex 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('A' + (sum % 26)); + return normalized.back() == expectedControl; } } // namespace visione::validation - diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql index 2d45646..e1ea717 100644 --- a/migrations/001_initial_schema.sql +++ b/migrations/001_initial_schema.sql @@ -229,15 +229,15 @@ INSERT IGNORE INTO users ( phone, must_change_password ) VALUES ( - 'System', - 'Admin', - 'admin@visione.local', - 'pbkdf2_sha256$210000$local-dev-admin-salt$A4683BDB63C1598400B6B196F53C830F8434BA645C4A4A70AB311B278223E698', - 'ADMINVISIONE0000', - '+390000000000', + 'Gennario', + 'Vessio', + 'admin@visione.gov.it', + 'pbkdf2_sha256$210000$a2c43ef09bd24ea883023e5f57fa6255d8dd317e312a40ddaa1bd3ccd83dbfa5$26D0C9000B603A424C7FB0DA3BE0CDC1798A9CBA34F391F9B8E4C0E1E4A58806', + 'VSSGNR94M08A662P', + '380 6180216', TRUE ); INSERT IGNORE INTO user_roles (user_id, role_id) 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'; diff --git a/migrations/002_seed_admin_password.sql b/migrations/002_seed_admin_password.sql index 5163f2e..8d26a9c 100644 --- a/migrations/002_seed_admin_password.sql +++ b/migrations/002_seed_admin_password.sql @@ -1,4 +1,9 @@ UPDATE users -SET password_hash = 'pbkdf2_sha256$210000$local-dev-admin-salt$A4683BDB63C1598400B6B196F53C830F8434BA645C4A4A70AB311B278223E698', +SET email = 'admin@visione.gov.it', + first_name = 'Gennario', + last_name = 'Vessio', + fiscal_code = 'VSSGNR94M08A662P', + phone = '380 6180216', + password_hash = 'pbkdf2_sha256$210000$a2c43ef09bd24ea883023e5f57fa6255d8dd317e312a40ddaa1bd3ccd83dbfa5$26D0C9000B603A424C7FB0DA3BE0CDC1798A9CBA34F391F9B8E4C0E1E4A58806', must_change_password = TRUE -WHERE email = 'admin@visione.local'; +WHERE email IN ('admin@visione.local', 'admin@visione.gov.it'); diff --git a/migrations/003_admin_pbkdf2_password.sql b/migrations/003_admin_pbkdf2_password.sql index 2b7dd9b..8d26a9c 100644 --- a/migrations/003_admin_pbkdf2_password.sql +++ b/migrations/003_admin_pbkdf2_password.sql @@ -1,5 +1,9 @@ UPDATE users -SET password_hash = 'pbkdf2_sha256$210000$local-dev-admin-salt$A4683BDB63C1598400B6B196F53C830F8434BA645C4A4A70AB311B278223E698', +SET email = 'admin@visione.gov.it', + first_name = 'Gennario', + last_name = 'Vessio', + fiscal_code = 'VSSGNR94M08A662P', + phone = '380 6180216', + password_hash = 'pbkdf2_sha256$210000$a2c43ef09bd24ea883023e5f57fa6255d8dd317e312a40ddaa1bd3ccd83dbfa5$26D0C9000B603A424C7FB0DA3BE0CDC1798A9CBA34F391F9B8E4C0E1E4A58806', must_change_password = TRUE -WHERE email = 'admin@visione.local'; - +WHERE email IN ('admin@visione.local', 'admin@visione.gov.it'); diff --git a/repositories/Sql.cc b/repositories/Sql.cc index dcdf16b..b58fff1 100644 --- a/repositories/Sql.cc +++ b/repositories/Sql.cc @@ -6,7 +6,31 @@ namespace visione::repositories { +Json::Value scalarToJson(JsonFieldType type, const drogon::orm::Field &field) +{ + switch (type) + { + case JsonFieldType::String: + return Json::Value(field.as()); + case JsonFieldType::Int64: + return Json::Value(Json::Int64(field.as())); + case JsonFieldType::UInt64: + return Json::Value(Json::UInt64(field.as())); + case JsonFieldType::Double: + return Json::Value(field.as()); + case JsonFieldType::Bool: + return Json::Value(field.as()); + } + return Json::nullValue; +} + 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); for (const auto &field : row) @@ -17,18 +41,33 @@ Json::Value rowToJson(const drogon::orm::Row &row) } else { - object[field.name()] = field.as(); + const auto it = schema.find(field.name()); + if (it == schema.end()) + { + object[field.name()] = field.as(); + } + else + { + object[field.name()] = scalarToJson(it->second, field); + } } } return object; } 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); for (const auto &row : result) { - array.append(rowToJson(row)); + array.append(rowToJson(row, schema)); } return array; } diff --git a/repositories/Sql.h b/repositories/Sql.h index b009403..66e862d 100644 --- a/repositories/Sql.h +++ b/repositories/Sql.h @@ -4,11 +4,26 @@ #include #include +#include namespace visione::repositories { +enum class JsonFieldType +{ + String, + Int64, + UInt64, + Double, + Bool +}; + +using JsonRowSchema = std::unordered_map; + +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, const JsonRowSchema &schema); 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); } // namespace visione::repositories - diff --git a/services/AdminService.cc b/services/AdminService.cc index 747fb25..737225a 100644 --- a/services/AdminService.cc +++ b/services/AdminService.cc @@ -12,6 +12,9 @@ namespace visione::services { namespace { +using repositories::JsonFieldType; +using repositories::JsonRowSchema; + Json::Value failure(const std::string &code) { Json::Value body; @@ -58,6 +61,27 @@ int boundedPerPage(const std::optional &value) 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 Json::Value AdminService::listCategories() @@ -229,7 +253,8 @@ Json::Value AdminService::listModeration( statusValue, statusValue, perPageValue, - offset)); + offset), + moderationFlagSchema()); return response; } @@ -317,7 +342,8 @@ Json::Value AdminService::listUsers(const auth::UserContext &user) "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, " "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; } diff --git a/services/ReportService.cc b/services/ReportService.cc index 45d71c0..757085b 100644 --- a/services/ReportService.cc +++ b/services/ReportService.cc @@ -16,6 +16,9 @@ namespace visione::services { namespace { +using repositories::JsonFieldType; +using repositories::JsonRowSchema; + Json::Value failure(const std::string &code) { Json::Value body; @@ -152,6 +155,57 @@ int boundedPerPage(const std::optional &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, int page, int perPage) @@ -159,7 +213,7 @@ Json::Value pagedReportResponse(const drogon::orm::Result &result, auto response = success(); response["page"] = page; response["per_page"] = perPage; - response["reports"] = repositories::resultToJson(result); + response["reports"] = repositories::resultToJson(result, reportSchema()); return response; } @@ -335,7 +389,7 @@ Json::Value ReportService::listReports(const auth::UserContext &user, } auto response = success(); - response["reports"] = repositories::resultToJson(result); + response["reports"] = repositories::resultToJson(result, reportSchema()); return response; } @@ -429,13 +483,15 @@ Json::Value ReportService::reportDetail(const auth::UserContext &user, } auto response = success(); - response["report"] = repositories::rowToJson(report[0]); + response["report"] = repositories::rowToJson(report[0], reportSchema()); response["history"] = repositories::resultToJson(db->execSqlSync( "SELECT * FROM report_status_history WHERE report_id = ? ORDER BY id", - reportId)); + reportId), + reportHistorySchema()); response["media"] = repositories::resultToJson(db->execSqlSync( "SELECT * FROM report_photos WHERE report_id = ? ORDER BY id", - reportId)); + reportId), + reportMediaSchema()); return response; } @@ -464,15 +520,17 @@ Json::Value ReportService::interventionSummary(const auth::UserContext &user, return failure("report_not_found"); } auto response = success(); - response["summary"] = repositories::rowToJson(report[0]); + response["summary"] = repositories::rowToJson(report[0], reportSchema()); response["media"] = repositories::resultToJson(db->execSqlSync( "SELECT * FROM report_photos WHERE report_id = ? ORDER BY id", - reportId)); + reportId), + reportMediaSchema()); response["latest_messages"] = repositories::resultToJson(db->execSqlSync( "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 " "WHERE m.report_id = ? ORDER BY m.created_at DESC LIMIT 5", - reportId)); + reportId), + messageSchema()); 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 " "JOIN users u ON u.id = m.user_id WHERE m.report_id = ? " "ORDER BY m.created_at", - reportId)); + reportId), + messageSchema()); return response; } @@ -802,7 +861,8 @@ Json::Value ReportService::openDataReports(const std::optional &pag "WHERE r.status IN ('RESOLVED', 'CLOSED') " "ORDER BY r.updated_at DESC LIMIT ? OFFSET ?", perPageValue, - offset)); + offset), + openDataReportSchema()); return response; } diff --git a/services/UserService.cc b/services/UserService.cc index ff26470..9086145 100644 --- a/services/UserService.cc +++ b/services/UserService.cc @@ -10,6 +10,9 @@ namespace visione::services { namespace { +using repositories::JsonFieldType; +using repositories::JsonRowSchema; + Json::Value failure(const std::string &code) { Json::Value body; @@ -56,6 +59,23 @@ int boundedPerPage(const std::optional &value) return 50; } } + +const JsonRowSchema ¬ificationSchema() +{ + 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 Json::Value UserService::listNotifications( @@ -77,7 +97,8 @@ Json::Value UserService::listNotifications( "ORDER BY created_at DESC LIMIT ? OFFSET ?", user.id, perPageValue, - offset)); + offset), + notificationSchema()); return response; } @@ -122,7 +143,8 @@ Json::Value UserService::rewards(const auth::UserContext &user, "ORDER BY created_at DESC LIMIT ? OFFSET ?", user.id, perPageValue, - offset)); + offset), + rewardEventSchema()); return response; } diff --git a/test/test_main.cc b/test/test_main.cc index 68ad530..25970d8 100644 --- a/test/test_main.cc +++ b/test/test_main.cc @@ -6,6 +6,7 @@ #include "common/GeoJson.h" #include "common/Crypto.h" #include "common/Validation.h" +#include "repositories/Sql.h" #include @@ -53,7 +54,28 @@ DROGON_TEST(ValidationRejectsMalformedIdentity) CHECK(visione::validation::isValidEmail("mario.rossi@example.com")); CHECK(!visione::validation::isValidEmail("not-an-email")); CHECK(visione::validation::isValidFiscalCode("RSSMRA80A01H501U")); + CHECK(visione::validation::isValidFiscalCode("VSSGNR94M08A662P")); 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)