Compare commits

..
30 Commits
53 changed files with 2913 additions and 1223 deletions
+12
View File
@@ -5,4 +5,16 @@ MYSQL_PASSWORD=visione
MYSQL_ROOT_PASSWORD=root
MAILPIT_SMTP_PORT=1025
MAILPIT_WEB_PORT=8025
MAIL_PROVIDER=smtp
SMTP_HOST=127.0.0.1
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_USE_TLS=false
SMTP_USE_STARTTLS=false
SMTP_VERIFY_PEER=false
SMTP_VERIFY_HOST=false
MAIL_FROM=[email protected]
CORS_ALLOWED_ORIGIN=http://localhost:3000
-41
View File
@@ -1,41 +0,0 @@
# Visione Backend Agent Guide
## Project Context
- This repository is the backend for **Visione**, an urban issue reporting webapp.
- The backend is C++20 with Drogon. Keep code, symbols, API fields, database names and comments in English.
- User-facing product labels may be Italian when they represent UI/business vocabulary.
- Docker is for local external services only: MySQL, Mailpit and future Redis-like dependencies.
ENVIRONMENT:
You are running in a docker container, so it's normal if something doesn't work as expected.
You are allowed to install new tools, libraries or dependencies if you think they are necessary to complete your task.
Since you are running in a docker container, you could have some build issues sometimes, do not waste tokens over-trying to fix them, just report the issue and move on to the next task if you believe the issue is not related to your task.
Prefer using commands to read files, search for patterns ecc... since I have installed a tool that helps reduce token usage for those operations.
## Authoritative References
- Local Drogon source: `/home/gabdevele/Projects/snitchitaly/backend/drogon`
- Official Drogon docs: `https://drogonframework.github.io/drogon-docs/`
- Project docs start from `docs/LLM_CONTEXT.md`.
## Architecture Rules
- Controllers parse HTTP input and shape HTTP responses only.
- Services own business rules.
- Repositories/database helpers own SQL access.
- Prefer small, readable C++ over clever abstractions.
- Use parameterized SQL; do not concatenate user input into SQL.
- Keep local development simple. Do not introduce production infrastructure unless requested.
## Commands
- Configure: `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug`
- Build: `cmake --build build`
- Test: `ctest --test-dir build --output-on-failure`
- OpenAPI: `cmake --build build --target openapi`
- Lab frontend: `cd scripts/lab && uv run streamlit run app.py`
- Local services: `docker compose up -d`
- App: `./build/visione-backend`
## Development Workflow
- Update docs when behavior, schema, setup or API contracts change.
- After each completed and verified feature, create a short commit.
- Keep commit messages concise, for example `Add Mailpit config` or `Add report API`.
- Do not spend excessive time on exhaustive tests; cover high-risk behavior and regressions.
+4
View File
@@ -8,6 +8,8 @@ set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
find_package(Python3 COMPONENTS Interpreter REQUIRED)
find_package(OpenSSL REQUIRED)
find_package(CURL REQUIRED)
option(USE_SYSTEM_DROGON "Use an installed Drogon package instead of CPM" OFF)
@@ -71,6 +73,8 @@ target_include_directories(${PROJECT_NAME}
target_link_libraries(${PROJECT_NAME}
PRIVATE
Drogon::Drogon
CURL::libcurl
OpenSSL::Crypto
)
file(GLOB CONTROLLER_HEADERS CONFIGURE_DEPENDS
+2 -2
View File
@@ -6,7 +6,7 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential ccache cmake ninja-build git pkg-config \
libssl-dev zlib1g-dev \
libcurl4-openssl-dev libssl-dev zlib1g-dev \
libmariadb-dev libmariadb-dev-compat \
libsqlite3-dev python3 libjsoncpp-dev uuid-dev ca-certificates \
&& rm -rf /var/lib/apt/lists/*
@@ -27,7 +27,7 @@ FROM ubuntu:22.04 AS runtime
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 ca-certificates libstdc++6 libmariadb3 libjsoncpp25 libuuid1 \
libcurl4 libssl3 ca-certificates libstdc++6 libmariadb3 libjsoncpp25 libuuid1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
+41 -19
View File
@@ -9,7 +9,7 @@ Install native build dependencies first. On Ubuntu:
```bash
sudo apt install build-essential cmake ninja-build git pkg-config \
libjsoncpp-dev uuid-dev zlib1g-dev libssl-dev \
libcurl4-openssl-dev libjsoncpp-dev uuid-dev zlib1g-dev libssl-dev \
libmariadb-dev libmariadb-dev-compat
```
@@ -36,9 +36,8 @@ has been built.
Local seeded admin:
- email: `admin@visione.local`
- password: `ChangeMe123!`
- the account has `must_change_password = true` and is for local development only.
- email: `admin@visionebari.it`
- access uses OTP email delivery and needs mail provider config.
If you already had a MySQL volume from the old scaffold, create the local
database/user once:
@@ -55,26 +54,15 @@ docker compose exec -T mysql mysql -uroot -proot -e \
```bash
curl http://localhost:5555/api/v1/health
curl http://localhost:5555/api/v1/ready
curl -X POST http://localhost:5555/api/v1/auth/login/password \
curl -X POST http://localhost:5555/api/v1/auth/otp/request \
-H 'Content-Type: application/json' \
-d '{"email":"admin@visione.local","password":"ChangeMe123!"}'
-d '{"email":"admin@visionebari.it"}'
```
Use `-DUSE_SYSTEM_DROGON=ON` only if Drogon is already installed and
discoverable by CMake. The default build uses CPM.cmake and downloads Drogon
`v1.9.13`.
## Project Docs
- `AGENTS.md`: durable instructions for coding agents.
- `docs/LLM_CONTEXT.md`: short project context for future LLM sessions.
- `docs/MILESTONE_01.md`: what this milestone implemented and what comes next.
- `docs/MILESTONE_02.md`: workflow and authorization milestone.
- `docs/MILESTONE_03.md`: media upload, OpenAPI generator and API lab milestone.
- `docs/MILESTONE_04.md`: backend completion milestone.
- `docs/API_SPEC.md`: API surface.
- `docs/DATABASE_SCHEMA.md`: schema overview.
- `docs/ARCHITECTURE.md`: backend structure.
## OpenAPI
@@ -94,6 +82,32 @@ python3 -m unittest scripts/test_generate_openapi.py
The app serves the generated JSON at `/api/v1/openapi.json` and Swagger UI at
`/api/v1/docs`.
## Email Delivery
OTP delivery uses SMTP through `libcurl`. Local dev defaults to Mailpit.
Set these environment variables before starting backend for local Mailpit:
```bash
export MAIL_PROVIDER=smtp
export SMTP_HOST=127.0.0.1
export SMTP_PORT=1025
export MAIL_FROM=[email protected]
```
For Resend in production, use SMTP credentials instead:
```bash
export MAIL_PROVIDER=resend
export SMTP_HOST=smtp.resend.com
export SMTP_PORT=465
export SMTP_USERNAME=resend
export SMTP_PASSWORD=re_xxx
export SMTP_USE_TLS=true
export SMTP_VERIFY_PEER=true
export SMTP_VERIFY_HOST=true
export MAIL_FROM=[email protected]
```
## Visione Lab
`scripts/lab/` is a small Streamlit frontend used only to test the backend
@@ -138,15 +152,23 @@ backend deployment with MySQL and a local SMTP catcher. Configure the public
domain in Dokploy against service `backend` and container port `5555`.
The runtime image includes `config.json`; override environment-specific values
with Dokploy environment variables. The SMTP catcher service uses the
with Dokploy environment variables. The Mailpit service uses the
multi-architecture Mailpit image to avoid `exec format error` failures on ARM
servers.
Set `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `MYSQL_ROOT_PASSWORD` and
Set `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `MYSQL_ROOT_PASSWORD`,
`SMTP_PASSWORD`, `MAIL_FROM` and
`CORS_ALLOWED_ORIGIN` in Dokploy environment variables. Use the exact browser
origin without quotes or a trailing slash, for example
`https://visione.studentindipendenti.it`.
For CIE auth deploys, also override values that vary by environment:
- `CIE_AUTH_SERVICE_BASE_URL`
- `CIE_AUTH_START_PATH`
- `CIE_AUTH_SHARED_SECRET`
- `CIE_AUTH_STATE_TTL_MINUTES`
- `CIE_AUTH_DEFAULT_FRONTEND_REDIRECT_URI`
Docker builds use BuildKit cache mounts for CPM dependencies and `ccache`, so
repeat Dokploy builds can reuse Drogon and C++ compilation cache.
+1 -7
View File
@@ -45,7 +45,7 @@ std::optional<UserContext> authenticate(const drogon::HttpRequestPtr &request)
auto db = drogon::app().getDbClient();
const auto userResult = db->execSqlSync(
"SELECT u.id, u.email, u.must_change_password "
"SELECT u.id, u.email "
"FROM api_tokens t "
"JOIN users u ON u.id = t.user_id "
"WHERE t.token_hash = ? AND t.revoked_at IS NULL "
@@ -93,7 +93,6 @@ std::optional<UserContext> authenticate(const drogon::HttpRequestPtr &request)
context.roles = std::move(roles);
context.permissions = std::move(permissions);
context.role = primaryRole(context.roles);
context.mustChangePassword = userResult[0]["must_change_password"].as<bool>();
return context;
}
@@ -122,9 +121,4 @@ bool isAdmin(const UserContext &user)
{
return hasRole(user, "admin") || hasPermission(user, "admin.manage");
}
bool mustChangePassword(const UserContext &user)
{
return user.mustChangePassword;
}
} // namespace visione::auth
-2
View File
@@ -15,7 +15,6 @@ struct UserContext
std::string role;
std::vector<std::string> roles;
std::vector<std::string> permissions;
bool mustChangePassword{false};
};
std::optional<std::string> bearerToken(const drogon::HttpRequestPtr &request);
@@ -25,5 +24,4 @@ bool hasPermission(const UserContext &user, const std::string &permission);
bool canManageReports(const UserContext &user);
bool canOperateReports(const UserContext &user);
bool isAdmin(const UserContext &user);
bool mustChangePassword(const UserContext &user);
} // namespace visione::auth
+25 -58
View File
@@ -1,10 +1,9 @@
#include "common/Crypto.h"
#include <drogon/utils/Utilities.h>
#include <openssl/crypto.h>
#include <openssl/hmac.h>
#include <openssl/evp.h>
#include <array>
#include <iomanip>
#include <random>
#include <sstream>
@@ -14,9 +13,6 @@ namespace visione::crypto
{
namespace
{
constexpr int passwordIterations = 210000;
constexpr size_t passwordHashBytes = 32;
std::string toHex(const unsigned char *data, size_t size)
{
std::ostringstream stream;
@@ -27,22 +23,6 @@ std::string toHex(const unsigned char *data, size_t size)
}
return stream.str();
}
std::string pbkdf2(const std::string &password,
const std::string &salt,
int iterations)
{
std::array<unsigned char, passwordHashBytes> output{};
PKCS5_PBKDF2_HMAC(password.c_str(),
static_cast<int>(password.size()),
reinterpret_cast<const unsigned char *>(salt.data()),
static_cast<int>(salt.size()),
iterations,
EVP_sha256(),
static_cast<int>(output.size()),
output.data());
return toHex(output.data(), output.size());
}
} // namespace
std::string sha256(const std::string &value)
@@ -50,6 +30,29 @@ std::string sha256(const std::string &value)
return drogon::utils::getSha256(value);
}
std::string hmacSha256Hex(const std::string &key, const std::string &value)
{
unsigned char digest[EVP_MAX_MD_SIZE];
unsigned int digestSize = 0;
HMAC(EVP_sha256(),
key.data(),
static_cast<int>(key.size()),
reinterpret_cast<const unsigned char *>(value.data()),
value.size(),
digest,
&digestSize);
return toHex(digest, digestSize);
}
bool constantTimeEquals(const std::string &left, const std::string &right)
{
if (left.size() != right.size())
{
return false;
}
return CRYPTO_memcmp(left.data(), right.data(), left.size()) == 0;
}
std::string randomNumericCode(int digits)
{
std::random_device device;
@@ -70,42 +73,6 @@ std::string randomToken()
return drogon::utils::getUuid() + drogon::utils::getUuid();
}
std::string hashPassword(const std::string &password)
{
const auto salt = drogon::utils::getUuid() + drogon::utils::getUuid();
return "pbkdf2_sha256$" + std::to_string(passwordIterations) + "$" + salt +
"$" + pbkdf2(password, salt, passwordIterations);
}
bool verifyPassword(const std::string &password, const std::string &hash)
{
constexpr std::string_view pbkdf2Prefix = "pbkdf2_sha256$";
if (!hash.starts_with(pbkdf2Prefix))
{
return false;
}
const auto iterationsStart = pbkdf2Prefix.size();
const auto saltStart = hash.find('$', iterationsStart);
if (saltStart == std::string::npos)
{
return false;
}
const auto digestStart = hash.find('$', saltStart + 1);
if (digestStart == std::string::npos)
{
return false;
}
const auto iterations =
std::stoi(hash.substr(iterationsStart, saltStart - iterationsStart));
const auto salt = hash.substr(saltStart + 1, digestStart - saltStart - 1);
const auto expected = "pbkdf2_sha256$" + std::to_string(iterations) + "$" +
salt + "$" + pbkdf2(password, salt, iterations);
return expected == hash;
}
std::string hashSecret(const std::string &secret)
{
return sha256("visione:" + secret);
+2 -3
View File
@@ -5,10 +5,9 @@
namespace visione::crypto
{
std::string sha256(const std::string &value);
std::string hmacSha256Hex(const std::string &key, const std::string &value);
bool constantTimeEquals(const std::string &left, const std::string &right);
std::string randomNumericCode(int digits);
std::string randomToken();
std::string hashPassword(const std::string &password);
bool verifyPassword(const std::string &password, const std::string &hash);
std::string hashSecret(const std::string &secret);
} // namespace visione::crypto
+189 -69
View File
@@ -1,119 +1,239 @@
#include "common/Email.h"
#include <curl/curl.h>
#include <drogon/drogon.h>
#include <array>
#include <algorithm>
#include <cstddef>
#include <cstring>
#include <string>
#include <netdb.h>
#include <sys/socket.h>
#include <unistd.h>
#include <memory>
#include <sstream>
namespace visione::email
{
namespace
{
const Json::Value &mailConfig()
{
return drogon::app().getCustomConfig()["mail"];
}
std::string configString(const std::string &key, const std::string &fallback)
{
const auto &mail = drogon::app().getCustomConfig()["mail"];
const auto &mail = mailConfig();
return mail.get(key, fallback).asString();
}
int configInt(const std::string &key, int fallback)
{
const auto &mail = drogon::app().getCustomConfig()["mail"];
const auto &mail = mailConfig();
return mail.get(key, fallback).asInt();
}
bool readResponse(int socket)
bool configBool(const std::string &key, bool fallback)
{
std::array<char, 512> buffer{};
const auto count = recv(socket, buffer.data(), buffer.size() - 1, 0);
return count > 0 && buffer[0] >= '2' && buffer[0] <= '3';
const auto &mail = mailConfig();
return mail.get(key, fallback).asBool();
}
bool sendLine(int socket, const std::string &line)
struct SmtpDefaults
{
const auto payload = line + "\r\n";
return send(socket, payload.data(), payload.size(), 0) ==
static_cast<ssize_t>(payload.size());
std::string host;
int port;
std::string username;
bool useTls;
bool useStartTls;
bool verifyPeer;
bool verifyHost;
};
SmtpDefaults smtpDefaults(const std::string &provider)
{
if (provider == "resend")
{
return {
.host = "smtp.resend.com",
.port = 465,
.username = "resend",
.useTls = true,
.useStartTls = false,
.verifyPeer = true,
.verifyHost = true,
};
}
return {
.host = "127.0.0.1",
.port = 1025,
.username = "",
.useTls = false,
.useStartTls = false,
.verifyPeer = false,
.verifyHost = false,
};
}
bool sendCommand(int socket, const std::string &line)
std::string smtpUrl(const std::string &host, int port, bool useTls)
{
return sendLine(socket, line) && readResponse(socket);
return (useTls ? "smtps://" : "smtp://") + host + ":" + std::to_string(port);
}
struct UploadContext
{
const std::string *payload;
size_t offset{0};
};
size_t readPayload(char *buffer, size_t size, size_t count, void *userData)
{
auto *context = static_cast<UploadContext *>(userData);
const auto capacity = size * count;
const auto remaining = context->payload->size() - context->offset;
const auto bytesToCopy = std::min(capacity, remaining);
if (bytesToCopy == 0)
{
return 0;
}
std::memcpy(buffer,
context->payload->data() + static_cast<std::ptrdiff_t>(context->offset),
bytesToCopy);
context->offset += bytesToCopy;
return bytesToCopy;
}
bool ensureCurlInitialized()
{
static const bool initialized = []() {
const auto result = curl_global_init(CURL_GLOBAL_DEFAULT);
if (result != CURLE_OK)
{
LOG_ERROR << "curl_global_init failed: " << curl_easy_strerror(result);
return false;
}
return true;
}();
return initialized;
}
} // namespace
std::string EmailService::deliveryName() const
{
return configString("provider", "smtp");
}
bool EmailService::sendOtp(const std::string &to, const std::string &code) const
{
const auto host = configString("smtp_host", "127.0.0.1");
const auto port = std::to_string(configInt("smtp_port", 1025));
const auto from = configString("from", "[email protected]");
const auto provider = deliveryName();
const auto defaults = smtpDefaults(provider);
const auto host = configString("host", defaults.host);
const auto port = configInt("port", defaults.port);
const auto username = configString("username", defaults.username);
const auto password = configString("password", "");
const auto from = configString("from", "");
const auto useTls = configBool("use_tls", defaults.useTls);
const auto useStartTls = configBool("use_starttls", defaults.useStartTls);
const auto verifyPeer = configBool("verify_peer", defaults.verifyPeer);
const auto verifyHost = configBool("verify_host", defaults.verifyHost);
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
if (host.empty() || from.empty())
{
LOG_ERROR << "Email delivery configuration missing: host or from";
return false;
}
if (provider == "resend" && password.empty())
{
LOG_ERROR << "Email delivery configuration missing: SMTP password for Resend";
return false;
}
addrinfo *addresses = nullptr;
if (getaddrinfo(host.c_str(), port.c_str(), &hints, &addresses) != 0)
if (!ensureCurlInitialized())
{
return false;
}
int smtp = -1;
for (auto *address = addresses; address != nullptr; address = address->ai_next)
{
smtp = socket(address->ai_family, address->ai_socktype, address->ai_protocol);
if (smtp < 0)
{
continue;
}
if (connect(smtp, address->ai_addr, address->ai_addrlen) == 0)
{
break;
}
close(smtp);
smtp = -1;
}
freeaddrinfo(addresses);
std::ostringstream payload;
payload << "To: <" << to << ">\r\n";
payload << "From: Visione <" << from << ">\r\n";
payload << "Subject: Codice OTP Visione\r\n";
payload << "MIME-Version: 1.0\r\n";
payload << "Content-Type: text/plain; charset=utf-8\r\n";
payload << "\r\n";
payload << "Il tuo codice OTP per Visione e': " << code << "\r\n";
payload << "Scade tra 10 minuti.\r\n";
const auto payloadString = payload.str();
const auto url = smtpUrl(host, port, useTls);
const auto mailFrom = "<" + from + ">";
const auto recipientAddress = "<" + to + ">";
if (smtp < 0)
using CurlHandle = std::unique_ptr<CURL, decltype(&curl_easy_cleanup)>;
using CurlList = std::unique_ptr<curl_slist, decltype(&curl_slist_free_all)>;
CurlHandle curl(curl_easy_init(), &curl_easy_cleanup);
if (!curl)
{
LOG_ERROR << "Could not initialize libcurl SMTP handle";
return false;
}
const auto closeSocket = [&smtp]() {
if (smtp >= 0)
CurlList recipients(nullptr, &curl_slist_free_all);
recipients.reset(curl_slist_append(nullptr, recipientAddress.c_str()));
if (!recipients)
{
close(smtp);
smtp = -1;
}
};
if (!readResponse(smtp) || !sendCommand(smtp, "HELO visione.local") ||
!sendCommand(smtp, "MAIL FROM:<" + from + ">") ||
!sendCommand(smtp, "RCPT TO:<" + to + ">") ||
!sendCommand(smtp, "DATA"))
{
closeSocket();
LOG_ERROR << "Could not build SMTP recipient list for " << to;
return false;
}
const std::string body =
"From: Visione <" + from + ">\r\n"
"To: " + to + "\r\n"
"Subject: Codice OTP Visione\r\n"
"Content-Type: text/plain; charset=UTF-8\r\n"
"\r\n"
"Il tuo codice OTP per Visione e': " + code + "\r\n"
"Scade tra 10 minuti.\r\n"
".";
UploadContext uploadContext{.payload = &payloadString};
char errorBuffer[CURL_ERROR_SIZE] = {0};
const auto ok = sendCommand(smtp, body) && sendCommand(smtp, "QUIT");
closeSocket();
return ok;
curl_easy_setopt(curl.get(), CURLOPT_URL, url.c_str());
curl_easy_setopt(curl.get(), CURLOPT_MAIL_FROM, mailFrom.c_str());
curl_easy_setopt(curl.get(), CURLOPT_MAIL_RCPT, recipients.get());
curl_easy_setopt(curl.get(), CURLOPT_READFUNCTION, readPayload);
curl_easy_setopt(curl.get(), CURLOPT_READDATA, &uploadContext);
curl_easy_setopt(curl.get(), CURLOPT_UPLOAD, 1L);
curl_easy_setopt(curl.get(), CURLOPT_USERAGENT, "visione-backend/1.0");
curl_easy_setopt(curl.get(), CURLOPT_CONNECTTIMEOUT, 10L);
curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT, 20L);
curl_easy_setopt(curl.get(), CURLOPT_ERRORBUFFER, errorBuffer);
curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYPEER, verifyPeer ? 1L : 0L);
curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYHOST, verifyHost ? 2L : 0L);
if (!username.empty())
{
curl_easy_setopt(curl.get(), CURLOPT_USERNAME, username.c_str());
}
if (!password.empty())
{
curl_easy_setopt(curl.get(), CURLOPT_PASSWORD, password.c_str());
}
if (useStartTls)
{
curl_easy_setopt(curl.get(), CURLOPT_USE_SSL, CURLUSESSL_ALL);
}
const auto result = curl_easy_perform(curl.get());
if (result != CURLE_OK)
{
LOG_ERROR << "OTP email delivery failed via SMTP for "
<< to
<< ", provider="
<< provider
<< ", host="
<< host
<< ", port="
<< port
<< ", error="
<< (errorBuffer[0] != '\0' ? errorBuffer : curl_easy_strerror(result));
return false;
}
LOG_INFO << "OTP email sent via SMTP for " << to
<< ", provider=" << provider
<< ", host=" << host
<< ", port=" << port;
return true;
}
} // namespace visione::email
+1 -1
View File
@@ -8,6 +8,6 @@ class EmailService
{
public:
bool sendOtp(const std::string &to, const std::string &code) const;
std::string deliveryName() const;
};
} // namespace visione::email
+114 -10
View File
@@ -1,5 +1,6 @@
#include "common/GeoJson.h"
#include <cmath>
#include <vector>
namespace visione::geojson
@@ -11,17 +12,12 @@ struct Point
double x;
double y;
};
} // namespace
bool pointInPolygon(double latitude, double longitude, const Json::Value &geojson)
bool pointInRing(double latitude,
double longitude,
const Json::Value &ring,
bool *onBoundary = nullptr)
{
if (!geojson.isObject() || geojson["type"].asString() != "Polygon" ||
!geojson["coordinates"].isArray() || geojson["coordinates"].empty())
{
return false;
}
const auto &ring = geojson["coordinates"][0];
if (!ring.isArray() || ring.size() < 4)
{
return false;
@@ -41,10 +37,27 @@ bool pointInPolygon(double latitude, double longitude, const Json::Value &geojso
const auto x = longitude;
const auto y = latitude;
bool inside = false;
constexpr double epsilon = 1e-9;
for (size_t i = 0, j = points.size() - 1; i < points.size(); j = i++)
{
const auto &pi = points[i];
const auto &pj = points[j];
const auto cross =
(x - pi.x) * (pj.y - pi.y) - (y - pi.y) * (pj.x - pi.x);
const auto minX = std::min(pi.x, pj.x) - epsilon;
const auto maxX = std::max(pi.x, pj.x) + epsilon;
const auto minY = std::min(pi.y, pj.y) - epsilon;
const auto maxY = std::max(pi.y, pj.y) + epsilon;
if (std::abs(cross) <= epsilon && x >= minX && x <= maxX && y >= minY &&
y <= maxY)
{
if (onBoundary)
{
*onBoundary = true;
}
return true;
}
const bool intersects =
((pi.y > y) != (pj.y > y)) &&
(x < (pj.x - pi.x) * (y - pi.y) / (pj.y - pi.y) + pi.x);
@@ -55,5 +68,96 @@ bool pointInPolygon(double latitude, double longitude, const Json::Value &geojso
}
return inside;
}
} // namespace visione::geojson
bool polygonContainsPoint(double latitude,
double longitude,
const Json::Value &polygon)
{
if (!polygon.isObject() || polygon["type"].asString() != "Polygon" ||
!polygon["coordinates"].isArray() || polygon["coordinates"].empty())
{
return false;
}
bool onBoundary = false;
if (!pointInRing(latitude, longitude, polygon["coordinates"][0], &onBoundary))
{
return false;
}
if (onBoundary)
{
return true;
}
for (Json::ArrayIndex i = 1; i < polygon["coordinates"].size(); ++i)
{
if (pointInRing(latitude, longitude, polygon["coordinates"][i]))
{
return false;
}
}
return true;
}
} // namespace
bool pointInPolygon(double latitude, double longitude, const Json::Value &geojson)
{
return polygonContainsPoint(latitude, longitude, geojson);
}
bool pointInGeometry(double latitude, double longitude, const Json::Value &geojson)
{
if (!geojson.isObject() || !geojson.isMember("type"))
{
return false;
}
const auto type = geojson["type"].asString();
if (type == "Polygon")
{
return polygonContainsPoint(latitude, longitude, geojson);
}
if (type == "MultiPolygon" && geojson["coordinates"].isArray())
{
for (const auto &polygonCoords : geojson["coordinates"])
{
Json::Value polygon(Json::objectValue);
polygon["type"] = "Polygon";
polygon["coordinates"] = polygonCoords;
if (polygonContainsPoint(latitude, longitude, polygon))
{
return true;
}
}
}
return false;
}
bool polygonInsideGeometry(const Json::Value &candidate, const Json::Value &container)
{
if (!candidate.isObject() || candidate["type"].asString() != "Polygon" ||
!candidate["coordinates"].isArray() || candidate["coordinates"].empty())
{
return false;
}
const auto &ring = candidate["coordinates"][0];
if (!ring.isArray() || ring.size() < 4)
{
return false;
}
for (const auto &coordinate : ring)
{
if (!coordinate.isArray() || coordinate.size() < 2)
{
return false;
}
if (!pointInGeometry(coordinate[1].asDouble(),
coordinate[0].asDouble(),
container))
{
return false;
}
}
return true;
}
} // namespace visione::geojson
+2 -1
View File
@@ -5,5 +5,6 @@
namespace visione::geojson
{
bool pointInPolygon(double latitude, double longitude, const Json::Value &geojson);
bool pointInGeometry(double latitude, double longitude, const Json::Value &geojson);
bool polygonInsideGeometry(const Json::Value &candidate, const Json::Value &container);
} // namespace visione::geojson
+31 -1
View File
@@ -1,5 +1,9 @@
#include "common/Http.h"
#include <drogon/HttpRequest.h>
#include <sstream>
namespace visione::http
{
drogon::HttpResponsePtr json(Json::Value body, drogon::HttpStatusCode status)
@@ -22,6 +26,14 @@ drogon::HttpResponsePtr error(const std::string &code,
return json(std::move(body), status);
}
drogon::HttpResponsePtr redirect(const std::string &url,
drogon::HttpStatusCode status)
{
auto response = drogon::HttpResponse::newRedirectionResponse(url);
response->setStatusCode(status);
return response;
}
Json::Value requireJson(const drogon::HttpRequestPtr &request)
{
const auto &body = request->getJsonObject();
@@ -31,5 +43,23 @@ Json::Value requireJson(const drogon::HttpRequestPtr &request)
}
return *body;
}
} // namespace visione::http
Json::Value parseJsonBody(const std::string &body)
{
if (body.empty())
{
return Json::Value(Json::nullValue);
}
Json::CharReaderBuilder builder;
builder["collectComments"] = false;
std::string errors;
Json::Value parsed;
std::istringstream stream(body);
if (!Json::parseFromStream(builder, stream, &parsed, &errors))
{
return Json::Value(Json::nullValue);
}
return parsed;
}
} // namespace visione::http
+3 -1
View File
@@ -12,6 +12,8 @@ drogon::HttpResponsePtr json(Json::Value body,
drogon::HttpResponsePtr error(const std::string &code,
drogon::HttpStatusCode status,
const std::string &message = {});
drogon::HttpResponsePtr redirect(const std::string &url,
drogon::HttpStatusCode status = drogon::k302Found);
Json::Value requireJson(const drogon::HttpRequestPtr &request);
Json::Value parseJsonBody(const std::string &body);
} // namespace visione::http
+18 -4
View File
@@ -56,10 +56,24 @@
"token_ttl_hours": 24,
"otp_ttl_minutes": 10
},
"cie_auth": {
"service_base_url": "http://127.0.0.1:8001",
"start_path": "/visione/start",
"shared_secret": "change-me-for-local-dev",
"state_ttl_minutes": 10,
"default_frontend_redirect_uri": "http://localhost:3000/auth/cie/callback"
},
"mail": {
"smtp_host": "127.0.0.1",
"smtp_port": 1025,
"from": "[email protected]"
"provider": "smtp",
"host": "127.0.0.1",
"port": 1025,
"username": "",
"password": "",
"from": "[email protected]",
"use_tls": false,
"use_starttls": false,
"verify_peer": false,
"verify_host": false
},
"uploads": {
"public_base_path": "/uploads",
@@ -69,7 +83,7 @@
"spec_path": "build/openapi/visione.openapi.json"
},
"cors": {
"allowed_origin": "http://localhost:3000"
"allowed_origin": "http://127.0.0.1:3000"
}
}
}
+104 -46
View File
@@ -33,19 +33,6 @@ std::optional<visione::auth::UserContext> requireUser(
return user;
}
bool blockPasswordChangeRequired(
const visione::auth::UserContext &user,
std::function<void(const HttpResponsePtr &)> &callback)
{
if (!visione::auth::mustChangePassword(user))
{
return false;
}
callback(visione::http::error("password_change_required",
drogon::k403Forbidden));
return true;
}
void sendAdmin(Json::Value body,
std::function<void(const HttpResponsePtr &)> &&callback)
{
@@ -69,24 +56,24 @@ void sendAdmin(Json::Value body,
{
status = drogon::k409Conflict;
}
else if (error == "invalid_reference" || error.starts_with("invalid_"))
{
status = drogon::k422UnprocessableEntity;
}
else if (error == "area_outside_allowed_boundary")
{
status = drogon::k422UnprocessableEntity;
}
callback(visione::http::json(std::move(body), status));
}
} // namespace
void AdminController::listCategories(
const drogon::HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback)
{
visione::services::AdminService service;
callback(visione::http::json(service.listCategories()));
}
void AdminController::createCategory(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -95,20 +82,12 @@ void AdminController::createCategory(
std::move(callback));
}
void AdminController::listAreas(
const drogon::HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback)
{
visione::services::AdminService service;
callback(visione::http::json(service.listAreas()));
}
void AdminController::createArea(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -117,20 +96,12 @@ void AdminController::createArea(
std::move(callback));
}
void AdminController::listTeams(
const drogon::HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback)
{
visione::services::AdminService service;
callback(visione::http::json(service.listTeams()));
}
void AdminController::createTeam(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -139,12 +110,99 @@ void AdminController::createTeam(
std::move(callback));
}
void AdminController::updateCategory(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
long long categoryId)
{
auto user = requireUser(req, callback);
if (!user)
{
return;
}
visione::services::AdminService service;
sendAdmin(service.updateCategory(*user, categoryId, visione::http::requireJson(req)),
std::move(callback));
}
void AdminController::deleteCategory(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
long long categoryId)
{
auto user = requireUser(req, callback);
if (!user)
{
return;
}
visione::services::AdminService service;
sendAdmin(service.deleteCategory(*user, categoryId), std::move(callback));
}
void AdminController::updateArea(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
long long areaId)
{
auto user = requireUser(req, callback);
if (!user)
{
return;
}
visione::services::AdminService service;
sendAdmin(service.updateArea(*user, areaId, visione::http::requireJson(req)),
std::move(callback));
}
void AdminController::deleteArea(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
long long areaId)
{
auto user = requireUser(req, callback);
if (!user)
{
return;
}
visione::services::AdminService service;
sendAdmin(service.deleteArea(*user, areaId), std::move(callback));
}
void AdminController::updateTeam(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
long long teamId)
{
auto user = requireUser(req, callback);
if (!user)
{
return;
}
visione::services::AdminService service;
sendAdmin(service.updateTeam(*user, teamId, visione::http::requireJson(req)),
std::move(callback));
}
void AdminController::deleteTeam(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
long long teamId)
{
auto user = requireUser(req, callback);
if (!user)
{
return;
}
visione::services::AdminService service;
sendAdmin(service.deleteTeam(*user, teamId), std::move(callback));
}
void AdminController::overviewStats(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -157,7 +215,7 @@ void AdminController::listModeration(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -175,7 +233,7 @@ void AdminController::updateModeration(
long long flagId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -189,7 +247,7 @@ void AdminController::listUsers(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -203,7 +261,7 @@ void AdminController::updateUserRoles(
long long userId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -218,7 +276,7 @@ void AdminController::updateUserBan(
long long userId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
+42 -14
View File
@@ -6,16 +6,29 @@ class AdminController : public drogon::HttpController<AdminController>
{
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(AdminController::listCategories,
"/api/v1/admin/categories",
drogon::Get);
ADD_METHOD_TO(AdminController::createCategory,
"/api/v1/admin/categories",
drogon::Post);
ADD_METHOD_TO(AdminController::listAreas, "/api/v1/admin/areas", drogon::Get);
ADD_METHOD_TO(AdminController::createArea, "/api/v1/admin/areas", drogon::Post);
ADD_METHOD_TO(AdminController::listTeams, "/api/v1/admin/teams", drogon::Get);
ADD_METHOD_TO(AdminController::createTeam, "/api/v1/admin/teams", drogon::Post);
ADD_METHOD_TO(AdminController::updateCategory,
"/api/v1/admin/categories/{1}",
drogon::Patch);
ADD_METHOD_TO(AdminController::deleteCategory,
"/api/v1/admin/categories/{1}",
drogon::Delete);
ADD_METHOD_TO(AdminController::updateArea,
"/api/v1/admin/areas/{1}",
drogon::Patch);
ADD_METHOD_TO(AdminController::deleteArea,
"/api/v1/admin/areas/{1}",
drogon::Delete);
ADD_METHOD_TO(AdminController::updateTeam,
"/api/v1/admin/teams/{1}",
drogon::Patch);
ADD_METHOD_TO(AdminController::deleteTeam,
"/api/v1/admin/teams/{1}",
drogon::Delete);
ADD_METHOD_TO(AdminController::overviewStats,
"/api/v1/admin/stats/overview",
drogon::Get);
@@ -34,24 +47,39 @@ class AdminController : public drogon::HttpController<AdminController>
drogon::Patch);
METHOD_LIST_END
void listCategories(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void createCategory(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void listAreas(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void createArea(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void listTeams(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void createTeam(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void updateCategory(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long categoryId);
void deleteCategory(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long categoryId);
void updateArea(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long areaId);
void deleteArea(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long areaId);
void updateTeam(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long teamId);
void deleteTeam(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long teamId);
void overviewStats(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
+29 -12
View File
@@ -66,7 +66,7 @@ void AuthController::verifyOtp(
serviceResponse(service.verifyOtp(body), std::move(callback));
}
void AuthController::loginWithPassword(
void AuthController::startCieLogin(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
@@ -78,21 +78,15 @@ void AuthController::loginWithPassword(
}
visione::services::AuthService service;
serviceResponse(service.loginWithPassword(body), std::move(callback));
serviceResponse(service.startCieLogin(body), std::move(callback));
}
void AuthController::changePassword(
void AuthController::completeCieLogin(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
const auto user = visione::auth::authenticate(req);
if (!user)
{
callback(visione::http::error("unauthorized", drogon::k401Unauthorized));
return;
}
const auto body = visione::http::requireJson(req);
const std::string rawBody(req->body());
const auto body = visione::http::parseJsonBody(rawBody);
if (body.isNull())
{
callback(visione::http::error("invalid_json", drogon::k400BadRequest));
@@ -100,7 +94,30 @@ void AuthController::changePassword(
}
visione::services::AuthService service;
serviceResponse(service.changePassword(*user, body), std::move(callback));
auto responseBody =
service.completeCieLogin(rawBody, body, req->getHeader("X-Visione-Signature"));
if (responseBody.get("ok", false).asBool() &&
responseBody.isMember("redirect_url"))
{
callback(visione::http::redirect(responseBody["redirect_url"].asString()));
return;
}
serviceResponse(std::move(responseBody), std::move(callback));
}
void AuthController::cieSessionStatus(
const drogon::HttpRequestPtr & /*req*/,
std::function<void(const HttpResponsePtr &)> &&callback,
std::string state)
{
visione::services::AuthService service;
auto body = service.cieSessionStatus(state);
if (!body.get("ok", false).asBool())
{
callback(visione::http::json(std::move(body), drogon::k404NotFound));
return;
}
callback(visione::http::json(std::move(body)));
}
void AuthController::me(
+13 -6
View File
@@ -15,12 +15,15 @@ class AuthController : public drogon::HttpController<AuthController>
ADD_METHOD_TO(AuthController::verifyOtp,
"/api/v1/auth/otp/verify",
drogon::Post);
ADD_METHOD_TO(AuthController::loginWithPassword,
"/api/v1/auth/login/password",
ADD_METHOD_TO(AuthController::startCieLogin,
"/api/v1/auth/cie/start",
drogon::Post);
ADD_METHOD_TO(AuthController::changePassword,
"/api/v1/auth/change-password",
ADD_METHOD_TO(AuthController::completeCieLogin,
"/api/v1/auth/cie/exchange",
drogon::Post);
ADD_METHOD_TO(AuthController::cieSessionStatus,
"/api/v1/auth/cie/session/{1}",
drogon::Get);
ADD_METHOD_TO(AuthController::me, "/api/v1/me", drogon::Get);
ADD_METHOD_TO(AuthController::editUser, "/api/v1/me", drogon::Patch);
METHOD_LIST_END
@@ -34,12 +37,16 @@ class AuthController : public drogon::HttpController<AuthController>
void verifyOtp(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void loginWithPassword(
void startCieLogin(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void changePassword(
void completeCieLogin(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void cieSessionStatus(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
std::string state);
void me(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void editUser(const drogon::HttpRequestPtr &req,
+20
View File
@@ -21,6 +21,14 @@ std::optional<std::string> queryValue(const drogon::HttpRequestPtr &req,
}
} // namespace
void PublicController::listAreas(
const drogon::HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback)
{
visione::services::ReportService service;
callback(visione::http::json(service.listAreas()));
}
void PublicController::openDataReports(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
@@ -29,3 +37,15 @@ void PublicController::openDataReports(
callback(visione::http::json(service.openDataReports(queryValue(req, "page"),
queryValue(req, "per_page"))));
}
void PublicController::interventionSummary(
const drogon::HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &token)
{
visione::services::ReportService service;
auto body = service.publicInterventionSummary(token);
const auto status = body.get("ok", false).asBool() ? drogon::k200OK
: drogon::k404NotFound;
callback(visione::http::json(std::move(body), status));
}
+10
View File
@@ -6,12 +6,22 @@ class PublicController : public drogon::HttpController<PublicController>
{
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(PublicController::listAreas, "/api/v1/areas", drogon::Get);
ADD_METHOD_TO(PublicController::openDataReports,
"/api/v1/open-data/reports",
drogon::Get);
ADD_METHOD_TO(PublicController::interventionSummary,
"/api/v1/interventions/{1}",
drogon::Get);
METHOD_LIST_END
void listAreas(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void openDataReports(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void interventionSummary(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
const std::string &token);
};
+98 -31
View File
@@ -36,19 +36,6 @@ std::optional<visione::auth::UserContext> requireUser(
return user;
}
bool blockPasswordChangeRequired(
const visione::auth::UserContext &user,
std::function<void(const HttpResponsePtr &)> &callback)
{
if (!visione::auth::mustChangePassword(user))
{
return false;
}
callback(visione::http::error("password_change_required",
drogon::k403Forbidden));
return true;
}
void sendService(Json::Value body,
std::function<void(const HttpResponsePtr &)> &&callback)
{
@@ -68,10 +55,22 @@ void sendService(Json::Value body,
status = drogon::k404NotFound;
}
else if (error == "invalid_status_transition" ||
error == "media_limit_reached")
error == "media_limit_reached" ||
error == "report_not_open" ||
error == "report_owned_by_other_operator" ||
error == "feedback_already_exists")
{
status = drogon::k409Conflict;
}
else if (error == "invalid_reference" || error.starts_with("invalid_") ||
error == "missing_rating")
{
status = drogon::k422UnprocessableEntity;
}
else if (error == "coordinates_outside_allowed_boundary")
{
status = drogon::k422UnprocessableEntity;
}
callback(visione::http::json(std::move(body), status));
}
@@ -108,7 +107,7 @@ void ReportController::create(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -128,7 +127,7 @@ void ReportController::list(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -141,12 +140,38 @@ void ReportController::list(
std::move(callback));
}
void ReportController::listCategories(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user)
{
return;
}
visione::services::ReportService service;
sendService(service.listCategories(*user), std::move(callback));
}
void ReportController::listTeams(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user)
{
return;
}
visione::services::ReportService service;
sendService(service.listOperatorTeams(*user), std::move(callback));
}
void ReportController::assignedReports(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -162,7 +187,7 @@ void ReportController::openReports(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -178,7 +203,7 @@ void ReportController::otherOperatorReports(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -195,7 +220,7 @@ void ReportController::detail(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -209,7 +234,7 @@ void ReportController::interventionSummary(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -217,13 +242,55 @@ void ReportController::interventionSummary(
sendService(service.interventionSummary(*user, reportId), std::move(callback));
}
void ReportController::teamSuggestions(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
long long reportId)
{
auto user = requireUser(req, callback);
if (!user)
{
return;
}
visione::services::ReportService service;
sendService(service.teamSuggestions(*user, reportId), std::move(callback));
}
void ReportController::createInterventionLink(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
long long reportId)
{
auto user = requireUser(req, callback);
if (!user)
{
return;
}
visione::services::ReportService service;
sendService(service.createInterventionLink(*user, reportId), std::move(callback));
}
void ReportController::revokeInterventionLink(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
long long reportId)
{
auto user = requireUser(req, callback);
if (!user)
{
return;
}
visione::services::ReportService service;
sendService(service.revokeInterventionLink(*user, reportId), std::move(callback));
}
void ReportController::updateStatus(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
long long reportId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -238,7 +305,7 @@ void ReportController::takeReport(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -252,7 +319,7 @@ void ReportController::addMessage(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -267,7 +334,7 @@ void ReportController::listMessages(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -282,7 +349,7 @@ void ReportController::flagMessage(
long long messageId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -298,7 +365,7 @@ void ReportController::assignTeam(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -313,7 +380,7 @@ void ReportController::addFeedback(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -328,7 +395,7 @@ void ReportController::flagReport(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -343,7 +410,7 @@ void ReportController::uploadMedia(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -368,7 +435,7 @@ void ReportController::uploadMedia(
auto allowed = service.canAddMedia(*user, reportId);
if (!allowed.get("ok", false).asBool())
{
callback(visione::http::json(std::move(allowed), drogon::k403Forbidden));
sendService(std::move(allowed), std::move(callback));
return;
}
+32
View File
@@ -8,6 +8,12 @@ class ReportController : public drogon::HttpController<ReportController>
METHOD_LIST_BEGIN
ADD_METHOD_TO(ReportController::create, "/api/v1/reports", drogon::Post);
ADD_METHOD_TO(ReportController::list, "/api/v1/reports", drogon::Get);
ADD_METHOD_TO(ReportController::listCategories,
"/api/v1/report-categories",
drogon::Get);
ADD_METHOD_TO(ReportController::listTeams,
"/api/v1/operator/teams",
drogon::Get);
ADD_METHOD_TO(ReportController::assignedReports,
"/api/v1/operator/reports/assigned",
drogon::Get);
@@ -21,6 +27,15 @@ class ReportController : public drogon::HttpController<ReportController>
ADD_METHOD_TO(ReportController::interventionSummary,
"/api/v1/reports/{1}/intervention-summary",
drogon::Get);
ADD_METHOD_TO(ReportController::teamSuggestions,
"/api/v1/reports/{1}/team-suggestions",
drogon::Get);
ADD_METHOD_TO(ReportController::createInterventionLink,
"/api/v1/reports/{1}/intervention-link",
drogon::Post);
ADD_METHOD_TO(ReportController::revokeInterventionLink,
"/api/v1/reports/{1}/intervention-link",
drogon::Delete);
ADD_METHOD_TO(ReportController::updateStatus,
"/api/v1/reports/{1}/status",
drogon::Patch);
@@ -54,6 +69,11 @@ class ReportController : public drogon::HttpController<ReportController>
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void list(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void listCategories(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void listTeams(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void assignedReports(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
@@ -70,6 +90,18 @@ class ReportController : public drogon::HttpController<ReportController>
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long reportId);
void teamSuggestions(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long reportId);
void createInterventionLink(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long reportId);
void revokeInterventionLink(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long reportId);
void updateStatus(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
+4 -17
View File
@@ -32,19 +32,6 @@ std::optional<visione::auth::UserContext> requireUser(
}
return user;
}
bool blockPasswordChangeRequired(
const visione::auth::UserContext &user,
std::function<void(const HttpResponsePtr &)> &callback)
{
if (!visione::auth::mustChangePassword(user))
{
return false;
}
callback(visione::http::error("password_change_required",
drogon::k403Forbidden));
return true;
}
} // namespace
void UserController::listNotifications(
@@ -52,7 +39,7 @@ void UserController::listNotifications(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -68,7 +55,7 @@ void UserController::markNotificationRead(
long long notificationId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -84,7 +71,7 @@ void UserController::markAllNotificationsRead(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
@@ -97,7 +84,7 @@ void UserController::rewards(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
if (!user)
{
return;
}
+26 -14
View File
@@ -13,14 +13,25 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-visione}
DB_NAME: ${DB_NAME:-visione}
DB_TIMEOUT: ${DB_TIMEOUT:-30}
SMTP_HOST: ${SMTP_HOST:-mailpit}
SMTP_PORT: ${SMTP_PORT:-1025}
MAIL_PROVIDER: ${MAIL_PROVIDER:-resend}
SMTP_HOST: ${SMTP_HOST:-smtp.resend.com}
SMTP_PORT: ${SMTP_PORT:-465}
SMTP_USERNAME: ${SMTP_USERNAME:-resend}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
SMTP_USE_STARTTLS: ${SMTP_USE_STARTTLS:-false}
SMTP_VERIFY_PEER: ${SMTP_VERIFY_PEER:-true}
SMTP_VERIFY_HOST: ${SMTP_VERIFY_HOST:-true}
RESEND_API_KEY: ${RESEND_API_KEY:-}
MAIL_FROM: ${MAIL_FROM:[email protected]}
CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN:-http://localhost:3000}
CIE_AUTH_SERVICE_BASE_URL: ${CIE_AUTH_SERVICE_BASE_URL:-http://127.0.0.1:8001}
CIE_AUTH_START_PATH: ${CIE_AUTH_START_PATH:-/visione/start}
CIE_AUTH_SHARED_SECRET: ${CIE_AUTH_SHARED_SECRET:-change-me-for-local-dev}
CIE_AUTH_STATE_TTL_MINUTES: ${CIE_AUTH_STATE_TTL_MINUTES:-10}
CIE_AUTH_DEFAULT_FRONTEND_REDIRECT_URI: ${CIE_AUTH_DEFAULT_FRONTEND_REDIRECT_URI:-http://localhost:3000/auth/cie/callback}
expose:
- "5555"
networks:
- visione
- dokploy-network
depends_on:
mysql:
condition: service_healthy
@@ -37,8 +48,6 @@ services:
- "3306"
volumes:
- mysql-data:/var/lib/mysql
networks:
- visione
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"]
interval: 5s
@@ -52,14 +61,17 @@ services:
expose:
- "1025"
- "8025"
networks:
- visione
phpmyadmin:
image: phpmyadmin:latest
restart: unless-stopped
expose:
- "80"
environment:
PMA_HOST: mysql
PMA_USER: ${DB_USER:-visione}
PMA_PASSWORD: ${DB_PASSWORD:-visione}
volumes:
mysql-data:
uploads-data:
networks:
visione:
dokploy-network:
external: true
-66
View File
@@ -1,66 +0,0 @@
# Visione API Spec
Base path: `/api/v1`.
## Technical
- `GET /health`: process health.
- `GET /ready`: MySQL readiness.
- `GET /openapi.json`: generated OpenAPI 3.0 JSON.
- `GET /docs`: Swagger UI page backed by the generated spec.
## Auth
- `POST /auth/register`: create a citizen account.
- `POST /auth/otp/request`: send an OTP email through Mailpit.
- `POST /auth/otp/verify`: verify OTP and create an API token.
- `POST /auth/login/password`: password login fallback.
- `POST /auth/change-password`: change the authenticated user's password.
- `GET /me`: return the authenticated user.
- `PATCH /me`: edit user informations.
Protected endpoints use:
```http
Authorization: Bearer <token>
```
## Reports
- `POST /reports`: create a report.
- `GET /reports`: list reports with optional `status`, `category_id`, `area_id`.
- `GET /operator/reports/assigned`: list reports assigned to the current operator.
- `GET /operator/reports/open`: list open unassigned reports.
- `GET /operator/reports/others`: list reports assigned to other operators.
- `GET /reports/{id}`: report detail.
- `GET /reports/{id}/intervention-summary`: operator-facing report summary with
citizen contact data, media and latest messages.
- `PATCH /reports/{id}/status`: update status.
- `POST /reports/{id}/take`: assigned-queue operator takes an open report.
- `POST /reports/{id}/messages`: write a report message.
- `GET /reports/{id}/messages`: list report messages.
- `POST /reports/{id}/messages/{message_id}/flags`: assigned operator flags a
report message for moderation.
- `POST /reports/{id}/assignments`: assign a team.
- `POST /reports/{id}/feedback`: add citizen feedback.
- `POST /reports/{id}/flags`: assigned operator flags a report for moderation.
- `POST /reports/{id}/media`: upload up to five multipart media files. Supported
types are JPEG, PNG, WebP, MP4, WebM and QuickTime.
## User
- `GET /notifications`: list authenticated user notifications.
- `PATCH /notifications/{id}/read`: mark one notification as read.
- `PATCH /notifications/read-all`: mark all authenticated user notifications as read.
- `GET /me/rewards`: return current points and reward event history.
## Admin Reference Data
- `GET|POST /admin/categories`
- `GET|POST /admin/areas`
- `GET|POST /admin/teams`
- `GET /admin/stats/overview`
- `GET /admin/moderation`: list moderation flags with optional `status`.
- `PATCH /admin/moderation/{id}`: resolve or dismiss a moderation flag, optionally
rejecting the related report.
- `GET /admin/users`
- `PATCH /admin/users/{id}/roles`
- `PATCH /admin/users/{id}/ban`
## Public Data
- `GET /open-data/reports`: anonymized resolved/closed report export.
-48
View File
@@ -1,48 +0,0 @@
# Visione Backend Architecture
## Layout
- `controllers/`: Drogon `HttpController` classes.
- `services/`: application/business logic.
- `repositories/`: database helpers and migrations.
- `common/`: reusable HTTP, auth, crypto, email and validation utilities.
- `migrations/`: versioned SQL files.
- `scripts/generate_openapi.py`: minimal OpenAPI route extractor.
- `docs/openapi/`: manual OpenAPI contract documents.
- `scripts/lab/`: local Streamlit frontend for visual endpoint testing.
- `docs/`: product, API, schema and milestone documentation.
## Drogon Usage
Controllers use Drogon route macros and return JSON with
`HttpResponse::newHttpJsonResponse`. Database access uses
`drogon::orm::DbClient` with raw parameterized SQL placeholders.
The codebase does not use Drogon generated models or `Mapper<T>`. Raw SQL stays
in repository/database helpers because the schema is still evolving.
## Local Email
OTP emails are sent to Mailpit over SMTP. Mailpit keeps messages local and
inspectable through its web UI.
## Password Hashing
User passwords use PBKDF2-HMAC-SHA256 with per-password random salts and 210000
iterations. This is intentionally slower than a plain SHA-256 digest and is
available through OpenSSL, which is already a Drogon dependency in this project.
Legacy `sha256$` password hashes are rejected instead of silently accepted.
## OpenAPI Generation
`scripts/generate_openapi.py` scans Drogon `ADD_METHOD_TO` route macros in
`controllers/*.h` and extracts only paths, HTTP methods, operation ids, tags and
path parameters. It merges those route skeletons with
`docs/openapi/visione.openapi.yaml`, which owns summaries, request bodies,
responses, schemas and examples. The manual file uses JSON-compatible YAML so
the CMake `openapi` target can run without extra Python packages and writes
`build/openapi/visione.openapi.json`.
If the manual YAML contains an operation that no longer exists in the Drogon
route list, generation fails unless that operation is explicitly marked
`x-manual-only: true`.
## Visione Lab
`scripts/lab/` is a Streamlit dev frontend for visual endpoint testing. It is
intentionally separate from the real product frontend and should stay small:
forms, uploads and response rendering only. Run it with uv from `scripts/lab/`.
-31
View File
@@ -1,31 +0,0 @@
# Visione Database Schema
The schema is stored in ordered SQL migrations under `migrations/`.
Persistence currently uses `drogon::orm::DbClient` with raw parameterized SQL.
The project does not use Drogon generated models or `Mapper<T>` while the schema
is still evolving.
Important tables:
- `users`: identity, personal data, password hash, points and moderation flags.
- `roles`, `permissions`, `role_permissions`, `user_roles`: authorization.
- `otp_challenges`: local OTP login flow.
- `api_tokens`: bearer tokens for the local API.
- `report_categories`: report classification.
- `areas`: named city areas with GeoJSON polygon boundaries.
- `team_categories`, `teams`: municipal intervention teams.
- `reports`: citizen reports with coordinates, address, status, area and assigned operator.
- `report_status_history`: immutable status transition log.
- `assignments`: report-to-team assignment history.
- `report_photos`: uploaded report media URL, original filename, content type,
media kind and size. The table name is historical; rows can be images or videos.
- `messages`: citizen/operator report conversation.
- `feedback`: citizen rating and comment after resolution.
- `reward_events`: indirect gamification points ledger.
- `notifications`: local in-app notifications for status changes, messages and
reward events.
- `moderation_flags`: report/message review queue for abusive or invalid content.
GeoJSON is intentionally used instead of MySQL Spatial in milestone 1 to keep
the local setup simple and portable.
-22
View File
@@ -1,22 +0,0 @@
# Visione Backend LLM Context
Visione is a C++20/Drogon backend for a municipal urban issue reporting system.
Citizens create reports with category, position, address, description and media
attachments.
Operators manage report queues, assign teams, update status, chat with citizens
and use intervention summaries.
Admins manage roles, users, areas, teams, reference data, moderation and stats.
The project runs locally on the host machine. Docker is used only for external
services such as MySQL and Mailpit. Real production deployment and Dockerizing
the backend are later delivery tasks.
Important conventions:
- Code and database identifiers are English.
- The webapp is Italian-facing, so labels or seeded category names may be Italian.
- Use Drogon references from `/home/gabdevele/Projects/snitchitaly/backend/drogon`
and `https://drogonframework.github.io/drogon-docs/`.
- `AGENTS.md` is the durable agent instruction entrypoint.
- `docs/MILESTONE_01.md`, `docs/MILESTONE_02.md`, `docs/MILESTONE_03.md` and
`docs/MILESTONE_04.md` describe what has been implemented and what comes next.
-22
View File
@@ -1,22 +0,0 @@
# Milestone 01: Local Backend Foundation
## Implemented in this milestone
- Project identity and documentation for Visione.
- Root `AGENTS.md` for future coding agents.
- Local Docker services for MySQL and Mailpit.
- Initial MySQL schema and seed data.
- Drogon backend structure for auth, reports, admin data and stats.
- OTP email delivery through Mailpit.
- Focused regression tests for technical endpoints and core validation helpers.
- Local admin seed: `[email protected]` / `ChangeMe123!`, with forced password change flag.
- Multipart report media upload endpoint storing files under `uploads/reports/`.
- PBKDF2-HMAC-SHA256 password hashes for credentials. Legacy SHA-256 hashes are intentionally rejected.
## To do next
- Consider Argon2id/bcrypt and password reset flows before production hardening.
- Add real email provider configuration for non-local environments.
- Add CIE/SPID integration design and provider adapters.
- Expand frontend-driven API coverage as Vue screens are implemented.
- Add stronger frontend-driven endpoint coverage as Vue screens are implemented.
- Add notification channels, route optimization and open-data exports.
- Dockerize the backend only for final delivery/deployment.
-18
View File
@@ -1,18 +0,0 @@
# Milestone 02: Workflow Core and Hardening
## Implemented in this milestone
- Documentation/config fixes from Milestone 01 review.
- Authorization context with roles, permissions and forced password-change state.
- Authenticated password change endpoint.
- Admin user listing, role assignment and ban/unban endpoints.
- Report ownership workflow with assigned operator and take endpoint.
- Valid report status transitions.
- Citizen report filters scoped to own reports.
- Report media upload validation and metadata storage.
## To do next
- Add broader API tests once the frontend contracts stabilize.
- Add notification delivery for status changes.
- Add moderation flow for abusive reports/messages.
- Add open-data exports and statistics refinements.
- Design CIE/SPID integration.
-20
View File
@@ -1,20 +0,0 @@
# Milestone 03: Media, OpenAPI and API Lab
## Implemented in this milestone
- Removed legacy `sha256$` password verification and updated local admin seeds to
PBKDF2-HMAC-SHA256.
- Documented why PBKDF2-HMAC-SHA256 is used now: salted, intentionally slower
than plain SHA-256 and available through the existing OpenSSL dependency.
- Added report media uploads at `POST /api/v1/reports/{id}/media`.
- Supported JPEG, PNG, WebP, MP4, WebM and QuickTime uploads.
- Report attachments now use the `/media` upload path.
- Added generated OpenAPI JSON from Drogon route macros plus the manual
`docs/openapi/visione.openapi.yaml` contract.
- Exposed `/api/v1/openapi.json` and `/api/v1/docs`.
- Added a small Streamlit API lab for visual endpoint testing.
## To do next
- Expand the manual OpenAPI contract as endpoints evolve.
- Add minimal media upload integration tests after endpoint contracts settle.
- Keep API and docs on `media` terminology only.
- Keep the Streamlit lab small and replace it with the real frontend later.
-22
View File
@@ -1,22 +0,0 @@
# Milestone 04: Backend Completion
## Implemented in this milestone
- Added operator queues for assigned reports, open unassigned reports and reports
assigned to other operators.
- Added an operator intervention summary with report data, citizen contact data,
media and latest messages.
- Added local in-app notifications for status changes, team assignment, report
messages and reward events.
- Added citizen reward ledger APIs and automatic points for resolved reports.
- Added report/message moderation flags with admin/operator review and optional
report rejection.
- Expanded admin statistics with category, team, monthly and segmented
resolution-time aggregates.
- Added anonymized public open-data report export.
- Updated the manual OpenAPI contract for the new backend routes.
## Intentionally not implemented
- Real CIE/SPID integration.
- Real SMS, Telegram, WhatsApp or production SMTP notification delivery.
- Route optimization for intervention teams.
- Frontend-specific pages outside the Streamlit API lab.
-51
View File
@@ -1,51 +0,0 @@
# Visione Project Spec
## Goal
Visione lets citizens report urban problems and lets municipal staff manage,
assign and resolve interventions while preserving history and basic analytics.
## Roles
- `citizen`: registers, requests OTP login, creates reports, follows own reports,
sends messages and leaves feedback after resolution.
- `operator`: views reports, takes open reports, assigns teams, updates managed
reports, messages citizens and flags abusive/fake citizen content on managed
reports.
- `admin`: manages users, roles, reference data, teams, areas and all reports.
The system admin seed must change password before using protected operational
or administrative APIs.
## Report Statuses
- `OPEN`: created and waiting for review.
- `UNDER_REVIEW`: an operator is checking the report.
- `ASSIGNED`: a team has been assigned.
- `IN_PROGRESS`: intervention is active.
- `RESOLVED`: intervention was completed.
- `REJECTED`: report was invalid or abusive.
- `CLOSED`: administrative closure after feedback or final review.
Valid milestone 2 transitions:
- `OPEN -> UNDER_REVIEW` when an operator takes the report.
- `UNDER_REVIEW -> ASSIGNED` when a team is assigned.
- `ASSIGNED -> IN_PROGRESS`.
- `IN_PROGRESS -> RESOLVED`.
- `RESOLVED -> CLOSED`.
- `OPEN/UNDER_REVIEW/ASSIGNED/IN_PROGRESS -> REJECTED` only for invalid reports.
- `REJECTED -> CLOSED`.
## MVP Scope
- Local auth with email OTP delivered through Mailpit.
- MySQL persistence with migrations and seed data.
- Report creation/list/detail/status/message/assignment/feedback/moderation flows.
- Area polygons stored as GeoJSON and matched by backend logic.
- Operator queues for assigned, open and other-operator reports.
- Local in-app notifications and reward points ledger.
- Public anonymized open-data report export.
- Basic and segmented stats for administration.
## Out of Scope for the Local Backend MVP
- Real CIE integration.
- Real SMTP provider, SMS, Telegram or WhatsApp notifications.
- Route optimization.
- Full Docker packaging of the backend.
-168
View File
@@ -1,168 +0,0 @@
Traccia principale del progetto:
Caso di studio
Segnalazioni urbane
Un’amministrazione comunale desidera sviluppare un sistema digitale per permettere ai cittadini di segnalare problemi presenti sul territorio, come buche stradali, illuminazione guasta, rifiuti abbandonati o altri disservizi urbani, con l’obiettivo di migliorare la gestione degli interventi e la qualità della vita in città.
Il sistema deve prevedere due principali tipologie di utenti: da un lato i cittadini, che inviano segnalazioni, dall’altro gli operatori comunali, responsabili della gestione e risoluzione degli interventi.
I cittadini devono potersi registrare e accedere tramite credenziali personali. Una volta autenticati, devono poter inserire una nuova segnalazione specificando il tipo di problema, la posizione e una breve descrizione. Il sistema deve memorizzare le segnalazioni e permettere ai cittadini di visualizzarne lo stato nel tempo, ad esempio in attesa, in lavorazione o risolta.
Gli utenti devono inoltre poter consultare le segnalazioni già presenti, eventualmente filtrandole per categoria o area, e fornire un feedback sull’efficacia degli interventi effettuati.
Dal lato degli operatori comunali, il sistema deve consentire di visualizzare tutte le segnalazioni ricevute, assegnarle a specifiche squadre di intervento e aggiornarne lo stato man mano che vengono gestite. Deve essere possibile monitorare i tempi di risposta e individuare le aree della città con maggior concentrazione di problemi.
Il sistema deve mantenere uno storico delle segnalazioni e degli interventi effettuati, permettendo all’amministrazione di analizzare i dati raccolti per pianificare interventi futuri e migliorare l’efficienza dei servizi.
Dopodichè ho elaborato una serie di feature che il sistema deve implementare anche rielaborazione e idee. ovvero:
Feature Segnalazioni urbane
– Lo sviluppo del della webapp avverrà su una istanza self hosted di Gitea come gesto di ribellione nei confronti di github che userà i dati sulle repo per trainare i modelli di IA (lo faceva già prima ma ora lo dice)
– Il progetto sarà diviso tra frontend e backend. Frontend in probabilmente Vue.js e backend in C++, nello specifico Dragon è stato scelto come framework. Abbiamo scelto di sviluppare una webapp perchè il punto importante di un progetto del genere è la versatilità, la compatibilità su tutti i sistemi e la velocità.
– Deve esistere un account amministratore generato dal sistema, appena ci si logga la prima volta ci chiederà di cambiare password, e questo profilo lo dovrà teoricamente avere solo chi il tecnico che gestisce la piattaforma di quel comune, dato che da questo profilo si può fare tutto. E’ diverso da un account operatore perchè l’operatore ha meno permessi rispetto ad un admin, l’admin può decidere chi sono gli operatori. (NOTA: eventualmente ci possono essere i permessi customizzabili, quindi l’admin può creare anche altri utenti che hanno permessi in più)
– Deve essere possibile registrarsi alla piattaforma con tutti i quanti i dati anagrafici possibili e immaginabili, sarebbe figo avere un sistema che dato il codice fiscale ti auto completa i dati deducibili da quest’ultimo. Integrazione frontend con google maps per l’indirizzo. Deve essere possibile anche collegare CIE. Nella registrazione si richiede verifica a due fattori
– Il login deve essere effettuato con email o CIE. Nel caso di email la modalità di default non richiede una password ma un OTP, in quanto dimostrato essere più sicuro. (La password è comunque obbligatoria da settare)
– Una volta registrati, se siamo un operatore (comunale, poliziotto, pompiere o chiunque) sarà un admin o un’altro operatore con abbastanza permessi ad assegnarci a quel ruolo e sbloccare i permessi per accedere ad altre parti della piattaforma.
– Admin/operatori vari devono poter creare le varie zone in base al perimetro, se questo è fattibile tramite una mappa sarebbe l’ideale, assegnare i vari nomi ecc.. Questo serve sia per le segnalazioni e per l’assegnazione dell’agente/squadra che deve intervenire sia per fini statistici
– Al sito non si registrano solo gli agenti che intervengono o gli operatori ma anche i cittadini, che possono effettuare delle segnalazioni. Le segnalazioni devono avere una foto, la posizione con coordinate geografiche e via con numero. Il sistema automaticamente capisce a che zona del comune fa parte
– Quando viene creata una segnalazione c’è un indicatore dello stato, tipo: APERTA, IN REVISIONE, AGENTI IN ARRIVO, RISOLTA (sono esempi, vanno migliorati). Di default viene assegnato APERTA.
– Nella segnalazione c’è una chat che viene fatta tra l’operatore e il cittadino per fare eventuali domande. L’operatore quindi può aggiornare lo stato, scrivere messaggi, segnalare il cittadino perchè magari ha inviato un pene come segnalazione.
– L’agente riceverà un summary della segnalazione + numero di telefono di chi ha segnalato, dato che intervengono sul posto non serve che loro chattano, chiamano direttamente.
– La webapp deve avere una pagina con una serie di statistiche
– Si può implementare anche una specie di gamification (indiretta, non diciamolo in maniera ovvia nel sito) del sistema per renderlo più interattivo, tipo ogni segnalazione valida vale dei punti che se accumulati portano a buoni / sconti.
– L’utente e gli operatori devono poter aggiungere anche dei filtri quando si cercano le varie segnalazioni. Inoltre il cittadino deve anche poter lasciare un feedback sull’intervento che hanno fatto, questo sarà collegato quindi anche all’operatore e agli agenti della squadra che sono intervenuti.
– L’operatore deve poter vedere le varie segnalazioni che sta gestendo e anche quelle di altri operatori ma senza poterci interagire / modificarle.
– Tutti vedono e possono prendere in carico le segnalazioni aperte.
In aggiunta come feature opzionali e secondarie sarebbe carino implementare:
- Notifiche Multicanale: Obbligare il cittadino a rientrare nell'app per controllare lo stato della segnalazione genera frustrazione. Potresti implementare notifiche via Email o l'integrazione con un Bot Telegram/WhatsApp che avvisa automaticamente l'utente quando lo stato passa da "In Lavorazione" a "Risolta".
- Routing Intelligente: Sfruttando le API delle mappe, il sistema potrebbe calcolare per la squadra operativa il percorso ottimale per risolvere 4 o 5 segnalazioni assegnate nella stessa giornata, minimizzando i tempi di spostamento
- Portale Open Data: Le amministrazioni amano promuovere la trasparenza. Potresti creare una pagina pubblica dove chiunque (cittadini, giornalisti, ricercatori) può scaricare un dataset anonimizzato con l'elenco dei disservizi risolti nell'ultimo anno, esportabile in formati standard come JSON o CSV.
Un mio collega ha definito questo schema per il database, possiamo e dobbiamo sicuramente modificarlo ma può essere un buon punto di partenza:
erDiagram
UTENTE ||--o{ SEGNALAZIONE : "manda"
%%citatdino
UTENTE ||--o{ STORICO_STATO : "aggiorna"
%%operatore
UTENTE ||--o{ ASSEGNAZIONE : "assegna "
%%operatore
UTENTE ||--o{ FEEDBACK : "lascia "
%%citatdino
UTENTE ||--o{ MESSAGGIO : "scrive"
CATEGORIA_SEGNALAZIONE ||--o{ SEGNALAZIONE : "classifica"
AREA ||--o{ SEGNALAZIONE : "localizza"
AREA ||--o{ SQUADRA : "ha"
%%che relaziobne diu merda
CATEGORIA_SQUADRA ||--o{ SQUADRA : "ha"
SEGNALAZIONE ||--o{ STORICO_STATO : "traccia"
SEGNALAZIONE ||--o{ ASSEGNAZIONE : "riceve"
SEGNALAZIONE ||--o| FEEDBACK : "valutata"
SEGNALAZIONE ||--o{ FOTO_SEGNALAZIONE : "documentata"
SEGNALAZIONE ||--o{ MESSAGGIO : "discussa"
SQUADRA ||--o{ ASSEGNAZIONE : "incaricata"
UTENTE {
int id PK
string nome
string cognome
string email UK
string password_hash
string codice_fiscale UK
date data_nascita
string luogo_nascita
enum ruolo
string telefono
datetime data_registrazione
int punti
boolean bannato
}
CATEGORIA_SEGNALAZIONE {
int id PK
string nome
text descrizione
}
AREA {
int id PK
string nome_quartiere
text descrizione
geometry coordinate_poligono
%%bho
}
CATEGORIA_SQUADRA {
int id PK
string nome
text descrizione
}
SEGNALAZIONE {
int id PK
int utente_id FK
int categoria_id FK
int area_id FK
decimal latitudine
decimal longitudine
string indirizzo
text descrizione
enum stato
datetime data_creazione
datetime data_ultima_modifica
}
STORICO_STATO {
int id PK
int segnalazione_id FK
datetime data
int operatore_id FK
text note
}
SQUADRA {
int id PK
int categoria_squadra_id FK
int area_id FK
string nome
text descrizione
boolean attiva
}
ASSEGNAZIONE {
int id PK
int segnalazione_id FK
int squadra_id FK
int operatore_assegnante_id FK
datetime data_assegnazione
datetime data_cessazione
}
FEEDBACK {
int id PK
int segnalazione_id FK, UK
int utente_id FK
int voto
%%??
text commento
datetime data
}
FOTO_SEGNALAZIONE {
int id PK
int segnalazione_id FK
string url
datetime data_upload
}
MESSAGGIO {
int id PK
int segnalazione_id FK
int utente_id FK
text testo
datetime data_invio
}
File diff suppressed because it is too large Load Diff
+54 -3
View File
@@ -5,8 +5,24 @@
#include "repositories/MigrationRunner.h"
#include <cstdio>
#include <fstream>
#include <cstdlib>
#include <fstream>
#include <string_view>
namespace
{
bool parseEnvBool(const char *value)
{
if (value == nullptr)
{
return false;
}
const std::string_view normalized(value);
return normalized == "1" || normalized == "true" || normalized == "TRUE" ||
normalized == "yes" || normalized == "on";
}
} // namespace
int main()
{
@@ -19,6 +35,7 @@ int main()
{
configFile >> config;
// DB config from env
if (const char* envHost = std::getenv("DB_HOST"))
config["db_clients"][0]["host"] = envHost;
if (const char* envPort = std::getenv("DB_PORT"))
@@ -32,12 +49,46 @@ int main()
if (const char* envTimeout = std::getenv("DB_TIMEOUT"))
config["db_clients"][0]["timeout"] = std::stod(envTimeout);
// Mail config from env
if (const char* envMailProvider = std::getenv("MAIL_PROVIDER"))
config["custom_config"]["mail"]["provider"] = envMailProvider;
if (const char* envSmtpHost = std::getenv("SMTP_HOST"))
config["custom_config"]["mail"]["smtp_host"] = envSmtpHost;
config["custom_config"]["mail"]["host"] = envSmtpHost;
if (const char* envSmtpPort = std::getenv("SMTP_PORT"))
config["custom_config"]["mail"]["smtp_port"] = std::stoi(envSmtpPort);
config["custom_config"]["mail"]["port"] = std::stoi(envSmtpPort);
if (const char* envSmtpUsername = std::getenv("SMTP_USERNAME"))
config["custom_config"]["mail"]["username"] = envSmtpUsername;
if (const char* envSmtpPassword = std::getenv("SMTP_PASSWORD"))
config["custom_config"]["mail"]["password"] = envSmtpPassword;
else if (const char* envResendApiKey = std::getenv("RESEND_API_KEY"))
config["custom_config"]["mail"]["password"] = envResendApiKey;
if (const char* envSmtpUseTls = std::getenv("SMTP_USE_TLS"))
config["custom_config"]["mail"]["use_tls"] = parseEnvBool(envSmtpUseTls);
if (const char* envSmtpUseStartTls = std::getenv("SMTP_USE_STARTTLS"))
config["custom_config"]["mail"]["use_starttls"] = parseEnvBool(envSmtpUseStartTls);
if (const char* envSmtpVerifyPeer = std::getenv("SMTP_VERIFY_PEER"))
config["custom_config"]["mail"]["verify_peer"] = parseEnvBool(envSmtpVerifyPeer);
if (const char* envSmtpVerifyHost = std::getenv("SMTP_VERIFY_HOST"))
config["custom_config"]["mail"]["verify_host"] = parseEnvBool(envSmtpVerifyHost);
if (const char* envMailFrom = std::getenv("MAIL_FROM"))
config["custom_config"]["mail"]["from"] = envMailFrom;
// CORS config from env
if (const char* envCorsAllowedOrigin = std::getenv("CORS_ALLOWED_ORIGIN"))
config["custom_config"]["cors"]["allowed_origin"] = envCorsAllowedOrigin;
// CIE auth config from env
if (const char* envCieServiceBaseUrl = std::getenv("CIE_AUTH_SERVICE_BASE_URL"))
config["custom_config"]["cie_auth"]["service_base_url"] = envCieServiceBaseUrl;
if (const char* envCieStartPath = std::getenv("CIE_AUTH_START_PATH"))
config["custom_config"]["cie_auth"]["start_path"] = envCieStartPath;
if (const char* envCieSharedSecret = std::getenv("CIE_AUTH_SHARED_SECRET"))
config["custom_config"]["cie_auth"]["shared_secret"] = envCieSharedSecret;
if (const char* envCieStateTtl = std::getenv("CIE_AUTH_STATE_TTL_MINUTES"))
config["custom_config"]["cie_auth"]["state_ttl_minutes"] = std::stoi(envCieStateTtl);
if (const char* envCieFrontendRedirect = std::getenv("CIE_AUTH_DEFAULT_FRONTEND_REDIRECT_URI"))
config["custom_config"]["cie_auth"]["default_frontend_redirect_uri"] = envCieFrontendRedirect;
drogon::app().loadConfigJson(config);
}
else
+9 -16
View File
@@ -3,14 +3,12 @@ CREATE TABLE IF NOT EXISTS users (
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
fiscal_code VARCHAR(16) NOT NULL,
birth_date DATE NULL,
birth_place VARCHAR(150) NULL,
phone VARCHAR(30) NULL,
points INT NOT NULL DEFAULT 0,
banned BOOLEAN NOT NULL DEFAULT FALSE,
must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY users_email_unique (email),
@@ -82,7 +80,6 @@ CREATE TABLE IF NOT EXISTS areas (
name VARCHAR(120) NOT NULL,
description TEXT NULL,
boundary_geojson JSON NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE KEY areas_name_unique (name)
);
@@ -102,7 +99,7 @@ CREATE TABLE IF NOT EXISTS teams (
active BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE KEY teams_name_unique (name),
CONSTRAINT teams_category_fk FOREIGN KEY (team_category_id) REFERENCES team_categories(id),
CONSTRAINT teams_area_fk FOREIGN KEY (area_id) REFERENCES areas(id)
CONSTRAINT teams_area_fk FOREIGN KEY (area_id) REFERENCES areas(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS reports (
@@ -119,7 +116,7 @@ CREATE TABLE IF NOT EXISTS reports (
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT reports_user_fk FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT reports_category_fk FOREIGN KEY (category_id) REFERENCES report_categories(id),
CONSTRAINT reports_area_fk FOREIGN KEY (area_id) REFERENCES areas(id),
CONSTRAINT reports_area_fk FOREIGN KEY (area_id) REFERENCES areas(id) ON DELETE SET NULL,
KEY reports_status_idx (status),
KEY reports_category_idx (category_id),
KEY reports_area_idx (area_id)
@@ -224,20 +221,16 @@ INSERT IGNORE INTO users (
first_name,
last_name,
email,
password_hash,
fiscal_code,
phone,
must_change_password
phone
) VALUES (
'Gennario',
'Vessio',
'admin@visione.gov.it',
'pbkdf2_sha256$210000$a2c43ef09bd24ea883023e5f57fa6255d8dd317e312a40ddaa1bd3ccd83dbfa5$26D0C9000B603A424C7FB0DA3BE0CDC1798A9CBA34F391F9B8E4C0E1E4A58806',
'VSSGNR94M08A662P',
'380 6180216',
TRUE
'Vito',
'Leccese',
'admin@visionebari.it',
'LCCVTI62M02A662Q',
'380 6180216'
);
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.gov.it' AND r.name = 'admin';
WHERE u.email = 'admin@visionebari.it' AND r.name = 'admin';
+6 -8
View File
@@ -1,9 +1,7 @@
UPDATE users
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 IN ('[email protected]', '[email protected]');
SET email = 'admin@visionebari.it',
first_name = 'Vito',
last_name = 'Leccese',
fiscal_code = 'LCCVTI62M02A662Q',
phone = '380 6180216'
WHERE email = '[email protected]';
+6 -8
View File
@@ -1,9 +1,7 @@
UPDATE users
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 IN ('[email protected]', '[email protected]');
SET email = 'admin@visionebari.it',
first_name = 'Vito',
last_name = 'Leccese',
fiscal_code = 'LCCVTI62M02A662Q',
phone = '380 6180216'
WHERE email = '[email protected]';
@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS report_category_team_categories (
report_category_id BIGINT UNSIGNED NOT NULL,
team_category_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (report_category_id, team_category_id),
CONSTRAINT report_category_team_categories_report_fk
FOREIGN KEY (report_category_id) REFERENCES report_categories(id),
CONSTRAINT report_category_team_categories_team_fk
FOREIGN KEY (team_category_id) REFERENCES team_categories(id)
);
CREATE TABLE IF NOT EXISTS intervention_access_links (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
report_id BIGINT UNSIGNED NOT NULL,
assignment_id BIGINT UNSIGNED NOT NULL,
token_hash VARCHAR(255) NOT NULL,
created_by_user_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
revoked_at DATETIME NULL,
last_accessed_at DATETIME NULL,
UNIQUE KEY intervention_access_links_token_hash_unique (token_hash),
KEY intervention_access_links_report_assignment_idx (report_id, assignment_id),
CONSTRAINT intervention_access_links_report_fk
FOREIGN KEY (report_id) REFERENCES reports(id),
CONSTRAINT intervention_access_links_assignment_fk
FOREIGN KEY (assignment_id) REFERENCES assignments(id),
CONSTRAINT intervention_access_links_user_fk
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
);
INSERT IGNORE INTO report_category_team_categories (report_category_id, team_category_id)
SELECT rc.id, tc.id
FROM report_categories rc
JOIN team_categories tc ON
(rc.name = 'Buche stradali' AND tc.name = 'Manutenzione strade') OR
(rc.name = 'Illuminazione guasta' AND tc.name = 'Illuminazione pubblica') OR
(rc.name = 'Rifiuti abbandonati' AND tc.name = 'Igiene urbana');
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
CREATE TABLE IF NOT EXISTS cie_auth_sessions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
state VARCHAR(128) NOT NULL,
frontend_redirect_uri VARCHAR(500) NOT NULL,
status ENUM('PENDING', 'AUTHENTICATED', 'FAILED') NOT NULL DEFAULT 'PENDING',
issuer VARCHAR(255) NULL,
subject VARCHAR(255) NULL,
fiscal_code VARCHAR(16) NULL,
authenticated_user_id BIGINT UNSIGNED NULL,
result_token VARCHAR(255) NULL,
raw_claims JSON NULL,
expires_at DATETIME NOT NULL,
consumed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY cie_auth_sessions_state_unique (state),
KEY cie_auth_sessions_status_idx (status),
KEY cie_auth_sessions_expires_idx (expires_at),
CONSTRAINT cie_auth_sessions_user_fk FOREIGN KEY (authenticated_user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS user_external_identities (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
provider VARCHAR(50) NOT NULL,
issuer VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
fiscal_code VARCHAR(16) NOT NULL,
raw_claims JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY user_external_identities_provider_subject_unique (provider, issuer, subject),
KEY user_external_identities_user_idx (user_id),
KEY user_external_identities_fiscal_code_idx (fiscal_code),
CONSTRAINT user_external_identities_user_fk FOREIGN KEY (user_id) REFERENCES users(id)
);
-2
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env python3
"""Generate OpenAPI JSON from Drogon routes plus a manual OpenAPI YAML file."""
from __future__ import annotations
import argparse
+295 -40
View File
@@ -1,5 +1,6 @@
#include "services/AdminService.h"
#include "common/GeoJson.h"
#include "common/Validation.h"
#include "repositories/Sql.h"
#include "services/UserService.h"
@@ -7,6 +8,7 @@
#include <drogon/drogon.h>
#include <algorithm>
#include <sstream>
namespace visione::services
{
@@ -78,28 +80,56 @@ const JsonRowSchema &adminUserSchema()
static const JsonRowSchema schema{
{"id", JsonFieldType::UInt64},
{"points", JsonFieldType::Int64},
{"banned", JsonFieldType::Bool},
{"must_change_password", JsonFieldType::Bool}};
{"banned", JsonFieldType::Bool}};
return schema;
}
} // namespace
Json::Value AdminService::listCategories()
bool isActiveField(const Json::Value &body)
{
auto response = success();
response["categories"] = repositories::resultToJson(
drogon::app().getDbClient()->execSqlSync(
"SELECT * FROM report_categories ORDER BY name"));
return response;
return body.isMember("active") && body["active"].isBool();
}
std::optional<Json::Value> loadAllowedBoundary(const std::string &code)
{
const auto rows = drogon::app().getDbClient()->execSqlSync(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(boundary_geojson, '$')) AS geojson "
"FROM allowed_boundaries WHERE code = ? AND active = TRUE LIMIT 1",
code);
if (rows.empty())
{
return std::nullopt;
}
Json::CharReaderBuilder builder;
Json::Value geojson;
std::string errors;
std::istringstream stream(rows[0]["geojson"].as<std::string>());
if (!Json::parseFromStream(builder, stream, &geojson, &errors))
{
return std::nullopt;
}
return geojson;
}
bool isAreaInsideAllowedBoundary(const Json::Value &boundaryGeojson)
{
const auto allowed = loadAllowedBoundary("BARI");
return allowed &&
geojson::polygonInsideGeometry(boundaryGeojson, *allowed);
}
} // namespace
Json::Value AdminService::createCategory(const auth::UserContext &user,
const Json::Value &body)
{
if (!auth::isAdmin(user) || !validation::hasString(body, "name"))
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
if (!validation::hasString(body, "name"))
{
return failure("invalid_category");
}
drogon::app().getDbClient()->execSqlSync(
"INSERT INTO report_categories (name, description) VALUES (?, ?)",
@@ -108,22 +138,21 @@ Json::Value AdminService::createCategory(const auth::UserContext &user,
return success();
}
Json::Value AdminService::listAreas()
{
auto response = success();
response["areas"] = repositories::resultToJson(
drogon::app().getDbClient()->execSqlSync("SELECT * FROM areas ORDER BY name"));
return response;
}
Json::Value AdminService::createArea(const auth::UserContext &user,
const Json::Value &body)
{
if (!auth::isAdmin(user) || !validation::hasString(body, "name") ||
!body["boundary_geojson"].isObject())
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
if (!validation::hasString(body, "name") || !body["boundary_geojson"].isObject())
{
return failure("invalid_area");
}
if (!isAreaInsideAllowedBoundary(body["boundary_geojson"]))
{
return failure("area_outside_allowed_boundary");
}
Json::StreamWriterBuilder writer;
drogon::app().getDbClient()->execSqlSync(
@@ -135,29 +164,38 @@ Json::Value AdminService::createArea(const auth::UserContext &user,
return success();
}
Json::Value AdminService::listTeams()
{
auto response = success();
response["teams"] = repositories::resultToJson(
drogon::app().getDbClient()->execSqlSync(
"SELECT t.*, tc.name AS category_name, a.name AS area_name "
"FROM teams t JOIN team_categories tc ON tc.id = t.team_category_id "
"LEFT JOIN areas a ON a.id = t.area_id ORDER BY t.name"));
return response;
}
Json::Value AdminService::createTeam(const auth::UserContext &user,
const Json::Value &body)
{
if (!auth::isAdmin(user) || !validation::hasString(body, "name") ||
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
if (!validation::hasString(body, "name") ||
!validation::hasNumber(body, "team_category_id"))
{
return failure("invalid_team");
}
auto db = drogon::app().getDbClient();
const auto teamCategoryRows = db->execSqlSync(
"SELECT id FROM team_categories WHERE id = ?",
body["team_category_id"].asInt64());
if (teamCategoryRows.empty())
{
return failure("invalid_reference");
}
if (body["area_id"].isNumeric())
{
drogon::app().getDbClient()->execSqlSync(
const auto areaRows = db->execSqlSync(
"SELECT id FROM areas WHERE id = ?",
body["area_id"].asInt64());
if (areaRows.empty())
{
return failure("invalid_reference");
}
db->execSqlSync(
"INSERT INTO teams (team_category_id, area_id, name, description) "
"VALUES (?, ?, ?, ?)",
body["team_category_id"].asInt64(),
@@ -167,7 +205,7 @@ Json::Value AdminService::createTeam(const auth::UserContext &user,
}
else
{
drogon::app().getDbClient()->execSqlSync(
db->execSqlSync(
"INSERT INTO teams (team_category_id, name, description) VALUES (?, ?, ?)",
body["team_category_id"].asInt64(),
body["name"].asString(),
@@ -198,10 +236,21 @@ Json::Value AdminService::overviewStats(const auth::UserContext &user)
"SELECT c.name AS category, COUNT(*) AS total "
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
"GROUP BY c.name ORDER BY total DESC"));
response["reports_by_team"] = repositories::resultToJson(db->execSqlSync(
"SELECT t.name AS team, COUNT(*) AS total "
response["assignments_by_team"] = repositories::resultToJson(db->execSqlSync(
"SELECT t.name AS team, COUNT(DISTINCT ass.id) AS total "
"FROM assignments ass JOIN teams t ON t.id = ass.team_id "
"GROUP BY t.name ORDER BY total DESC"));
response["resolved_reports_by_team"] = repositories::resultToJson(db->execSqlSync(
"SELECT t.name AS team, COUNT(DISTINCT ass.report_id) AS total "
"FROM assignments ass "
"JOIN teams t ON t.id = ass.team_id "
"JOIN reports r ON r.id = ass.report_id "
"WHERE r.status IN ('RESOLVED', 'CLOSED') "
"AND NOT EXISTS ("
" SELECT 1 FROM assignments ass2 "
" WHERE ass2.report_id = ass.report_id AND ass2.assigned_at > ass.assigned_at"
") "
"GROUP BY t.name ORDER BY total DESC"));
response["monthly_reports"] = repositories::resultToJson(db->execSqlSync(
"SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, COUNT(*) AS total "
"FROM reports GROUP BY DATE_FORMAT(created_at, '%Y-%m') "
@@ -229,7 +278,7 @@ Json::Value AdminService::listModeration(
const std::optional<std::string> &page,
const std::optional<std::string> &perPage)
{
if (!auth::canManageReports(user))
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
@@ -262,7 +311,7 @@ Json::Value AdminService::updateModeration(const auth::UserContext &user,
long long flagId,
const Json::Value &body)
{
if (!auth::canManageReports(user))
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
@@ -324,6 +373,212 @@ Json::Value AdminService::updateModeration(const auth::UserContext &user,
return success();
}
Json::Value AdminService::deleteCategory(const auth::UserContext &user,
long long categoryId)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
const auto rows = db->execSqlSync(
"SELECT id FROM report_categories WHERE id = ?",
categoryId);
if (rows.empty())
{
return failure("category_not_found");
}
db->execSqlSync("UPDATE report_categories SET active = FALSE WHERE id = ?",
categoryId);
return success();
}
Json::Value AdminService::updateCategory(const auth::UserContext &user,
long long categoryId,
const Json::Value &body)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
const auto rows = db->execSqlSync(
"SELECT id FROM report_categories WHERE id = ?",
categoryId);
if (rows.empty())
{
return failure("category_not_found");
}
if (validation::hasString(body, "name"))
{
db->execSqlSync("UPDATE report_categories SET name = ? WHERE id = ?",
body["name"].asString(),
categoryId);
}
if (body.isMember("description") && body["description"].isString())
{
db->execSqlSync("UPDATE report_categories SET description = ? WHERE id = ?",
body["description"].asString(),
categoryId);
}
if (isActiveField(body))
{
db->execSqlSync("UPDATE report_categories SET active = ? WHERE id = ?",
body["active"].asBool(),
categoryId);
}
return success();
}
Json::Value AdminService::deleteArea(const auth::UserContext &user,
long long areaId)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
const auto rows = db->execSqlSync("SELECT id FROM areas WHERE id = ?", areaId);
if (rows.empty())
{
return failure("area_not_found");
}
db->execSqlSync("UPDATE teams SET area_id = NULL WHERE area_id = ?", areaId);
db->execSqlSync("UPDATE reports SET area_id = NULL WHERE area_id = ?", areaId);
db->execSqlSync("DELETE FROM areas WHERE id = ?", areaId);
return success();
}
Json::Value AdminService::updateArea(const auth::UserContext &user,
long long areaId,
const Json::Value &body)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
const auto rows = db->execSqlSync("SELECT id FROM areas WHERE id = ?", areaId);
if (rows.empty())
{
return failure("area_not_found");
}
if (validation::hasString(body, "name"))
{
db->execSqlSync("UPDATE areas SET name = ? WHERE id = ?",
body["name"].asString(),
areaId);
}
if (body.isMember("description") && body["description"].isString())
{
db->execSqlSync("UPDATE areas SET description = ? WHERE id = ?",
body["description"].asString(),
areaId);
}
if (body["boundary_geojson"].isObject())
{
if (!isAreaInsideAllowedBoundary(body["boundary_geojson"]))
{
return failure("area_outside_allowed_boundary");
}
Json::StreamWriterBuilder writer;
db->execSqlSync(
"UPDATE areas SET boundary_geojson = CAST(? AS JSON) WHERE id = ?",
Json::writeString(writer, body["boundary_geojson"]),
areaId);
}
return success();
}
Json::Value AdminService::deleteTeam(const auth::UserContext &user,
long long teamId)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
const auto rows = db->execSqlSync("SELECT id FROM teams WHERE id = ?", teamId);
if (rows.empty())
{
return failure("team_not_found");
}
db->execSqlSync("UPDATE teams SET active = FALSE WHERE id = ?", teamId);
return success();
}
Json::Value AdminService::updateTeam(const auth::UserContext &user,
long long teamId,
const Json::Value &body)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
const auto rows = db->execSqlSync("SELECT id FROM teams WHERE id = ?", teamId);
if (rows.empty())
{
return failure("team_not_found");
}
if (validation::hasString(body, "name"))
{
db->execSqlSync("UPDATE teams SET name = ? WHERE id = ?",
body["name"].asString(),
teamId);
}
if (body.isMember("description") && body["description"].isString())
{
db->execSqlSync("UPDATE teams SET description = ? WHERE id = ?",
body["description"].asString(),
teamId);
}
if (validation::hasNumber(body, "team_category_id"))
{
const auto categoryRows = db->execSqlSync(
"SELECT id FROM team_categories WHERE id = ?",
body["team_category_id"].asInt64());
if (categoryRows.empty())
{
return failure("invalid_reference");
}
db->execSqlSync("UPDATE teams SET team_category_id = ? WHERE id = ?",
body["team_category_id"].asInt64(),
teamId);
}
if (body.isMember("area_id"))
{
if (body["area_id"].isNull())
{
db->execSqlSync("UPDATE teams SET area_id = NULL WHERE id = ?", teamId);
}
else if (body["area_id"].isNumeric())
{
const auto areaRows =
db->execSqlSync("SELECT id FROM areas WHERE id = ?",
body["area_id"].asInt64());
if (areaRows.empty())
{
return failure("invalid_reference");
}
db->execSqlSync("UPDATE teams SET area_id = ? WHERE id = ?",
body["area_id"].asInt64(),
teamId);
}
else
{
return failure("invalid_reference");
}
}
if (isActiveField(body))
{
db->execSqlSync("UPDATE teams SET active = ? WHERE id = ?",
body["active"].asBool(),
teamId);
}
return success();
}
Json::Value AdminService::listUsers(const auth::UserContext &user)
{
if (!auth::isAdmin(user))
@@ -335,13 +590,13 @@ Json::Value AdminService::listUsers(const auth::UserContext &user)
response["users"] = repositories::resultToJson(
drogon::app().getDbClient()->execSqlSync(
"SELECT 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, "
"GROUP_CONCAT(r.name ORDER BY r.name SEPARATOR ',') AS roles "
"FROM users u "
"LEFT JOIN user_roles ur ON ur.user_id = u.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, "
"u.phone, u.points, u.banned, u.must_change_password "
"u.phone, u.points, u.banned "
"ORDER BY u.created_at DESC LIMIT 200"),
adminUserSchema());
return response;
+12 -3
View File
@@ -12,11 +12,8 @@ namespace visione::services
class AdminService
{
public:
Json::Value listCategories();
Json::Value createCategory(const auth::UserContext &user, const Json::Value &body);
Json::Value listAreas();
Json::Value createArea(const auth::UserContext &user, const Json::Value &body);
Json::Value listTeams();
Json::Value createTeam(const auth::UserContext &user, const Json::Value &body);
Json::Value overviewStats(const auth::UserContext &user);
Json::Value listUsers(const auth::UserContext &user);
@@ -27,6 +24,18 @@ class AdminService
Json::Value updateModeration(const auth::UserContext &user,
long long flagId,
const Json::Value &body);
Json::Value deleteCategory(const auth::UserContext &user, long long categoryId);
Json::Value deleteArea(const auth::UserContext &user, long long areaId);
Json::Value deleteTeam(const auth::UserContext &user, long long teamId);
Json::Value updateCategory(const auth::UserContext &user,
long long categoryId,
const Json::Value &body);
Json::Value updateArea(const auth::UserContext &user,
long long areaId,
const Json::Value &body);
Json::Value updateTeam(const auth::UserContext &user,
long long teamId,
const Json::Value &body);
Json::Value updateUserRoles(const auth::UserContext &user,
long long targetUserId,
const Json::Value &body);
+360 -85
View File
@@ -1,20 +1,33 @@
#include "services/AuthService.h"
#include "common/Crypto.h"
#include "common/Email.h"
#include "common/Crypto.h"
#include "common/Validation.h"
#include <drogon/drogon.h>
#include <algorithm>
#include <cctype>
#include <json/writer.h>
#include <optional>
#include <string_view>
#include <vector>
namespace visione::services
{
namespace
{
Json::Value failure(const std::string &code)
constexpr std::string_view kCieProvider = "cie";
Json::Value failure(const std::string &code, const std::string &message = {})
{
Json::Value body;
body["ok"] = false;
body["error"] = code;
if (!message.empty())
{
body["message"] = message;
}
return body;
}
@@ -41,6 +54,74 @@ int tokenTtlHours()
.asInt();
}
const Json::Value &cieConfig()
{
return drogon::app().getCustomConfig()["cie_auth"];
}
std::string cieAuthBaseUrl()
{
return cieConfig().get("service_base_url", "").asString();
}
std::string cieStartPath()
{
return cieConfig().get("start_path", "/visione/start").asString();
}
std::string cieSharedSecret()
{
return cieConfig().get("shared_secret", "").asString();
}
int cieStateTtlMinutes()
{
return cieConfig().get("state_ttl_minutes", 10).asInt();
}
std::string cieDefaultFrontendRedirect()
{
return cieConfig().get("default_frontend_redirect_uri", "").asString();
}
std::string ensureCitizenEmail(const std::string &email,
const std::string &fiscalCode)
{
if (!email.empty())
{
return email;
}
return fiscalCode + "@cie.local";
}
std::string buildUrlWithQuery(const std::string &base,
const std::vector<std::pair<std::string, std::string>> &params)
{
std::string url = base;
url += (base.find('?') == std::string::npos) ? "?" : "&";
bool first = true;
for (const auto &[key, value] : params)
{
if (!first)
{
url += "&";
}
first = false;
url += drogon::utils::urlEncode(key) + "=" + drogon::utils::urlEncode(value);
}
return url;
}
std::string frontendRedirectWithState(const std::string &baseUrl,
const std::string &state)
{
if (baseUrl.empty())
{
return {};
}
return buildUrlWithQuery(baseUrl, {{"state", state}});
}
Json::Value issueToken(long long userId)
{
auto token = crypto::randomToken();
@@ -57,12 +138,113 @@ Json::Value issueToken(long long userId)
body["expires_in_hours"] = tokenTtlHours();
return body;
}
std::optional<std::string> readString(const Json::Value &body, const char *key)
{
if (!body.isMember(key) || !body[key].isString())
{
return std::nullopt;
}
return body[key].asString();
}
std::string normalizeFiscalCode(std::string fiscalCode)
{
std::ranges::transform(fiscalCode, fiscalCode.begin(), [](unsigned char ch) {
return static_cast<char>(std::toupper(ch));
});
return fiscalCode;
}
long long findOrCreateCieCitizen(const Json::Value &claims)
{
auto db = drogon::app().getDbClient();
const auto issuer = claims["issuer"].asString();
const auto subject = claims["subject"].asString();
const auto fiscalCode = normalizeFiscalCode(claims["fiscal_code"].asString());
const auto firstName = claims.get("first_name", "").asString();
const auto lastName = claims.get("last_name", "").asString();
const auto email = claims.get("email", "").asString();
const auto existingIdentity = db->execSqlSync(
"SELECT user_id FROM user_external_identities "
"WHERE provider = ? AND issuer = ? AND subject = ? LIMIT 1",
std::string(kCieProvider),
issuer,
subject);
if (!existingIdentity.empty())
{
return existingIdentity[0]["user_id"].as<long long>();
}
long long userId = 0;
const auto byFiscal = db->execSqlSync(
"SELECT id FROM users WHERE fiscal_code = ? LIMIT 1", fiscalCode);
if (!byFiscal.empty())
{
userId = byFiscal[0]["id"].as<long long>();
db->execSqlSync(
"UPDATE users SET first_name = COALESCE(NULLIF(?, ''), first_name), "
"last_name = COALESCE(NULLIF(?, ''), last_name), "
"email = COALESCE(NULLIF(?, ''), email) WHERE id = ?",
firstName,
lastName,
email,
userId);
}
else
{
db->execSqlSync(
"INSERT INTO users "
"(first_name, last_name, email, fiscal_code, birth_date, birth_place, phone) "
"VALUES (?, ?, ?, ?, NULL, NULL, NULL)",
firstName.empty() ? fiscalCode : firstName,
lastName.empty() ? fiscalCode : lastName,
ensureCitizenEmail(email, fiscalCode),
fiscalCode);
const auto inserted = db->execSqlSync("SELECT LAST_INSERT_ID() AS id");
userId = inserted[0]["id"].as<long long>();
db->execSqlSync(
"INSERT INTO user_roles (user_id, role_id) "
"SELECT ?, id FROM roles WHERE name = 'citizen'",
userId);
}
Json::StreamWriterBuilder writer;
writer["indentation"] = "";
db->execSqlSync(
"INSERT INTO user_external_identities "
"(user_id, provider, issuer, subject, fiscal_code, raw_claims) "
"VALUES (?, ?, ?, ?, ?, CAST(? AS JSON)) "
"ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), "
"fiscal_code = VALUES(fiscal_code), raw_claims = VALUES(raw_claims)",
userId,
std::string(kCieProvider),
issuer,
subject,
fiscalCode,
Json::writeString(writer, claims));
return userId;
}
bool validateCieExchangeSignature(const std::string &rawBody,
const std::string &signatureHeader)
{
const auto secret = cieSharedSecret();
if (secret.empty() || signatureHeader.empty())
{
return false;
}
const auto expected = crypto::hmacSha256Hex(secret, rawBody);
return crypto::constantTimeEquals(expected, signatureHeader);
}
} // namespace
Json::Value AuthService::registerCitizen(const Json::Value &body)
{
const auto missing = validation::missingStrings(
body, {"first_name", "last_name", "email", "password", "fiscal_code"});
body, {"first_name", "last_name", "email", "fiscal_code"});
if (!missing.empty())
{
auto response = failure("missing_fields");
@@ -96,12 +278,11 @@ Json::Value AuthService::registerCitizen(const Json::Value &body)
db->execSqlSync(
"INSERT INTO users "
"(first_name, last_name, email, password_hash, fiscal_code, birth_date, "
"birth_place, phone) VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?)",
"(first_name, last_name, email, fiscal_code, birth_date, "
"birth_place, phone) VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?)",
body["first_name"].asString(),
body["last_name"].asString(),
email,
crypto::hashPassword(body["password"].asString()),
fiscalCode,
body.get("birth_date", "").asString(),
body.get("birth_place", "").asString(),
@@ -147,12 +328,12 @@ Json::Value AuthService::requestOtp(const Json::Value &body)
email::EmailService mail;
if (!mail.sendOtp(user[0]["email"].as<std::string>(), code))
{
LOG_WARN << "Could not send OTP email through Mailpit";
LOG_WARN << "Could not send OTP email through " << mail.deliveryName();
return failure("otp_email_failed");
}
auto response = success();
response["delivery"] = "mailpit";
response["delivery"] = mail.deliveryName();
response["expires_in_minutes"] = otpTtlMinutes();
return response;
}
@@ -183,108 +364,202 @@ Json::Value AuthService::verifyOtp(const Json::Value &body)
return issueToken(challenge[0]["user_id"].as<long long>());
}
Json::Value AuthService::loginWithPassword(const Json::Value &body)
Json::Value AuthService::startCieLogin(const Json::Value &body)
{
const auto missing = validation::missingStrings(body, {"email", "password"});
if (!missing.empty())
const auto frontendRedirect =
body.get("frontend_redirect_uri", cieDefaultFrontendRedirect()).asString();
if (frontendRedirect.empty())
{
return failure("missing_fields");
return failure("missing_frontend_redirect_uri");
}
const auto serviceBaseUrl = cieAuthBaseUrl();
if (serviceBaseUrl.empty())
{
return failure("cie_auth_not_configured");
}
const auto state = crypto::randomToken();
auto db = drogon::app().getDbClient();
const auto user = db->execSqlSync(
"SELECT id, password_hash FROM users WHERE email = ? AND banned = FALSE "
"LIMIT 1",
body["email"].asString());
if (user.empty() ||
!crypto::verifyPassword(body["password"].asString(),
user[0]["password_hash"].as<std::string>()))
{
return failure("invalid_credentials");
}
return issueToken(user[0]["id"].as<long long>());
}
Json::Value AuthService::changePassword(const auth::UserContext &user,
const Json::Value &body)
{
const auto missing =
validation::missingStrings(body, {"current_password", "new_password"});
if (!missing.empty())
{
return failure("missing_fields");
}
if (body["new_password"].asString().size() < 10)
{
return failure("weak_password");
}
auto db = drogon::app().getDbClient();
const auto current = db->execSqlSync(
"SELECT password_hash FROM users WHERE id = ? AND banned = FALSE",
user.id);
if (current.empty() ||
!crypto::verifyPassword(
body["current_password"].asString(),
current[0]["password_hash"].as<std::string>()))
{
return failure("invalid_credentials");
}
db->execSqlSync(
"UPDATE users SET password_hash = ?, must_change_password = FALSE "
"WHERE id = ?",
crypto::hashPassword(body["new_password"].asString()),
user.id);
return success();
"INSERT INTO cie_auth_sessions "
"(state, frontend_redirect_uri, status, expires_at) "
"VALUES (?, ?, 'PENDING', DATE_ADD(NOW(), INTERVAL ? MINUTE))",
state,
frontendRedirect,
cieStateTtlMinutes());
auto response = success();
response["state"] = state;
response["authorization_url"] = buildUrlWithQuery(
serviceBaseUrl + cieStartPath(),
{{"state", state}, {"frontend_redirect_uri", frontendRedirect}});
response["status"] = "pending";
return response;
}
Json::Value AuthService::editUser(const auth::UserContext &user, Json::Value::const_iterator::reference body) {
Json::Value AuthService::completeCieLogin(const std::string &rawBody,
const Json::Value &body,
const std::string &signatureHeader)
{
if (!validateCieExchangeSignature(rawBody, signatureHeader))
{
return failure("invalid_signature");
}
const auto state = readString(body, "state");
const auto issuer = readString(body, "issuer");
const auto subject = readString(body, "subject");
const auto fiscalCode = readString(body, "fiscal_code");
if (!state || !issuer || !subject || !fiscalCode)
{
return failure("missing_fields");
}
if (!validation::isValidFiscalCode(*fiscalCode))
{
return failure("invalid_fiscal_code");
}
auto db = drogon::app().getDbClient();
const auto session = db->execSqlSync(
"SELECT id, frontend_redirect_uri, status FROM cie_auth_sessions "
"WHERE state = ? AND consumed_at IS NULL AND expires_at > NOW() LIMIT 1",
*state);
if (session.empty())
{
return failure("invalid_or_expired_state");
}
const auto sessionId = session[0]["id"].as<long long>();
const auto status = session[0]["status"].as<std::string>();
if (status == "AUTHENTICATED")
{
auto response = success();
response["redirect_url"] = frontendRedirectWithState(
session[0]["frontend_redirect_uri"].as<std::string>(), *state);
return response;
}
Json::Value claims(Json::objectValue);
claims["issuer"] = *issuer;
claims["subject"] = *subject;
claims["fiscal_code"] = normalizeFiscalCode(*fiscalCode);
claims["first_name"] = body.get("first_name", "").asString();
claims["last_name"] = body.get("last_name", "").asString();
claims["email"] = body.get("email", "").asString();
const auto userId = findOrCreateCieCitizen(claims);
const auto token = issueToken(userId);
Json::StreamWriterBuilder writer;
writer["indentation"] = "";
db->execSqlSync(
"UPDATE cie_auth_sessions "
"SET status = 'AUTHENTICATED', issuer = ?, subject = ?, fiscal_code = ?, "
"authenticated_user_id = ?, result_token = ?, raw_claims = CAST(? AS JSON), "
"consumed_at = NOW() WHERE id = ?",
*issuer,
*subject,
normalizeFiscalCode(*fiscalCode),
userId,
token["token"].asString(),
Json::writeString(writer, claims),
sessionId);
auto response = success();
response["redirect_url"] = frontendRedirectWithState(
session[0]["frontend_redirect_uri"].as<std::string>(), *state);
return response;
}
Json::Value AuthService::cieSessionStatus(const std::string &state)
{
auto db = drogon::app().getDbClient();
const auto session = db->execSqlSync(
"SELECT status, authenticated_user_id, result_token FROM cie_auth_sessions "
"WHERE state = ? LIMIT 1",
state);
if (session.empty())
{
return failure("session_not_found");
}
auto response = success();
const auto status = session[0]["status"].as<std::string>();
if (status == "AUTHENTICATED")
{
response["status"] = "authenticated";
response["auth"]["token"] = session[0]["result_token"].as<std::string>();
response["auth"]["token_type"] = "Bearer";
response["auth"]["expires_in_hours"] = tokenTtlHours();
response["user_id"] =
Json::Int64(session[0]["authenticated_user_id"].as<long long>());
return response;
}
response["status"] = status == "FAILED" ? "failed" : "pending";
return response;
}
Json::Value AuthService::currentUser(const auth::UserContext &user)
{
auto db = drogon::app().getDbClient();
const auto result = db->execSqlSync(
"SELECT id, first_name, last_name, email, fiscal_code, phone, points "
"FROM users WHERE id = ?",
user.id);
auto response = success();
if (!result.empty())
{
response["user"]["id"] = Json::Int64(result[0]["id"].as<long long>());
response["user"]["first_name"] = result[0]["first_name"].as<std::string>();
response["user"]["last_name"] = result[0]["last_name"].as<std::string>();
response["user"]["email"] = result[0]["email"].as<std::string>();
response["user"]["fiscal_code"] = result[0]["fiscal_code"].as<std::string>();
response["user"]["phone"] = result[0]["phone"].as<std::string>();
response["user"]["points"] = result[0]["points"].as<int>();
response["user"]["role"] = user.role;
for (const auto &role : user.roles)
{
response["user"]["roles"].append(role);
}
for (const auto &permission : user.permissions)
{
response["user"]["permissions"].append(permission);
}
}
return response;
}
Json::Value AuthService::editUser(const auth::UserContext &user,
Json::Value::const_iterator::reference body)
{
const auto allowedFields = {"first_name", "last_name", "phone"};
auto db = drogon::app().getDbClient();
bool hasUpdates = false;
for (const auto& field : allowedFields) {
if (body.isMember(field)) {
if (!body[field].isString()) {
for (const auto &field : allowedFields)
{
if (body.isMember(field))
{
if (!body[field].isString())
{
return failure("invalid_field_type");
}
db->execSqlSync(
"UPDATE users SET " + std::string(field) + " = ? WHERE id = ?",
body[field].asString(),
user.id
);
user.id);
hasUpdates = true;
}
}
if (!hasUpdates) {
return failure("missing_fields");
}
return success();
}
Json::Value AuthService::editUser(const auth::UserContext &user, Json::Value::const_iterator::reference body) {
const auto allowedFields = {"first_name", "last_name", "phone"};
if (!body.isMember("field") || !body.isMember("value") ||
!body["field"].isString() || !body["value"].isString())
if (!hasUpdates)
{
return failure("missing_fields");
}
const auto field = body["field"].asString();
if (std::find(allowedFields.begin(), allowedFields.end(), field) ==
allowedFields.end())
{
return failure("invalid_field");
}
auto db = drogon::app().getDbClient();
db->execSqlSync(
"UPDATE users SET " + field + " = ? WHERE id = ?",
body["value"].asString(),
user.id);
return success();
}
+5 -2
View File
@@ -12,8 +12,11 @@ class AuthService
Json::Value registerCitizen(const Json::Value &body);
Json::Value requestOtp(const Json::Value &body);
Json::Value verifyOtp(const Json::Value &body);
Json::Value loginWithPassword(const Json::Value &body);
Json::Value changePassword(const auth::UserContext &user, const Json::Value &body);
Json::Value startCieLogin(const Json::Value &body);
Json::Value completeCieLogin(const std::string &rawBody,
const Json::Value &body,
const std::string &signatureHeader);
Json::Value cieSessionStatus(const std::string &state);
Json::Value currentUser(const auth::UserContext &user);
Json::Value editUser(const auth::UserContext & user, Json::Value::const_iterator::reference body);
};
+375 -15
View File
@@ -1,16 +1,19 @@
#include "services/ReportService.h"
#include "common/Crypto.h"
#include "common/GeoJson.h"
#include "common/Validation.h"
#include "repositories/Sql.h"
#include "services/UserService.h"
#include <drogon/drogon.h>
#include <drogon/orm/Exception.h>
#include <map>
#include <set>
#include <sstream>
#include <algorithm>
#include <cctype>
namespace visione::services
{
@@ -34,6 +37,14 @@ Json::Value success()
return body;
}
Json::Value successWithLink(const std::string &url, const std::string &expiresAt)
{
auto body = success();
body["url"] = url;
body["expires_at"] = expiresAt;
return body;
}
bool isAllowedStatus(const std::string &status)
{
static const std::set<std::string> statuses = {
@@ -56,11 +67,48 @@ bool canTransition(const std::string &from, const std::string &to)
return it != transitions.end() && it->second.contains(to);
}
std::string maskedLastName(const std::string &lastName)
{
if (lastName.empty())
{
return {};
}
return std::string(1, static_cast<char>(std::toupper(lastName.front()))) + ".";
}
std::optional<Json::Value> loadAllowedBoundary(const std::string &code)
{
const auto rows = drogon::app().getDbClient()->execSqlSync(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(boundary_geojson, '$')) AS geojson "
"FROM allowed_boundaries WHERE code = ? AND active = TRUE LIMIT 1",
code);
if (rows.empty())
{
return std::nullopt;
}
Json::CharReaderBuilder builder;
Json::Value geojson;
std::string errors;
std::istringstream stream(rows[0]["geojson"].as<std::string>());
if (!Json::parseFromStream(builder, stream, &geojson, &errors))
{
return std::nullopt;
}
return geojson;
}
bool insideAllowedBoundary(double latitude, double longitude)
{
const auto boundary = loadAllowedBoundary("BARI");
return boundary && geojson::pointInGeometry(latitude, longitude, *boundary);
}
std::optional<long long> findArea(double latitude, double longitude)
{
const auto result = drogon::app().getDbClient()->execSqlSync(
"SELECT id, JSON_UNQUOTE(JSON_EXTRACT(boundary_geojson, '$')) AS geojson "
"FROM areas WHERE active = TRUE");
"FROM areas");
Json::CharReaderBuilder builder;
for (const auto &row : result)
@@ -70,7 +118,7 @@ std::optional<long long> findArea(double latitude, double longitude)
const auto raw = row["geojson"].as<std::string>();
std::istringstream stream(raw);
if (Json::parseFromStream(builder, stream, &geojson, &errors) &&
geojson::pointInPolygon(latitude, longitude, geojson))
geojson::pointInGeometry(latitude, longitude, geojson))
{
return row["id"].as<long long>();
}
@@ -109,6 +157,27 @@ bool canModifyReport(const auth::UserContext &user, long long reportId)
return !result.empty();
}
bool canOwnOrModifyReport(const auth::UserContext &user, long long reportId)
{
if (auth::isAdmin(user))
{
return true;
}
if (!auth::canOperateReports(user))
{
return false;
}
const auto result = drogon::app().getDbClient()->execSqlSync(
"SELECT assigned_operator_id FROM reports WHERE id = ?",
reportId);
if (result.empty())
{
return false;
}
return result[0]["assigned_operator_id"].isNull() ||
result[0]["assigned_operator_id"].as<long long>() == user.id;
}
bool canFlagReportContent(const auth::UserContext &user, long long reportId)
{
if (!auth::canOperateReports(user))
@@ -260,6 +329,27 @@ std::optional<long long> assignedOperator(long long reportId)
return rows[0]["assigned_operator_id"].as<long long>();
}
std::optional<long long> activeAssignmentId(long long reportId)
{
const auto rows = drogon::app().getDbClient()->execSqlSync(
"SELECT id FROM assignments WHERE report_id = ? AND ended_at IS NULL "
"ORDER BY assigned_at DESC LIMIT 1",
reportId);
if (rows.empty())
{
return std::nullopt;
}
return rows[0]["id"].as<long long>();
}
void revokeActiveLinksForReport(long long reportId)
{
drogon::app().getDbClient()->execSqlSync(
"UPDATE intervention_access_links SET revoked_at = NOW() "
"WHERE report_id = ? AND revoked_at IS NULL AND expires_at > NOW()",
reportId);
}
void notifyReportOwner(long long reportId,
const std::string &type,
const std::string &title,
@@ -271,6 +361,50 @@ void notifyReportOwner(long long reportId,
UserService::createNotification(*owner, type, title, body, reportId);
}
}
Json::Value buildInterventionSummaryRow(const drogon::orm::Row &row,
const drogon::orm::Result &mediaRows,
const drogon::orm::Result &messageRows,
bool publicView)
{
auto response = success();
Json::Value summary = repositories::rowToJson(row, reportSchema());
summary["category_name"] = row["category_name"].as<std::string>();
if (!row["area_name"].isNull())
{
summary["area_name"] = row["area_name"].as<std::string>();
}
if (!row["assigned_team_name"].isNull())
{
summary["assigned_team_name"] = row["assigned_team_name"].as<std::string>();
}
if (publicView)
{
summary["citizen_name"] = row["first_name"].as<std::string>() + " " +
maskedLastName(row["last_name"].as<std::string>());
if (!row["phone"].isNull())
{
summary["phone"] = row["phone"].as<std::string>();
}
response["summary"] = std::move(summary);
response["media"] = repositories::resultToJson(mediaRows, reportMediaSchema());
response["latest_messages"] =
repositories::resultToJson(messageRows, messageSchema());
return response;
}
summary["first_name"] = row["first_name"].as<std::string>();
summary["last_name"] = row["last_name"].as<std::string>();
if (!row["phone"].isNull())
{
summary["phone"] = row["phone"].as<std::string>();
}
summary["email"] = row["email"].as<std::string>();
response["summary"] = std::move(summary);
response["media"] = repositories::resultToJson(mediaRows, reportMediaSchema());
response["latest_messages"] =
repositories::resultToJson(messageRows, messageSchema());
return response;
}
} // namespace
Json::Value ReportService::createReport(const auth::UserContext &user,
@@ -292,6 +426,10 @@ Json::Value ReportService::createReport(const auth::UserContext &user,
const auto latitude = body["latitude"].asDouble();
const auto longitude = body["longitude"].asDouble();
if (!insideAllowedBoundary(latitude, longitude))
{
return failure("coordinates_outside_allowed_boundary");
}
const auto areaId = findArea(latitude, longitude);
auto db = drogon::app().getDbClient();
@@ -493,6 +631,43 @@ Json::Value ReportService::otherOperatorReports(
return pagedReportResponse(result, pageValue, perPageValue);
}
Json::Value ReportService::listCategories(const auth::UserContext &user)
{
if (user.id == 0)
{
return failure("forbidden");
}
auto response = success();
response["categories"] = repositories::resultToJson(
drogon::app().getDbClient()->execSqlSync(
"SELECT * FROM report_categories WHERE active = TRUE ORDER BY name"));
return response;
}
Json::Value ReportService::listAreas()
{
auto response = success();
response["areas"] = repositories::resultToJson(
drogon::app().getDbClient()->execSqlSync("SELECT * FROM areas ORDER BY name"));
return response;
}
Json::Value ReportService::listOperatorTeams(const auth::UserContext &user)
{
if (!auth::canManageReports(user))
{
return failure("forbidden");
}
auto response = success();
response["teams"] = repositories::resultToJson(
drogon::app().getDbClient()->execSqlSync(
"SELECT t.*, tc.name AS category_name, a.name AS area_name "
"FROM teams t JOIN team_categories tc ON tc.id = t.team_category_id "
"LEFT JOIN areas a ON a.id = t.area_id "
"WHERE t.active = TRUE ORDER BY t.name"));
return response;
}
Json::Value ReportService::reportDetail(const auth::UserContext &user,
long long reportId)
{
@@ -549,19 +724,64 @@ Json::Value ReportService::interventionSummary(const auth::UserContext &user,
{
return failure("report_not_found");
}
auto response = success();
response["summary"] = repositories::rowToJson(report[0], reportSchema());
response["media"] = repositories::resultToJson(db->execSqlSync(
return buildInterventionSummaryRow(report[0],
db->execSqlSync(
"SELECT * FROM report_photos WHERE report_id = ? ORDER BY id",
reportId),
reportMediaSchema());
response["latest_messages"] = repositories::resultToJson(db->execSqlSync(
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),
messageSchema());
return response;
false);
}
Json::Value ReportService::publicInterventionSummary(const std::string &token)
{
auto db = drogon::app().getDbClient();
const auto rows = db->execSqlSync(
"SELECT l.report_id "
"FROM intervention_access_links l "
"WHERE l.token_hash = ? AND l.revoked_at IS NULL AND l.expires_at > NOW() "
"LIMIT 1",
crypto::hashSecret(token));
if (rows.empty())
{
return failure("intervention_link_not_found");
}
const auto reportId = rows[0]["report_id"].as<long long>();
const auto report = db->execSqlSync(
"SELECT r.*, c.name AS category_name, a.name AS area_name, "
"u.first_name, u.last_name, u.phone, "
"t.name AS assigned_team_name "
"FROM reports r "
"JOIN users u ON u.id = r.user_id "
"JOIN report_categories c ON c.id = r.category_id "
"LEFT JOIN areas a ON a.id = r.area_id "
"LEFT JOIN assignments ass ON ass.report_id = r.id AND ass.ended_at IS NULL "
"LEFT JOIN teams t ON t.id = ass.team_id "
"WHERE r.id = ?",
reportId);
if (report.empty())
{
return failure("intervention_link_not_found");
}
db->execSqlSync(
"UPDATE intervention_access_links SET last_accessed_at = NOW() "
"WHERE token_hash = ?",
crypto::hashSecret(token));
return buildInterventionSummaryRow(report[0],
db->execSqlSync(
"SELECT * FROM report_photos WHERE report_id = ? ORDER BY id",
reportId),
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 = ? AND u.id <> ? "
"ORDER BY m.created_at DESC LIMIT 5",
reportId,
report[0]["user_id"].as<long long>()),
true);
}
Json::Value ReportService::updateStatus(const auth::UserContext &user,
@@ -725,31 +945,57 @@ Json::Value ReportService::assignTeam(const auth::UserContext &user,
long long reportId,
const Json::Value &body)
{
if (!canModifyReport(user, reportId) || !validation::hasNumber(body, "team_id"))
if (!canOwnOrModifyReport(user, reportId))
{
return failure("forbidden");
}
if (!validation::hasNumber(body, "team_id"))
{
return failure("invalid_team");
}
auto db = drogon::app().getDbClient();
const auto current = db->execSqlSync(
"SELECT status FROM reports WHERE id = ?",
"SELECT status, assigned_operator_id FROM reports WHERE id = ?",
reportId);
if (current.empty())
{
return failure("report_not_found");
}
if (!auth::isAdmin(user) && !current[0]["assigned_operator_id"].isNull() &&
current[0]["assigned_operator_id"].as<long long>() != user.id)
{
return failure("report_owned_by_other_operator");
}
const auto oldStatus = current[0]["status"].as<std::string>();
if (!canTransition(oldStatus, "ASSIGNED"))
if (oldStatus != "OPEN" && !canTransition(oldStatus, "ASSIGNED"))
{
return failure("invalid_status_transition");
}
const auto team = db->execSqlSync(
"SELECT id FROM teams WHERE id = ? AND active = TRUE",
"SELECT id, active FROM teams WHERE id = ?",
body["team_id"].asInt64());
if (team.empty())
{
return failure("team_not_found");
}
if (!team[0]["active"].as<bool>())
{
return failure("invalid_reference");
}
if (oldStatus == "OPEN")
{
db->execSqlSync(
"UPDATE reports SET assigned_operator_id = ?, status = 'ASSIGNED' WHERE id = ?",
user.id,
reportId);
}
else
{
db->execSqlSync("UPDATE reports SET status = 'ASSIGNED' WHERE id = ?",
reportId);
}
db->execSqlSync(
"UPDATE assignments SET ended_at = NOW() "
@@ -761,8 +1007,6 @@ Json::Value ReportService::assignTeam(const auth::UserContext &user,
reportId,
body["team_id"].asInt64(),
user.id);
db->execSqlSync("UPDATE reports SET status = 'ASSIGNED' WHERE id = ?",
reportId);
db->execSqlSync(
"INSERT INTO report_status_history "
"(report_id, old_status, new_status, changed_by_user_id, note) "
@@ -770,6 +1014,7 @@ Json::Value ReportService::assignTeam(const auth::UserContext &user,
reportId,
oldStatus,
user.id);
revokeActiveLinksForReport(reportId);
notifyReportOwner(reportId,
"team_assigned",
"Team assigned",
@@ -813,6 +1058,16 @@ Json::Value ReportService::addFeedback(const auth::UserContext &user,
return failure("report_not_resolved");
}
const auto existingFeedback = db->execSqlSync(
"SELECT id FROM feedback WHERE report_id = ?",
reportId);
if (!existingFeedback.empty())
{
return failure("feedback_already_exists");
}
try
{
db->execSqlSync(
"INSERT INTO feedback (report_id, user_id, rating, comment) "
"VALUES (?, ?, ?, ?)",
@@ -820,6 +1075,11 @@ Json::Value ReportService::addFeedback(const auth::UserContext &user,
user.id,
rating,
body.get("comment", "").asString());
}
catch (const drogon::orm::DrogonDbException &)
{
return failure("feedback_already_exists");
}
return success();
}
@@ -895,6 +1155,92 @@ Json::Value ReportService::flagMessage(const auth::UserContext &user,
return response;
}
Json::Value ReportService::teamSuggestions(const auth::UserContext &user,
long long reportId)
{
if (!auth::canManageReports(user))
{
return failure("forbidden");
}
auto db = drogon::app().getDbClient();
const auto reportRows = db->execSqlSync(
"SELECT id, category_id, area_id FROM reports WHERE id = ?",
reportId);
if (reportRows.empty())
{
return failure("report_not_found");
}
const auto result = db->execSqlSync(
"SELECT t.*, tc.name AS category_name, a.name AS area_name, "
"CASE "
" WHEN t.area_id = r.area_id THEN 'area_and_category' "
" WHEN t.area_id IS NULL THEN 'category_only' "
" ELSE 'fallback_global' "
"END AS match_reason, "
"CASE WHEN t.area_id = r.area_id THEN 0 "
" WHEN t.area_id IS NULL THEN 1 "
" ELSE 2 END AS rank_value "
"FROM reports r "
"JOIN report_category_team_categories map ON map.report_category_id = r.category_id "
"JOIN teams t ON t.team_category_id = map.team_category_id AND t.active = TRUE "
"JOIN team_categories tc ON tc.id = t.team_category_id "
"LEFT JOIN areas a ON a.id = t.area_id "
"WHERE r.id = ? AND (t.area_id = r.area_id OR t.area_id IS NULL) "
"ORDER BY rank_value, t.name",
reportId);
auto response = success();
response["suggestions"] = Json::arrayValue;
for (size_t i = 0; i < result.size(); ++i)
{
auto item = repositories::rowToJson(result[i]);
item["is_recommended"] = (i == 0);
response["suggestions"].append(std::move(item));
}
return response;
}
Json::Value ReportService::createInterventionLink(const auth::UserContext &user,
long long reportId)
{
if (!canModifyReport(user, reportId))
{
return failure("forbidden");
}
const auto assignmentId = activeAssignmentId(reportId);
if (!assignmentId)
{
return failure("invalid_status_transition");
}
auto token = crypto::randomToken();
auto db = drogon::app().getDbClient();
revokeActiveLinksForReport(reportId);
db->execSqlSync(
"INSERT INTO intervention_access_links "
"(report_id, assignment_id, token_hash, created_by_user_id, expires_at) "
"VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY))",
reportId,
*assignmentId,
crypto::hashSecret(token),
user.id);
const auto expiresRows = db->execSqlSync(
"SELECT expires_at FROM intervention_access_links WHERE token_hash = ?",
crypto::hashSecret(token));
return successWithLink("/api/v1/interventions/" + token,
expiresRows[0]["expires_at"].as<std::string>());
}
Json::Value ReportService::revokeInterventionLink(const auth::UserContext &user,
long long reportId)
{
if (!canModifyReport(user, reportId))
{
return failure("forbidden");
}
revokeActiveLinksForReport(reportId);
return success();
}
Json::Value ReportService::openDataReports(const std::optional<std::string> &page,
const std::optional<std::string> &perPage)
{
@@ -916,6 +1262,20 @@ Json::Value ReportService::openDataReports(const std::optional<std::string> &pag
perPageValue,
offset),
openDataReportSchema());
if (const auto boundary = loadAllowedBoundary("BARI"))
{
Json::Value filtered(Json::arrayValue);
for (const auto &report : response["reports"])
{
if (geojson::pointInGeometry(report["latitude"].asDouble(),
report["longitude"].asDouble(),
*boundary))
{
filtered.append(report);
}
}
response["reports"] = std::move(filtered);
}
return response;
}
+7
View File
@@ -19,14 +19,18 @@ class ReportService
Json::Value assignedReports(const auth::UserContext &user,
const std::optional<std::string> &page,
const std::optional<std::string> &perPage);
Json::Value listAreas();
Json::Value openReports(const auth::UserContext &user,
const std::optional<std::string> &page,
const std::optional<std::string> &perPage);
Json::Value otherOperatorReports(const auth::UserContext &user,
const std::optional<std::string> &page,
const std::optional<std::string> &perPage);
Json::Value listCategories(const auth::UserContext &user);
Json::Value listOperatorTeams(const auth::UserContext &user);
Json::Value reportDetail(const auth::UserContext &user, long long reportId);
Json::Value interventionSummary(const auth::UserContext &user, long long reportId);
Json::Value publicInterventionSummary(const std::string &token);
Json::Value updateStatus(const auth::UserContext &user,
long long reportId,
const Json::Value &body);
@@ -48,6 +52,9 @@ class ReportService
long long reportId,
long long messageId,
const Json::Value &body);
Json::Value teamSuggestions(const auth::UserContext &user, long long reportId);
Json::Value createInterventionLink(const auth::UserContext &user, long long reportId);
Json::Value revokeInterventionLink(const auth::UserContext &user, long long reportId);
Json::Value openDataReports(const std::optional<std::string> &page,
const std::optional<std::string> &perPage);
Json::Value canAddMedia(const auth::UserContext &user, long long reportId);
+2
View File
@@ -11,6 +11,8 @@ target_include_directories(${PROJECT_NAME}_test
target_link_libraries(${PROJECT_NAME}_test
PRIVATE
Drogon::Drogon
CURL::libcurl
OpenSSL::Crypto
)
ParseAndAddDrogonTests(${PROJECT_NAME}_test)
+71 -11
View File
@@ -5,6 +5,7 @@
#include "common/GeoJson.h"
#include "common/Crypto.h"
#include "common/Http.h"
#include "common/Validation.h"
#include "repositories/Sql.h"
@@ -54,7 +55,7 @@ DROGON_TEST(ValidationRejectsMalformedIdentity)
CHECK(visione::validation::isValidEmail("[email protected]"));
CHECK(!visione::validation::isValidEmail("not-an-email"));
CHECK(visione::validation::isValidFiscalCode("RSSMRA80A01H501U"));
CHECK(visione::validation::isValidFiscalCode("VSSGNR94M08A662P"));
CHECK(visione::validation::isValidFiscalCode("LCCVTI62M02A662Q"));
CHECK(!visione::validation::isValidFiscalCode("short"));
CHECK(!visione::validation::isValidFiscalCode("VSSGNR94M08A662A"));
}
@@ -65,7 +66,6 @@ DROGON_TEST(SqlScalarSerializationKeepsTypes)
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;
@@ -73,11 +73,28 @@ DROGON_TEST(SqlScalarSerializationKeepsTypes)
CHECK(json["id"].isUInt64());
CHECK(json["banned"].isBool());
CHECK(json["must_change_password"].isBool());
CHECK(json["points"].isInt64());
CHECK(json["phone"].isString());
}
DROGON_TEST(CryptoHmacIsDeterministic)
{
const auto digest = visione::crypto::hmacSha256Hex("secret", "{\"a\":1}");
CHECK(digest == "AA9E2E3575F5D7098B6CACCD790888C36D5FDB63342A73BADA2D6A51747A8494");
CHECK(visione::crypto::constantTimeEquals(digest, digest));
CHECK(!visione::crypto::constantTimeEquals(digest, "different"));
}
DROGON_TEST(ParseJsonBodyRejectsInvalidPayload)
{
const auto parsed = visione::http::parseJsonBody("{\"ok\":true}");
CHECK(parsed.isObject());
CHECK(parsed["ok"].asBool());
const auto invalid = visione::http::parseJsonBody("{oops");
CHECK(invalid.isNull());
}
DROGON_TEST(GeoJsonMatchesPointInsidePolygon)
{
Json::Value polygon;
@@ -106,18 +123,61 @@ DROGON_TEST(GeoJsonMatchesPointInsidePolygon)
CHECK(visione::geojson::pointInPolygon(41.5, 12.5, polygon));
CHECK(!visione::geojson::pointInPolygon(43.0, 12.5, polygon));
CHECK(visione::geojson::pointInGeometry(41.5, 12.5, polygon));
CHECK(!visione::geojson::pointInGeometry(43.0, 12.5, polygon));
}
DROGON_TEST(PasswordHashSupportsPbkdf2Only)
DROGON_TEST(GeoJsonAcceptsContainedPolygon)
{
const auto hash = visione::crypto::hashPassword("CorrectHorse123!");
CHECK(hash.rfind("pbkdf2_sha256$", 0) == 0);
CHECK(visione::crypto::verifyPassword("CorrectHorse123!", hash));
CHECK(!visione::crypto::verifyPassword("wrong", hash));
Json::Value container;
container["type"] = "Polygon";
Json::Value outer(Json::arrayValue);
const std::string legacy =
"sha256$local-dev-salt$CD4FF6AC8C9685D08E55AF693970F15F9BFCD4C0DC9F4D3001D202C9DB00291F";
CHECK(!visione::crypto::verifyPassword("ChangeMe123!", legacy));
Json::Value a(Json::arrayValue);
a.append(12.0);
a.append(41.0);
Json::Value b(Json::arrayValue);
b.append(13.0);
b.append(41.0);
Json::Value c(Json::arrayValue);
c.append(13.0);
c.append(42.0);
Json::Value d(Json::arrayValue);
d.append(12.0);
d.append(42.0);
outer.append(a);
outer.append(b);
outer.append(c);
outer.append(d);
outer.append(a);
container["coordinates"].append(outer);
Json::Value candidate;
candidate["type"] = "Polygon";
Json::Value inner(Json::arrayValue);
Json::Value p1(Json::arrayValue);
p1.append(12.2);
p1.append(41.2);
Json::Value p2(Json::arrayValue);
p2.append(12.8);
p2.append(41.2);
Json::Value p3(Json::arrayValue);
p3.append(12.8);
p3.append(41.8);
Json::Value p4(Json::arrayValue);
p4.append(12.2);
p4.append(41.8);
inner.append(p1);
inner.append(p2);
inner.append(p3);
inner.append(p4);
inner.append(p1);
candidate["coordinates"].append(inner);
CHECK(visione::geojson::polygonInsideGeometry(candidate, container));
candidate["coordinates"][0][3][0] = 14.0;
CHECK(!visione::geojson::polygonInsideGeometry(candidate, container));
}
int main(int argc, char **argv)