Compare commits

...
23 Commits
36 changed files with 1119 additions and 1001 deletions
+12
View File
@@ -5,4 +5,16 @@ MYSQL_PASSWORD=visione
MYSQL_ROOT_PASSWORD=root MYSQL_ROOT_PASSWORD=root
MAILPIT_SMTP_PORT=1025 MAILPIT_SMTP_PORT=1025
MAILPIT_WEB_PORT=8025 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 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) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
find_package(Python3 COMPONENTS Interpreter REQUIRED) 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) 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} target_link_libraries(${PROJECT_NAME}
PRIVATE PRIVATE
Drogon::Drogon Drogon::Drogon
CURL::libcurl
OpenSSL::Crypto
) )
file(GLOB CONTROLLER_HEADERS CONFIGURE_DEPENDS 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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential ccache cmake ninja-build git pkg-config \ 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 \ libmariadb-dev libmariadb-dev-compat \
libsqlite3-dev python3 libjsoncpp-dev uuid-dev ca-certificates \ libsqlite3-dev python3 libjsoncpp-dev uuid-dev ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -27,7 +27,7 @@ FROM ubuntu:22.04 AS runtime
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \ 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/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+40 -17
View File
@@ -9,7 +9,7 @@ Install native build dependencies first. On Ubuntu:
```bash ```bash
sudo apt install build-essential cmake ninja-build git pkg-config \ 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 libmariadb-dev libmariadb-dev-compat
``` ```
@@ -36,8 +36,8 @@ has been built.
Local seeded admin: Local seeded admin:
- email: `admin@visione.gov.it` - email: `admin@visionebari.it`
- access uses OTP through Mailpit and is for local development only. - access uses OTP email delivery and needs mail provider config.
If you already had a MySQL volume from the old scaffold, create the local If you already had a MySQL volume from the old scaffold, create the local
database/user once: database/user once:
@@ -56,24 +56,13 @@ curl http://localhost:5555/api/v1/health
curl http://localhost:5555/api/v1/ready curl http://localhost:5555/api/v1/ready
curl -X POST http://localhost:5555/api/v1/auth/otp/request \ curl -X POST http://localhost:5555/api/v1/auth/otp/request \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"email":"admin@visione.gov.it"}' -d '{"email":"admin@visionebari.it"}'
``` ```
Use `-DUSE_SYSTEM_DROGON=ON` only if Drogon is already installed and Use `-DUSE_SYSTEM_DROGON=ON` only if Drogon is already installed and
discoverable by CMake. The default build uses CPM.cmake and downloads Drogon discoverable by CMake. The default build uses CPM.cmake and downloads Drogon
`v1.9.13`. `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 ## OpenAPI
@@ -93,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 The app serves the generated JSON at `/api/v1/openapi.json` and Swagger UI at
`/api/v1/docs`. `/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 ## Visione Lab
`scripts/lab/` is a small Streamlit frontend used only to test the backend `scripts/lab/` is a small Streamlit frontend used only to test the backend
@@ -137,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`. domain in Dokploy against service `backend` and container port `5555`.
The runtime image includes `config.json`; override environment-specific values 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 multi-architecture Mailpit image to avoid `exec format error` failures on ARM
servers. 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 `CORS_ALLOWED_ORIGIN` in Dokploy environment variables. Use the exact browser
origin without quotes or a trailing slash, for example origin without quotes or a trailing slash, for example
`https://visione.studentindipendenti.it`. `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 Docker builds use BuildKit cache mounts for CPM dependencies and `ccache`, so
repeat Dokploy builds can reuse Drogon and C++ compilation cache. repeat Dokploy builds can reuse Drogon and C++ compilation cache.
+26
View File
@@ -1,6 +1,9 @@
#include "common/Crypto.h" #include "common/Crypto.h"
#include <drogon/utils/Utilities.h> #include <drogon/utils/Utilities.h>
#include <openssl/crypto.h>
#include <openssl/hmac.h>
#include <iomanip> #include <iomanip>
#include <random> #include <random>
#include <sstream> #include <sstream>
@@ -27,6 +30,29 @@ std::string sha256(const std::string &value)
return drogon::utils::getSha256(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::string randomNumericCode(int digits)
{ {
std::random_device device; std::random_device device;
+2
View File
@@ -5,6 +5,8 @@
namespace visione::crypto namespace visione::crypto
{ {
std::string sha256(const std::string &value); 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 randomNumericCode(int digits);
std::string randomToken(); std::string randomToken();
std::string hashSecret(const std::string &secret); std::string hashSecret(const std::string &secret);
+189 -69
View File
@@ -1,119 +1,239 @@
#include "common/Email.h" #include "common/Email.h"
#include <curl/curl.h>
#include <drogon/drogon.h> #include <drogon/drogon.h>
#include <array> #include <algorithm>
#include <cstddef>
#include <cstring> #include <cstring>
#include <string> #include <memory>
#include <sstream>
#include <netdb.h>
#include <sys/socket.h>
#include <unistd.h>
namespace visione::email namespace visione::email
{ {
namespace namespace
{ {
const Json::Value &mailConfig()
{
return drogon::app().getCustomConfig()["mail"];
}
std::string configString(const std::string &key, const std::string &fallback) 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(); return mail.get(key, fallback).asString();
} }
int configInt(const std::string &key, int fallback) 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(); 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 &mail = mailConfig();
const auto count = recv(socket, buffer.data(), buffer.size() - 1, 0); return mail.get(key, fallback).asBool();
return count > 0 && buffer[0] >= '2' && buffer[0] <= '3';
} }
bool sendLine(int socket, const std::string &line) struct SmtpDefaults
{ {
const auto payload = line + "\r\n"; std::string host;
return send(socket, payload.data(), payload.size(), 0) == int port;
static_cast<ssize_t>(payload.size()); 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 } // namespace
std::string EmailService::deliveryName() const
{
return configString("provider", "smtp");
}
bool EmailService::sendOtp(const std::string &to, const std::string &code) const bool EmailService::sendOtp(const std::string &to, const std::string &code) const
{ {
const auto host = configString("smtp_host", "127.0.0.1"); const auto provider = deliveryName();
const auto port = std::to_string(configInt("smtp_port", 1025)); const auto defaults = smtpDefaults(provider);
const auto from = configString("from", "[email protected]"); 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{}; if (host.empty() || from.empty())
hints.ai_family = AF_UNSPEC; {
hints.ai_socktype = SOCK_STREAM; 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 (!ensureCurlInitialized())
if (getaddrinfo(host.c_str(), port.c_str(), &hints, &addresses) != 0)
{ {
return false; return false;
} }
int smtp = -1; std::ostringstream payload;
for (auto *address = addresses; address != nullptr; address = address->ai_next) payload << "To: <" << to << ">\r\n";
{ payload << "From: Visione <" << from << ">\r\n";
smtp = socket(address->ai_family, address->ai_socktype, address->ai_protocol); payload << "Subject: Codice OTP Visione\r\n";
if (smtp < 0) payload << "MIME-Version: 1.0\r\n";
{ payload << "Content-Type: text/plain; charset=utf-8\r\n";
continue; payload << "\r\n";
} payload << "Il tuo codice OTP per Visione e': " << code << "\r\n";
if (connect(smtp, address->ai_addr, address->ai_addrlen) == 0) payload << "Scade tra 10 minuti.\r\n";
{ const auto payloadString = payload.str();
break; const auto url = smtpUrl(host, port, useTls);
} const auto mailFrom = "<" + from + ">";
close(smtp); const auto recipientAddress = "<" + to + ">";
smtp = -1;
}
freeaddrinfo(addresses);
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; return false;
} }
const auto closeSocket = [&smtp]() { CurlList recipients(nullptr, &curl_slist_free_all);
if (smtp >= 0) 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; return false;
} }
const std::string body = UploadContext uploadContext{.payload = &payloadString};
"From: Visione <" + from + ">\r\n" char errorBuffer[CURL_ERROR_SIZE] = {0};
"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"
".";
const auto ok = sendCommand(smtp, body) && sendCommand(smtp, "QUIT"); curl_easy_setopt(curl.get(), CURLOPT_URL, url.c_str());
closeSocket(); curl_easy_setopt(curl.get(), CURLOPT_MAIL_FROM, mailFrom.c_str());
return ok; 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 } // namespace visione::email
+1 -1
View File
@@ -8,6 +8,6 @@ class EmailService
{ {
public: public:
bool sendOtp(const std::string &to, const std::string &code) const; bool sendOtp(const std::string &to, const std::string &code) const;
std::string deliveryName() const;
}; };
} // namespace visione::email } // namespace visione::email
+31 -1
View File
@@ -1,5 +1,9 @@
#include "common/Http.h" #include "common/Http.h"
#include <drogon/HttpRequest.h>
#include <sstream>
namespace visione::http namespace visione::http
{ {
drogon::HttpResponsePtr json(Json::Value body, drogon::HttpStatusCode status) 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); 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) Json::Value requireJson(const drogon::HttpRequestPtr &request)
{ {
const auto &body = request->getJsonObject(); const auto &body = request->getJsonObject();
@@ -31,5 +43,23 @@ Json::Value requireJson(const drogon::HttpRequestPtr &request)
} }
return *body; 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::HttpResponsePtr error(const std::string &code,
drogon::HttpStatusCode status, drogon::HttpStatusCode status,
const std::string &message = {}); 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 requireJson(const drogon::HttpRequestPtr &request);
Json::Value parseJsonBody(const std::string &body);
} // namespace visione::http } // namespace visione::http
+18 -4
View File
@@ -56,10 +56,24 @@
"token_ttl_hours": 24, "token_ttl_hours": 24,
"otp_ttl_minutes": 10 "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": { "mail": {
"smtp_host": "127.0.0.1", "provider": "smtp",
"smtp_port": 1025, "host": "127.0.0.1",
"from": "[email protected]" "port": 1025,
"username": "",
"password": "",
"from": "[email protected]",
"use_tls": false,
"use_starttls": false,
"verify_peer": false,
"verify_host": false
}, },
"uploads": { "uploads": {
"public_base_path": "/uploads", "public_base_path": "/uploads",
@@ -69,7 +83,7 @@
"spec_path": "build/openapi/visione.openapi.json" "spec_path": "build/openapi/visione.openapi.json"
}, },
"cors": { "cors": {
"allowed_origin": "http://localhost:3000" "allowed_origin": "http://127.0.0.1:3000"
} }
} }
} }
+54
View File
@@ -66,6 +66,60 @@ void AuthController::verifyOtp(
serviceResponse(service.verifyOtp(body), std::move(callback)); serviceResponse(service.verifyOtp(body), std::move(callback));
} }
void AuthController::startCieLogin(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
const auto body = visione::http::requireJson(req);
if (body.isNull())
{
callback(visione::http::error("invalid_json", drogon::k400BadRequest));
return;
}
visione::services::AuthService service;
serviceResponse(service.startCieLogin(body), std::move(callback));
}
void AuthController::completeCieLogin(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
const std::string rawBody(req->body());
const auto body = visione::http::parseJsonBody(rawBody);
if (body.isNull())
{
callback(visione::http::error("invalid_json", drogon::k400BadRequest));
return;
}
visione::services::AuthService service;
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( void AuthController::me(
const drogon::HttpRequestPtr &req, const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback) std::function<void(const HttpResponsePtr &)> &&callback)
+19
View File
@@ -15,6 +15,15 @@ class AuthController : public drogon::HttpController<AuthController>
ADD_METHOD_TO(AuthController::verifyOtp, ADD_METHOD_TO(AuthController::verifyOtp,
"/api/v1/auth/otp/verify", "/api/v1/auth/otp/verify",
drogon::Post); drogon::Post);
ADD_METHOD_TO(AuthController::startCieLogin,
"/api/v1/auth/cie/start",
drogon::Post);
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::me, "/api/v1/me", drogon::Get);
ADD_METHOD_TO(AuthController::editUser, "/api/v1/me", drogon::Patch); ADD_METHOD_TO(AuthController::editUser, "/api/v1/me", drogon::Patch);
METHOD_LIST_END METHOD_LIST_END
@@ -28,6 +37,16 @@ class AuthController : public drogon::HttpController<AuthController>
void verifyOtp( void verifyOtp(
const drogon::HttpRequestPtr &req, const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback); std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void startCieLogin(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
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, void me(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback); std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void editUser(const drogon::HttpRequestPtr &req, void editUser(const drogon::HttpRequestPtr &req,
+2 -1
View File
@@ -57,7 +57,8 @@ void sendService(Json::Value body,
else if (error == "invalid_status_transition" || else if (error == "invalid_status_transition" ||
error == "media_limit_reached" || error == "media_limit_reached" ||
error == "report_not_open" || error == "report_not_open" ||
error == "report_owned_by_other_operator") error == "report_owned_by_other_operator" ||
error == "feedback_already_exists")
{ {
status = drogon::k409Conflict; status = drogon::k409Conflict;
} }
+26 -14
View File
@@ -13,14 +13,25 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-visione} DB_PASSWORD: ${DB_PASSWORD:-visione}
DB_NAME: ${DB_NAME:-visione} DB_NAME: ${DB_NAME:-visione}
DB_TIMEOUT: ${DB_TIMEOUT:-30} DB_TIMEOUT: ${DB_TIMEOUT:-30}
SMTP_HOST: ${SMTP_HOST:-mailpit} MAIL_PROVIDER: ${MAIL_PROVIDER:-resend}
SMTP_PORT: ${SMTP_PORT:-1025} 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} 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: expose:
- "5555" - "5555"
networks:
- visione
- dokploy-network
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
@@ -37,8 +48,6 @@ services:
- "3306" - "3306"
volumes: volumes:
- mysql-data:/var/lib/mysql - mysql-data:/var/lib/mysql
networks:
- visione
healthcheck: healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"] test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"]
interval: 5s interval: 5s
@@ -52,14 +61,17 @@ services:
expose: expose:
- "1025" - "1025"
- "8025" - "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: volumes:
mysql-data: mysql-data:
uploads-data: uploads-data:
networks:
visione:
dokploy-network:
external: true
-160
View File
@@ -1,160 +0,0 @@
# Visione API Spec
Base path: `/api/v1`
## Auth Header
```http
Authorization: Bearer <token>
```
## Roles
- public: no auth
- authenticated: any valid user
- operator: `reports.manage` or admin
- admin: role/permission admin
## HTTP Semantics
- `200`: success
- `401`: missing or invalid auth
- `403`: authenticated but forbidden
- `404`: missing resource or invalid public intervention token
- `409`: workflow or ownership conflict
- `422`: semantic payload/reference error
## Technical
- `GET /health`
- `GET /ready`
- `GET /openapi.json`
- `GET /docs`
## Auth + Profile
- `POST /auth/register`
citizen registration without password
- `POST /auth/otp/request`
sends OTP through Mailpit
- `POST /auth/otp/verify`
consumes OTP, returns bearer token
- `GET /me`
current user profile, roles, permissions, points
- `PATCH /me`
editable fields today: `first_name`, `last_name`, `phone`
## Public Reference / Public Access
- `GET /areas`
municipal areas
- `GET /interventions/{token}`
public external-team summary link
- `GET /open-data/reports`
anonymized reports in `RESOLVED` or `CLOSED`
filtered to Bari municipal boundary
## User Reference / Personal
- `GET /report-categories`
active report categories, authenticated users only
- `GET /notifications`
- `PATCH /notifications/{id}/read`
- `PATCH /notifications/read-all`
- `GET /me/rewards`
## Report Endpoints
- `POST /reports`
create citizen report, optional inline `media`, area inferred by GeoJSON
coordinates must stay inside allowed Bari municipal boundary
- `GET /reports`
citizen sees own reports; operator/admin see all
- `GET /reports/{id}`
detail + status history + media
- `GET /reports/{id}/messages`
- `POST /reports/{id}/messages`
- `POST /reports/{id}/media`
max 5 total media, image/video only
- `POST /reports/{id}/feedback`
only owner, only when status is `RESOLVED`
## Operator Workflow
- `GET /operator/teams`
active external intervention teams
- `GET /operator/reports/open`
- `GET /operator/reports/assigned`
- `GET /operator/reports/others`
- `POST /reports/{id}/take`
`OPEN -> UNDER_REVIEW`
- `PATCH /reports/{id}/status`
only owner operator or admin, valid transitions only
- `POST /reports/{id}/assignments`
assign external team
if report is `OPEN`, assignment can directly claim report and move to `ASSIGNED`
- `GET /reports/{id}/intervention-summary`
operator/admin full summary with citizen contact data
- `GET /reports/{id}/team-suggestions`
suggestions by category/team-category mapping and area
- `POST /reports/{id}/intervention-link`
create/rotate public intervention token for current assignment
- `DELETE /reports/{id}/intervention-link`
revoke active intervention token
## Moderation
- `POST /reports/{id}/flags`
operator flags owned report
- `POST /reports/{id}/messages/{message_id}/flags`
operator flags owned report message
- `GET /admin/moderation`
admin moderation queue
- `PATCH /admin/moderation/{id}`
admin resolves/dismisses; may also reject report
## Admin Reference Data
- `POST /admin/categories`
- `PATCH /admin/categories/{id}`
- `DELETE /admin/categories/{id}`
- `POST /admin/areas`
- `PATCH /admin/areas/{id}`
- `DELETE /admin/areas/{id}`
deletes area and clears linked `reports.area_id` and `teams.area_id`
area polygon must stay inside allowed Bari municipal boundary
- `POST /admin/teams`
- `PATCH /admin/teams/{id}`
- `DELETE /admin/teams/{id}`
- `GET /admin/users`
- `PATCH /admin/users/{id}/roles`
- `PATCH /admin/users/{id}/ban`
- `GET /admin/stats/overview`
## Workflow Summary
### Citizen
1. register/login
2. create `OPEN` report
3. watch status/history/media
4. chat with operator
5. receive notifications
6. leave feedback on `RESOLVED`
### Operator
1. inspect open queue
2. `take` or assign team directly
3. own report through `assigned_operator_id`
4. update status / chat / moderate
5. generate public team link
### Admin
1. manage reference data and users
2. resolve moderation
3. read global stats
## Current Status Machine
- `OPEN -> UNDER_REVIEW | REJECTED`
- `UNDER_REVIEW -> ASSIGNED | REJECTED`
- `ASSIGNED -> IN_PROGRESS | REJECTED`
- `IN_PROGRESS -> RESOLVED | REJECTED`
- `RESOLVED -> CLOSED`
- `REJECTED -> CLOSED`
## Important Current Truths
- External teams are not platform users.
- Platform owner is always `reports.assigned_operator_id`.
- `assignments` is historical external team linkage only.
- Feedback does not auto-close report.
- Public intervention summary exposes citizen phone by design, but not email or full surname.
- Report coordinates are constrained to Bari municipal boundary.
- Admin-created area polygons must stay inside Bari municipal boundary.
-99
View File
@@ -1,99 +0,0 @@
# Visione Backend Architecture
## Purpose
Visione backend is C++20 + Drogon API for municipal urban issue reporting.
Citizens create reports. Operators own report workflow on platform. External
intervention teams do not log into platform; operators can assign them and
generate public read-only intervention links.
## Source Of Truth
- Route truth: `controllers/*.h`
- Business truth: `services/*.cc`
- Schema truth: `migrations/*.sql`
- Contract truth: `docs/openapi/visione.openapi.yaml` merged with route scan
- High-signal human summary: `docs/API_SPEC.md` and `docs/LLM_CONTEXT.md`
## Code Layout
- `controllers/`: HTTP input/output only, auth gate, status mapping.
- `services/`: business rules, workflow, permission checks, orchestration.
- `repositories/`: SQL serialization helpers and migration runner.
- `common/`: auth, crypto, validation, HTTP helpers, email, CORS.
- `migrations/`: ordered SQL schema evolution.
- `docs/openapi/`: manual OpenAPI layer.
- `scripts/generate_openapi.py`: extracts Drogon routes and merges manual spec.
## Runtime Model
- Auth uses bearer tokens from `api_tokens`.
- OTP login uses `otp_challenges` and Mailpit in local dev.
- DB access uses raw parameterized SQL through `drogon::orm::DbClient`.
- No Drogon generated ORM models. No `Mapper<T>`.
## Role Model
- `citizen`: own reports only, own messages, feedback, notifications.
- `operator`: can read all reports, manage workflow, assign teams, create flags.
- `admin`: full admin + report management + moderation approval.
Effective permission helpers:
- `auth::canManageReports(user)`: admin or `reports.manage`
- `auth::canOperateReports(user)`: non-admin report manager
## Workflow Ownership Rules
- `reports.assigned_operator_id` is single platform owner of report.
- `assignments` is external team assignment history only.
- Team assignment can claim `OPEN` report directly:
operator becomes `assigned_operator_id`, report moves to `ASSIGNED`.
- `take` exists for `OPEN -> UNDER_REVIEW` without team assignment.
- External team link is public, revocable, tied to current assignment, hashed in
`intervention_access_links`.
- Spatial scope is constrained to Bari municipal boundary from
`allowed_boundaries.code = 'BARI'`.
## Status Machine
- Allowed statuses: `OPEN`, `UNDER_REVIEW`, `ASSIGNED`, `IN_PROGRESS`,
`RESOLVED`, `REJECTED`, `CLOSED`
- Allowed transitions:
- `OPEN -> UNDER_REVIEW | REJECTED`
- `UNDER_REVIEW -> ASSIGNED | REJECTED`
- `ASSIGNED -> IN_PROGRESS | REJECTED`
- `IN_PROGRESS -> RESOLVED | REJECTED`
- `RESOLVED -> CLOSED`
- `REJECTED -> CLOSED`
## Main Persistence Objects
- `users`, `roles`, `permissions`, `user_roles`, `role_permissions`
- `api_tokens`, `otp_challenges`
- `report_categories`, `areas`, `team_categories`, `teams`
- `reports`, `report_status_history`, `assignments`, `report_photos`
- `messages`, `feedback`, `reward_events`, `notifications`
- `moderation_flags`
- `report_category_team_categories`
- `intervention_access_links`
- `allowed_boundaries`
## HTTP/Error Conventions
- `401`: missing/invalid auth
- `403`: authenticated but forbidden
- `404`: missing resource or invalid public intervention token
- `409`: workflow/ownership conflict
- `422`: semantic payload/reference error
Controllers currently map service error strings heuristically. When adding new
service errors, update controller mappers too.
## OpenAPI Generation
`scripts/generate_openapi.py` scans `ADD_METHOD_TO(...)` in controller headers.
Generated route skeletons merge with `docs/openapi/visione.openapi.yaml`.
Build fails if manual spec contains operation without matching route, unless
`x-manual-only: true`.
Output path:
- `build/openapi/visione.openapi.json`
## Current Gaps / Sharp Edges
- No dedicated repository layer split yet; SQL still lives inside services.
- `AuthService::editUser()` updates fields with dynamic SQL column name from
hardcoded allowlist, not user input.
- Public intervention summary currently excludes email and full surname, but
still exposes citizen phone by design.
- Allowed-area containment for admin areas is vertex-based, simple not strict GIS-hard.
- No frontend doc set here beyond API/OpenAPI docs.
-109
View File
@@ -1,109 +0,0 @@
# Visione Database Schema
Schema truth is `migrations/*.sql`. This file is compact human map of current DB.
## Migration Set
- `001_initial_schema.sql`
base auth, users, reports, areas, teams, messages, feedback, rewards
- `004_report_workflow.sql`
`reports.assigned_operator_id`, workflow indexes, richer media columns
- `006_completion_features.sql`
notifications, moderation flags, reward uniqueness
- `007_reference_data_and_intervention_links.sql`
category-to-team-category mapping, public intervention links
## Core Auth Tables
- `users`
identity, profile, points, `banned`
- `roles`
- `permissions`
- `role_permissions`
- `user_roles`
- `otp_challenges`
OTP flow, hashed codes, expiry, consumption
- `api_tokens`
bearer token hashes, expiry, revocation
## Reference Data
- `report_categories`
citizen-facing report categories, `active`
- `areas`
named municipal areas, GeoJSON polygon in `boundary_geojson`
- `team_categories`
category of external intervention team
- `teams`
external teams, optional `area_id`, `team_category_id`, `active`
- `report_category_team_categories`
explicit compatibility mapping:
report category -> team category
- `allowed_boundaries`
active allowed municipal containment geometry, currently seeded with `BARI`
## Report Domain
- `reports`
owner citizen, category, inferred area, coordinates, address, description,
status, `assigned_operator_id`
- `report_status_history`
immutable workflow log
- `assignments`
historical report -> external team assignments, active row has `ended_at IS NULL`
- `report_photos`
historical name; stores both images and videos
current columns include `original_filename`, `content_type`, `media_kind`,
`size_bytes`
- `messages`
citizen/operator conversation per report
- `feedback`
one row per report, unique on `report_id`
## Engagement / Moderation
- `reward_events`
points ledger, unique `(report_id, reason)`
- `notifications`
local in-app notifications
- `moderation_flags`
operator-created flags, admin-resolved moderation queue
- `intervention_access_links`
public revocable hashed links for external teams, tied to `report_id` and
`assignment_id`
## Ownership Model
- Platform owner of report: `reports.assigned_operator_id`
- External team linkage: `assignments`
- Public field-team access: `intervention_access_links`
## Status Model
`reports.status` enum:
- `OPEN`
- `UNDER_REVIEW`
- `ASSIGNED`
- `IN_PROGRESS`
- `RESOLVED`
- `REJECTED`
- `CLOSED`
## Important Constraints / Semantics
- `users.email` unique
- `users.fiscal_code` unique
- `teams.name` unique
- `areas.name` unique
- `report_categories.name` unique
- `feedback.report_id` unique
- `reward_events(report_id, reason)` unique
- `api_tokens.token_hash` unique
- `intervention_access_links.token_hash` unique
- category/team delete behavior is soft delete via `active = FALSE`
- area delete behavior is hard delete; linked `reports.area_id` and `teams.area_id`
become `NULL`
## Current Query Model
- No generated ORM models
- No `Mapper<T>`
- Raw parameterized SQL through `drogon::orm::DbClient`
- JSON serialization helpers in `repositories/Sql.*`
## Current Gaps
- No dedicated table for external team personnel
- No route-optimization/routing tables
- No audit table for intervention link access beyond `last_accessed_at`
- Containment check for admin area polygons is simple vertex-inside check
-127
View File
@@ -1,127 +0,0 @@
# Visione Backend LLM Context
## What This Repo Is
Visione is C++20 + Drogon backend for urban issue reporting.
Core actors:
- `citizen`: creates and follows reports
- `operator`: manages report lifecycle on platform
- `admin`: manages users/reference data and approves moderation
External intervention teams are off-platform. They receive summary through
public revocable link generated by operator/admin.
## Environment
- Local backend runs outside Docker.
- Docker is only for local dependencies like MySQL and Mailpit.
- Primary 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`
## High-Signal Conventions
- Code, DB identifiers, API fields, comments: English.
- User-facing labels may be Italian.
- Keep controller thin, service owns business logic.
- Use parameterized SQL only.
- Prefer current code over old assumptions. Route truth is in controller headers.
## Current Auth Model
- Registration creates `citizen`.
- Login path:
- OTP email via Mailpit
- Auth token: bearer token from `api_tokens`
- Banned users cannot authenticate.
## Current Report Workflow
### Citizen flow
1. Register/login.
2. `POST /reports` with category, coordinates, address, description, optional media array.
3. Backend first rejects coordinates outside Bari municipal boundary.
4. Backend infers `area_id` by GeoJSON polygon match when possible from existing areas.
5. Report starts `OPEN`, status history row inserted.
6. Citizen can:
- list own reports
- read detail/history/media
- chat on own report
- upload more media up to 5 total
- read notifications
- read rewards
- leave feedback only when report is `RESOLVED`
### Operator flow
1. Read queues:
- `/operator/reports/open`
- `/operator/reports/assigned`
- `/operator/reports/others`
2. Optional `POST /reports/{id}/take`:
`OPEN -> UNDER_REVIEW`, owner becomes operator.
3. `POST /reports/{id}/assignments`:
- if report `OPEN`, operator can assign directly without `take`
- owner becomes operator if report had no owner
- status becomes `ASSIGNED`
- current active assignment closes, new one inserted
4. Continue status flow:
- `ASSIGNED -> IN_PROGRESS -> RESOLVED -> CLOSED`
- rejection path to `REJECTED`, then `CLOSED`
5. Operator can chat with citizen on owned report.
6. Operator can create moderation flags for report/message on owned report.
7. Operator can request team suggestions and generate/revoke public intervention link.
### Admin flow
- Create/update categories, areas, teams
- Area GeoJSON must stay inside Bari municipal boundary
- List users, edit roles, ban/unban
- Read stats
- Review moderation queue and resolve/dismiss flags
- Optionally reject report during moderation resolution
## Team Model
- `teams` are external intervention squads, not platform users.
- `assignments` is history of which external team was assigned.
- `report_category_team_categories` maps report categories to compatible team categories.
- `GET /reports/{id}/team-suggestions` returns recommended active teams by:
- mapped team category
- same area first
- area-less team as fallback
- `POST /reports/{id}/intervention-link` creates revocable public link.
- `GET /interventions/{token}` returns public summary for field team:
report data, location, media, assigned team, masked citizen identity, phone.
## Spatial Boundary Rule
- `allowed_boundaries` stores active municipal allowed area.
- Current seed uses `BARI`.
- Report coordinates outside boundary are rejected.
- Admin area polygons outside boundary are rejected.
- Open data output is filtered again by same boundary.
## Current Notifications / Rewards / Moderation
- Notifications: local in-app only.
- Reward points: awarded once per `(report_id, reason)`.
- Moderation:
- operator creates flag
- admin resolves/dismisses
- admin may reject report during moderation decision
## Important Behavioral Truths
- Owner on platform is always `reports.assigned_operator_id`, not team.
- Feedback does not auto-close report.
- Public areas list is anonymous.
- Report categories require auth.
- Team list requires operator/admin auth.
- Moderation approval is admin-only.
- Public intervention token invalid/revoked/scoped-out returns `404`.
## Files To Read First
- `docs/API_SPEC.md`
- `docs/ARCHITECTURE.md`
- `docs/openapi/visione.openapi.yaml`
- `controllers/*.h`
- `services/*.cc`
- `migrations/*.sql`
## External References
- Local Drogon source: `/home/gabdevele/Projects/snitchitaly/backend/drogon`
- Drogon docs: `https://drogonframework.github.io/drogon-docs/`
- Durable agent instructions: `AGENTS.md`
-129
View File
@@ -1,129 +0,0 @@
# Visione Project Spec
This file describes current implemented product shape, not aspirational backlog.
## Goal
Visione lets citizens report urban issues and lets municipal staff manage report
workflow, communicate with citizens, assign real external intervention teams,
moderate abuse, and inspect operational stats.
## Roles
- `citizen`
register, login, create reports, read own reports, chat, upload media,
read notifications, read rewards, leave feedback on resolved reports
- `operator`
read all reports, take open reports, own workflow, assign external teams,
change statuses, chat with citizens, create moderation flags, generate public
intervention links
- `admin`
all operator powers plus user/role management, reference data management,
moderation approval, bans, stats
## Product Model
- Citizens use platform directly.
- Operators use platform directly.
- External intervention teams do not authenticate to platform.
- Teams receive public read-only intervention summary through revocable link.
## Implemented Features
### Auth
- citizen registration
- OTP login through Mailpit
- bearer token auth
- profile read + limited self-edit (`first_name`, `last_name`, `phone`)
### Reports
- create report with category, coordinates, address, description
- optional inline media on creation
- later media upload up to total 5 files
- coordinates must stay inside Bari municipal boundary
- area inferred by GeoJSON polygon match
- citizen report list/detail/history/media
- operator queues:
- open unassigned
- assigned to current operator
- assigned to other operators
### Workflow
- statuses:
`OPEN`, `UNDER_REVIEW`, `ASSIGNED`, `IN_PROGRESS`, `RESOLVED`, `REJECTED`, `CLOSED`
- valid transitions enforced in backend
- `take` claims open report into `UNDER_REVIEW`
- team assignment can directly claim open report and move to `ASSIGNED`
- owner on platform remains operator, not team
### Communication
- citizen/operator report chat
- local in-app notifications for status, assignment, messages, rewards
### External Team Handling
- external teams are reference data, not platform accounts
- active teams filtered for operator list
- suggestion endpoint recommends compatible teams by category mapping and area
- operator/admin can create or revoke public intervention summary link
- public summary exposes operational info needed on field:
location, description, media, assigned team, masked citizen identity, phone
### Moderation
- operator can flag report or message on owned report
- admin resolves or dismisses flag
- admin can reject report during moderation decision
### Rewards / Feedback
- reward events ledger
- points awarded on resolved/closed report, deduplicated by `(report_id, reason)`
- citizen feedback allowed only on `RESOLVED`
- feedback does not auto-close report
### Admin
- create/update categories
- soft-delete categories
- create/update areas
area polygons must stay inside Bari municipal boundary
- delete areas
- create/update teams
- soft-delete teams
- list users, update roles, ban/unban
- global stats including area/category/team resolution aggregates
### Public Data
- area list
- anonymized resolved/closed open-data export
- public intervention token endpoint
- open-data output filtered to Bari municipal boundary
## Current Workflow Summary
### Citizen
1. register/login
2. create report
3. report starts `OPEN`
4. follow history/status
5. chat with operator if needed
6. receive notifications
7. leave feedback when resolved
### Operator
1. inspect open queue
2. `take` or assign external team directly
3. become report owner on platform
4. update status / chat / flag abuse
5. generate public intervention link for team
### Admin
1. manage users and reference data
2. approve moderation decisions
3. inspect stats
## Out Of Scope Today
- real CIE/SPID integration
- production email/SMS/Telegram/WhatsApp notifications
- routing optimization for teams
- team platform accounts
- citizen-facing frontend in this repo
- hard-delete lifecycle for reference data
## Product Truth Rules
- Code truth beats stale docs.
- Route truth: controller headers.
- Business truth: services.
- DB truth: migrations.
-167
View File
@@ -1,167 +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. 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 usa OTP, in quanto dimostrato essere più sicuro.
– 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 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
}
+199 -3
View File
@@ -1496,6 +1496,99 @@
], ],
"type": "object" "type": "object"
}, },
"CieStartRequest": {
"additionalProperties": false,
"properties": {
"frontend_redirect_uri": {
"type": "string"
}
},
"required": [
"frontend_redirect_uri"
],
"type": "object"
},
"CieStartResponse": {
"additionalProperties": false,
"properties": {
"authorization_url": {
"type": "string"
},
"ok": {
"type": "boolean"
},
"state": {
"type": "string"
},
"status": {
"type": "string"
}
},
"required": [
"ok",
"authorization_url",
"state",
"status"
],
"type": "object"
},
"CieExchangeRequest": {
"additionalProperties": false,
"properties": {
"email": {
"type": "string"
},
"fiscal_code": {
"type": "string"
},
"first_name": {
"type": "string"
},
"issuer": {
"type": "string"
},
"last_name": {
"type": "string"
},
"state": {
"type": "string"
},
"subject": {
"type": "string"
}
},
"required": [
"state",
"issuer",
"subject",
"fiscal_code"
],
"type": "object"
},
"CieSessionResponse": {
"additionalProperties": false,
"properties": {
"auth": {
"additionalProperties": true,
"type": "object"
},
"ok": {
"type": "boolean"
},
"status": {
"type": "string"
},
"user_id": {
"format": "int64",
"type": "integer"
}
},
"required": [
"ok",
"status"
],
"type": "object"
},
"UpdateModerationRequest": { "UpdateModerationRequest": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -1610,7 +1703,110 @@
"version": "0.1.0" "version": "0.1.0"
}, },
"openapi": "3.0.3", "openapi": "3.0.3",
"paths": { "paths": {
"/api/v1/auth/cie/exchange": {
"post": {
"description": "Completes CIE bridge exchange after Django RP verifies the identity and posts signed user claims.",
"operationId": "AuthController.completeCieLogin",
"parameters": [ ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CieExchangeRequest"
}
}
},
"required": true
},
"responses": {
"302": {
"description": "Redirect to frontend callback URL with state query parameter."
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"404": {
"$ref": "#/components/responses/NotFound"
}
},
"summary": "Complete CIE login",
"tags": [
"Auth"
]
}
},
"/api/v1/auth/cie/session/{id}": {
"get": {
"description": "Returns status for CIE login session started by Visione.",
"operationId": "AuthController.cieSessionStatus",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CieSessionResponse"
}
}
},
"description": "Current login session status."
},
"404": {
"$ref": "#/components/responses/NotFound"
}
},
"summary": "CIE session status",
"tags": [
"Auth"
]
}
},
"/api/v1/auth/cie/start": {
"post": {
"description": "Creates a local CIE login session and returns Django RP authorization URL.",
"operationId": "AuthController.startCieLogin",
"parameters": [ ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CieStartRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CieStartResponse"
}
}
},
"description": "Authorization URL and local state."
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"summary": "Start CIE login",
"tags": [
"Auth"
]
}
},
"/api/v1/admin/areas": { "/api/v1/admin/areas": {
"post": { "post": {
"description": "Creates a municipal area with GeoJSON boundary.", "description": "Creates a municipal area with GeoJSON boundary.",
@@ -2386,7 +2582,7 @@
}, },
"/api/v1/auth/otp/request": { "/api/v1/auth/otp/request": {
"post": { "post": {
"description": "Sends a one-time code by email through Mailpit locally.", "description": "Sends a one-time code by email through configured SMTP provider.",
"operationId": "AuthController.requestOtp", "operationId": "AuthController.requestOtp",
"parameters": [ ], "parameters": [ ],
"requestBody": { "requestBody": {
@@ -3116,7 +3312,7 @@
}, },
"/api/v1/reports": { "/api/v1/reports": {
"get": { "get": {
"description": "Lists reports visible to the authenticated user.", "description": "Lists reports visible to the authenticated user. Citizens see own reports only; operators and admins can read all reports. Admin can use this endpoint for global read-only overview screens.",
"operationId": "ReportController.list", "operationId": "ReportController.list",
"parameters": [ "parameters": [
{ {
+54 -3
View File
@@ -5,8 +5,24 @@
#include "repositories/MigrationRunner.h" #include "repositories/MigrationRunner.h"
#include <cstdio> #include <cstdio>
#include <fstream>
#include <cstdlib> #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() int main()
{ {
@@ -19,6 +35,7 @@ int main()
{ {
configFile >> config; configFile >> config;
// DB config from env
if (const char* envHost = std::getenv("DB_HOST")) if (const char* envHost = std::getenv("DB_HOST"))
config["db_clients"][0]["host"] = envHost; config["db_clients"][0]["host"] = envHost;
if (const char* envPort = std::getenv("DB_PORT")) if (const char* envPort = std::getenv("DB_PORT"))
@@ -32,12 +49,46 @@ int main()
if (const char* envTimeout = std::getenv("DB_TIMEOUT")) if (const char* envTimeout = std::getenv("DB_TIMEOUT"))
config["db_clients"][0]["timeout"] = std::stod(envTimeout); 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")) 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")) 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")) if (const char* envCorsAllowedOrigin = std::getenv("CORS_ALLOWED_ORIGIN"))
config["custom_config"]["cors"]["allowed_origin"] = envCorsAllowedOrigin; 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); drogon::app().loadConfigJson(config);
} }
else else
+5 -5
View File
@@ -224,13 +224,13 @@ INSERT IGNORE INTO users (
fiscal_code, fiscal_code,
phone phone
) VALUES ( ) VALUES (
'Gennario', 'Vito',
'Vessio', 'Leccese',
'admin@visione.gov.it', 'admin@visionebari.it',
'VSSGNR94M08A662P', 'LCCVTI62M02A662Q',
'380 6180216' '380 6180216'
); );
INSERT IGNORE INTO user_roles (user_id, role_id) INSERT IGNORE INTO user_roles (user_id, role_id)
SELECT u.id, r.id FROM users u JOIN roles r SELECT u.id, r.id FROM users u JOIN roles r
WHERE u.email = 'admin@visione.gov.it' AND r.name = 'admin'; WHERE u.email = 'admin@visionebari.it' AND r.name = 'admin';
+5 -5
View File
@@ -1,7 +1,7 @@
UPDATE users UPDATE users
SET email = 'admin@visione.gov.it', SET email = 'admin@visionebari.it',
first_name = 'Gennario', first_name = 'Vito',
last_name = 'Vessio', last_name = 'Leccese',
fiscal_code = 'VSSGNR94M08A662P', fiscal_code = 'LCCVTI62M02A662Q',
phone = '380 6180216' phone = '380 6180216'
WHERE email IN ('admin@visione.local', '[email protected].it'); WHERE email = 'admin@visionebari.it';
+5 -5
View File
@@ -1,7 +1,7 @@
UPDATE users UPDATE users
SET email = 'admin@visione.gov.it', SET email = 'admin@visionebari.it',
first_name = 'Gennario', first_name = 'Vito',
last_name = 'Vessio', last_name = 'Leccese',
fiscal_code = 'VSSGNR94M08A662P', fiscal_code = 'LCCVTI62M02A662Q',
phone = '380 6180216' phone = '380 6180216'
WHERE email IN ('admin@visione.local', '[email protected].it'); WHERE email = 'admin@visionebari.it';
@@ -1,12 +0,0 @@
ALTER TABLE teams DROP FOREIGN KEY teams_area_fk;
ALTER TABLE reports DROP FOREIGN KEY reports_area_fk;
ALTER TABLE areas DROP COLUMN active;
ALTER TABLE teams
ADD CONSTRAINT teams_area_fk
FOREIGN KEY (area_id) REFERENCES areas(id) ON DELETE SET NULL;
ALTER TABLE reports
ADD CONSTRAINT reports_area_fk
FOREIGN KEY (area_id) REFERENCES areas(id) ON DELETE SET NULL;
-3
View File
@@ -1,3 +0,0 @@
ALTER TABLE users
DROP COLUMN password_hash,
DROP COLUMN must_change_password;
+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 #!/usr/bin/env python3
"""Generate OpenAPI JSON from Drogon routes plus a manual OpenAPI YAML file."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
+335 -12
View File
@@ -1,21 +1,33 @@
#include "services/AuthService.h" #include "services/AuthService.h"
#include "common/Email.h" #include "common/Email.h"
#include "common/Crypto.h"
#include "common/Validation.h" #include "common/Validation.h"
#include "common/Crypto.h"
#include <drogon/drogon.h> #include <drogon/drogon.h>
#include <algorithm>
#include <cctype>
#include <json/writer.h>
#include <optional>
#include <string_view>
#include <vector>
namespace visione::services namespace visione::services
{ {
namespace 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; Json::Value body;
body["ok"] = false; body["ok"] = false;
body["error"] = code; body["error"] = code;
if (!message.empty())
{
body["message"] = message;
}
return body; return body;
} }
@@ -42,6 +54,74 @@ int tokenTtlHours()
.asInt(); .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) Json::Value issueToken(long long userId)
{ {
auto token = crypto::randomToken(); auto token = crypto::randomToken();
@@ -58,6 +138,107 @@ Json::Value issueToken(long long userId)
body["expires_in_hours"] = tokenTtlHours(); body["expires_in_hours"] = tokenTtlHours();
return body; 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 } // namespace
Json::Value AuthService::registerCitizen(const Json::Value &body) Json::Value AuthService::registerCitizen(const Json::Value &body)
@@ -147,12 +328,12 @@ Json::Value AuthService::requestOtp(const Json::Value &body)
email::EmailService mail; email::EmailService mail;
if (!mail.sendOtp(user[0]["email"].as<std::string>(), code)) 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"); return failure("otp_email_failed");
} }
auto response = success(); auto response = success();
response["delivery"] = "mailpit"; response["delivery"] = mail.deliveryName();
response["expires_in_minutes"] = otpTtlMinutes(); response["expires_in_minutes"] = otpTtlMinutes();
return response; return response;
} }
@@ -183,6 +364,143 @@ Json::Value AuthService::verifyOtp(const Json::Value &body)
return issueToken(challenge[0]["user_id"].as<long long>()); return issueToken(challenge[0]["user_id"].as<long long>());
} }
Json::Value AuthService::startCieLogin(const Json::Value &body)
{
const auto frontendRedirect =
body.get("frontend_redirect_uri", cieDefaultFrontendRedirect()).asString();
if (frontendRedirect.empty())
{
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();
db->execSqlSync(
"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::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) Json::Value AuthService::currentUser(const auth::UserContext &user)
{ {
auto db = drogon::app().getDbClient(); auto db = drogon::app().getDbClient();
@@ -214,27 +532,32 @@ Json::Value AuthService::currentUser(const auth::UserContext &user)
return response; return response;
} }
Json::Value AuthService::editUser(const auth::UserContext &user, Json::Value::const_iterator::reference body) { Json::Value AuthService::editUser(const auth::UserContext &user,
Json::Value::const_iterator::reference body)
{
const auto allowedFields = {"first_name", "last_name", "phone"}; const auto allowedFields = {"first_name", "last_name", "phone"};
auto db = drogon::app().getDbClient(); auto db = drogon::app().getDbClient();
bool hasUpdates = false; bool hasUpdates = false;
for (const auto& field : allowedFields) { for (const auto &field : allowedFields)
if (body.isMember(field)) { {
if (!body[field].isString()) { if (body.isMember(field))
{
if (!body[field].isString())
{
return failure("invalid_field_type"); return failure("invalid_field_type");
} }
db->execSqlSync( db->execSqlSync(
"UPDATE users SET " + std::string(field) + " = ? WHERE id = ?", "UPDATE users SET " + std::string(field) + " = ? WHERE id = ?",
body[field].asString(), body[field].asString(),
user.id user.id);
);
hasUpdates = true; hasUpdates = true;
} }
} }
if (!hasUpdates) { if (!hasUpdates)
{
return failure("missing_fields"); return failure("missing_fields");
} }
+5
View File
@@ -12,6 +12,11 @@ class AuthService
Json::Value registerCitizen(const Json::Value &body); Json::Value registerCitizen(const Json::Value &body);
Json::Value requestOtp(const Json::Value &body); Json::Value requestOtp(const Json::Value &body);
Json::Value verifyOtp(const Json::Value &body); Json::Value verifyOtp(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 currentUser(const auth::UserContext &user);
Json::Value editUser(const auth::UserContext & user, Json::Value::const_iterator::reference body); Json::Value editUser(const auth::UserContext & user, Json::Value::const_iterator::reference body);
}; };
+23 -7
View File
@@ -7,6 +7,7 @@
#include "services/UserService.h" #include "services/UserService.h"
#include <drogon/drogon.h> #include <drogon/drogon.h>
#include <drogon/orm/Exception.h>
#include <map> #include <map>
#include <set> #include <set>
@@ -1057,13 +1058,28 @@ Json::Value ReportService::addFeedback(const auth::UserContext &user,
return failure("report_not_resolved"); return failure("report_not_resolved");
} }
db->execSqlSync( const auto existingFeedback = db->execSqlSync(
"INSERT INTO feedback (report_id, user_id, rating, comment) " "SELECT id FROM feedback WHERE report_id = ?",
"VALUES (?, ?, ?, ?)", reportId);
reportId, if (!existingFeedback.empty())
user.id, {
rating, return failure("feedback_already_exists");
body.get("comment", "").asString()); }
try
{
db->execSqlSync(
"INSERT INTO feedback (report_id, user_id, rating, comment) "
"VALUES (?, ?, ?, ?)",
reportId,
user.id,
rating,
body.get("comment", "").asString());
}
catch (const drogon::orm::DrogonDbException &)
{
return failure("feedback_already_exists");
}
return success(); return success();
} }
+2
View File
@@ -11,6 +11,8 @@ target_include_directories(${PROJECT_NAME}_test
target_link_libraries(${PROJECT_NAME}_test target_link_libraries(${PROJECT_NAME}_test
PRIVATE PRIVATE
Drogon::Drogon Drogon::Drogon
CURL::libcurl
OpenSSL::Crypto
) )
ParseAndAddDrogonTests(${PROJECT_NAME}_test) ParseAndAddDrogonTests(${PROJECT_NAME}_test)
+20 -1
View File
@@ -5,6 +5,7 @@
#include "common/GeoJson.h" #include "common/GeoJson.h"
#include "common/Crypto.h" #include "common/Crypto.h"
#include "common/Http.h"
#include "common/Validation.h" #include "common/Validation.h"
#include "repositories/Sql.h" #include "repositories/Sql.h"
@@ -54,7 +55,7 @@ DROGON_TEST(ValidationRejectsMalformedIdentity)
CHECK(visione::validation::isValidEmail("[email protected]")); CHECK(visione::validation::isValidEmail("[email protected]"));
CHECK(!visione::validation::isValidEmail("not-an-email")); CHECK(!visione::validation::isValidEmail("not-an-email"));
CHECK(visione::validation::isValidFiscalCode("RSSMRA80A01H501U")); CHECK(visione::validation::isValidFiscalCode("RSSMRA80A01H501U"));
CHECK(visione::validation::isValidFiscalCode("VSSGNR94M08A662P")); CHECK(visione::validation::isValidFiscalCode("LCCVTI62M02A662Q"));
CHECK(!visione::validation::isValidFiscalCode("short")); CHECK(!visione::validation::isValidFiscalCode("short"));
CHECK(!visione::validation::isValidFiscalCode("VSSGNR94M08A662A")); CHECK(!visione::validation::isValidFiscalCode("VSSGNR94M08A662A"));
} }
@@ -76,6 +77,24 @@ DROGON_TEST(SqlScalarSerializationKeepsTypes)
CHECK(json["phone"].isString()); 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) DROGON_TEST(GeoJsonMatchesPointInsidePolygon)
{ {
Json::Value polygon; Json::Value polygon;