Compare commits
32
Commits
c5fede30ad
..
main
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,9 +36,8 @@ has been built.
|
|||||||
|
|
||||||
Local seeded admin:
|
Local seeded admin:
|
||||||
|
|
||||||
- email: `admin@visione.local`
|
- email: `admin@visionebari.it`
|
||||||
- password: `ChangeMe123!`
|
- access uses OTP email delivery and needs mail provider config.
|
||||||
- the account has `must_change_password = true` and is for local development only.
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -55,26 +54,15 @@ docker compose exec -T mysql mysql -uroot -proot -e \
|
|||||||
```bash
|
```bash
|
||||||
curl http://localhost:5555/api/v1/health
|
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/login/password \
|
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.local","password":"ChangeMe123!"}'
|
-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
|
||||||
|
|
||||||
@@ -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
|
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
|
||||||
@@ -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`.
|
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.
|
||||||
|
|
||||||
|
|||||||
+1
-7
@@ -45,7 +45,7 @@ std::optional<UserContext> authenticate(const drogon::HttpRequestPtr &request)
|
|||||||
|
|
||||||
auto db = drogon::app().getDbClient();
|
auto db = drogon::app().getDbClient();
|
||||||
const auto userResult = db->execSqlSync(
|
const auto userResult = db->execSqlSync(
|
||||||
"SELECT u.id, u.email, u.must_change_password "
|
"SELECT u.id, u.email "
|
||||||
"FROM api_tokens t "
|
"FROM api_tokens t "
|
||||||
"JOIN users u ON u.id = t.user_id "
|
"JOIN users u ON u.id = t.user_id "
|
||||||
"WHERE t.token_hash = ? AND t.revoked_at IS NULL "
|
"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.roles = std::move(roles);
|
||||||
context.permissions = std::move(permissions);
|
context.permissions = std::move(permissions);
|
||||||
context.role = primaryRole(context.roles);
|
context.role = primaryRole(context.roles);
|
||||||
context.mustChangePassword = userResult[0]["must_change_password"].as<bool>();
|
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,9 +121,4 @@ bool isAdmin(const UserContext &user)
|
|||||||
{
|
{
|
||||||
return hasRole(user, "admin") || hasPermission(user, "admin.manage");
|
return hasRole(user, "admin") || hasPermission(user, "admin.manage");
|
||||||
}
|
}
|
||||||
|
|
||||||
bool mustChangePassword(const UserContext &user)
|
|
||||||
{
|
|
||||||
return user.mustChangePassword;
|
|
||||||
}
|
|
||||||
} // namespace visione::auth
|
} // namespace visione::auth
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ struct UserContext
|
|||||||
std::string role;
|
std::string role;
|
||||||
std::vector<std::string> roles;
|
std::vector<std::string> roles;
|
||||||
std::vector<std::string> permissions;
|
std::vector<std::string> permissions;
|
||||||
bool mustChangePassword{false};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
std::optional<std::string> bearerToken(const drogon::HttpRequestPtr &request);
|
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 canManageReports(const UserContext &user);
|
||||||
bool canOperateReports(const UserContext &user);
|
bool canOperateReports(const UserContext &user);
|
||||||
bool isAdmin(const UserContext &user);
|
bool isAdmin(const UserContext &user);
|
||||||
bool mustChangePassword(const UserContext &user);
|
|
||||||
} // namespace visione::auth
|
} // namespace visione::auth
|
||||||
|
|||||||
+25
-58
@@ -1,10 +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 <openssl/evp.h>
|
|
||||||
|
|
||||||
#include <array>
|
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <random>
|
#include <random>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
@@ -14,9 +13,6 @@ namespace visione::crypto
|
|||||||
{
|
{
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
constexpr int passwordIterations = 210000;
|
|
||||||
constexpr size_t passwordHashBytes = 32;
|
|
||||||
|
|
||||||
std::string toHex(const unsigned char *data, size_t size)
|
std::string toHex(const unsigned char *data, size_t size)
|
||||||
{
|
{
|
||||||
std::ostringstream stream;
|
std::ostringstream stream;
|
||||||
@@ -27,22 +23,6 @@ std::string toHex(const unsigned char *data, size_t size)
|
|||||||
}
|
}
|
||||||
return stream.str();
|
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
|
} // namespace
|
||||||
|
|
||||||
std::string sha256(const std::string &value)
|
std::string sha256(const std::string &value)
|
||||||
@@ -50,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;
|
||||||
@@ -70,42 +73,6 @@ std::string randomToken()
|
|||||||
return drogon::utils::getUuid() + drogon::utils::getUuid();
|
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)
|
std::string hashSecret(const std::string &secret)
|
||||||
{
|
{
|
||||||
return sha256("visione:" + secret);
|
return sha256("visione:" + secret);
|
||||||
|
|||||||
+2
-3
@@ -5,10 +5,9 @@
|
|||||||
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 hashPassword(const std::string &password);
|
|
||||||
bool verifyPassword(const std::string &password, const std::string &hash);
|
|
||||||
std::string hashSecret(const std::string &secret);
|
std::string hashSecret(const std::string &secret);
|
||||||
} // namespace visione::crypto
|
} // namespace visione::crypto
|
||||||
|
|
||||||
|
|||||||
+189
-69
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
+114
-10
@@ -1,5 +1,6 @@
|
|||||||
#include "common/GeoJson.h"
|
#include "common/GeoJson.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace visione::geojson
|
namespace visione::geojson
|
||||||
@@ -11,17 +12,12 @@ struct Point
|
|||||||
double x;
|
double x;
|
||||||
double y;
|
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)
|
if (!ring.isArray() || ring.size() < 4)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -41,10 +37,27 @@ bool pointInPolygon(double latitude, double longitude, const Json::Value &geojso
|
|||||||
const auto x = longitude;
|
const auto x = longitude;
|
||||||
const auto y = latitude;
|
const auto y = latitude;
|
||||||
bool inside = false;
|
bool inside = false;
|
||||||
|
constexpr double epsilon = 1e-9;
|
||||||
for (size_t i = 0, j = points.size() - 1; i < points.size(); j = i++)
|
for (size_t i = 0, j = points.size() - 1; i < points.size(); j = i++)
|
||||||
{
|
{
|
||||||
const auto &pi = points[i];
|
const auto &pi = points[i];
|
||||||
const auto &pj = points[j];
|
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 =
|
const bool intersects =
|
||||||
((pi.y > y) != (pj.y > y)) &&
|
((pi.y > y) != (pj.y > y)) &&
|
||||||
(x < (pj.x - pi.x) * (y - pi.y) / (pj.y - pi.y) + pi.x);
|
(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;
|
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
@@ -5,5 +5,6 @@
|
|||||||
namespace visione::geojson
|
namespace visione::geojson
|
||||||
{
|
{
|
||||||
bool pointInPolygon(double latitude, double longitude, const Json::Value &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
|
} // namespace visione::geojson
|
||||||
|
|
||||||
|
|||||||
+31
-1
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
+19
-5
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-46
@@ -33,19 +33,6 @@ std::optional<visione::auth::UserContext> requireUser(
|
|||||||
return user;
|
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,
|
void sendAdmin(Json::Value body,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
@@ -69,24 +56,24 @@ void sendAdmin(Json::Value body,
|
|||||||
{
|
{
|
||||||
status = drogon::k409Conflict;
|
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));
|
callback(visione::http::json(std::move(body), status));
|
||||||
}
|
}
|
||||||
} // namespace
|
} // 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(
|
void AdminController::createCategory(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -95,20 +82,12 @@ void AdminController::createCategory(
|
|||||||
std::move(callback));
|
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(
|
void AdminController::createArea(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -117,20 +96,12 @@ void AdminController::createArea(
|
|||||||
std::move(callback));
|
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(
|
void AdminController::createTeam(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -139,12 +110,99 @@ void AdminController::createTeam(
|
|||||||
std::move(callback));
|
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(
|
void AdminController::overviewStats(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -157,7 +215,7 @@ void AdminController::listModeration(
|
|||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -175,7 +233,7 @@ void AdminController::updateModeration(
|
|||||||
long long flagId)
|
long long flagId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -189,7 +247,7 @@ void AdminController::listUsers(
|
|||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -203,7 +261,7 @@ void AdminController::updateUserRoles(
|
|||||||
long long userId)
|
long long userId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -218,7 +276,7 @@ void AdminController::updateUserBan(
|
|||||||
long long userId)
|
long long userId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,29 @@ class AdminController : public drogon::HttpController<AdminController>
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
METHOD_LIST_BEGIN
|
METHOD_LIST_BEGIN
|
||||||
ADD_METHOD_TO(AdminController::listCategories,
|
|
||||||
"/api/v1/admin/categories",
|
|
||||||
drogon::Get);
|
|
||||||
ADD_METHOD_TO(AdminController::createCategory,
|
ADD_METHOD_TO(AdminController::createCategory,
|
||||||
"/api/v1/admin/categories",
|
"/api/v1/admin/categories",
|
||||||
drogon::Post);
|
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::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::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,
|
ADD_METHOD_TO(AdminController::overviewStats,
|
||||||
"/api/v1/admin/stats/overview",
|
"/api/v1/admin/stats/overview",
|
||||||
drogon::Get);
|
drogon::Get);
|
||||||
@@ -34,24 +47,39 @@ class AdminController : public drogon::HttpController<AdminController>
|
|||||||
drogon::Patch);
|
drogon::Patch);
|
||||||
METHOD_LIST_END
|
METHOD_LIST_END
|
||||||
|
|
||||||
void listCategories(
|
|
||||||
const drogon::HttpRequestPtr &req,
|
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
|
||||||
void createCategory(
|
void createCategory(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||||
void listAreas(
|
|
||||||
const drogon::HttpRequestPtr &req,
|
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
|
||||||
void createArea(
|
void createArea(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||||
void listTeams(
|
|
||||||
const drogon::HttpRequestPtr &req,
|
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
|
||||||
void createTeam(
|
void createTeam(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
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(
|
void overviewStats(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ void AuthController::verifyOtp(
|
|||||||
serviceResponse(service.verifyOtp(body), std::move(callback));
|
serviceResponse(service.verifyOtp(body), std::move(callback));
|
||||||
}
|
}
|
||||||
|
|
||||||
void AuthController::loginWithPassword(
|
void AuthController::startCieLogin(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
@@ -78,21 +78,15 @@ void AuthController::loginWithPassword(
|
|||||||
}
|
}
|
||||||
|
|
||||||
visione::services::AuthService service;
|
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,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
const auto user = visione::auth::authenticate(req);
|
const std::string rawBody(req->body());
|
||||||
if (!user)
|
const auto body = visione::http::parseJsonBody(rawBody);
|
||||||
{
|
|
||||||
callback(visione::http::error("unauthorized", drogon::k401Unauthorized));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto body = visione::http::requireJson(req);
|
|
||||||
if (body.isNull())
|
if (body.isNull())
|
||||||
{
|
{
|
||||||
callback(visione::http::error("invalid_json", drogon::k400BadRequest));
|
callback(visione::http::error("invalid_json", drogon::k400BadRequest));
|
||||||
@@ -100,7 +94,30 @@ void AuthController::changePassword(
|
|||||||
}
|
}
|
||||||
|
|
||||||
visione::services::AuthService service;
|
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(
|
void AuthController::me(
|
||||||
|
|||||||
@@ -15,16 +15,17 @@ 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::loginWithPassword,
|
ADD_METHOD_TO(AuthController::startCieLogin,
|
||||||
"/api/v1/auth/login/password",
|
"/api/v1/auth/cie/start",
|
||||||
drogon::Post);
|
drogon::Post);
|
||||||
ADD_METHOD_TO(AuthController::changePassword,
|
ADD_METHOD_TO(AuthController::completeCieLogin,
|
||||||
"/api/v1/auth/change-password",
|
"/api/v1/auth/cie/exchange",
|
||||||
drogon::Post);
|
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,
|
ADD_METHOD_TO(AuthController::editUser, "/api/v1/me", drogon::Patch);
|
||||||
"/api/v1/me",
|
|
||||||
drogon::Patch);
|
|
||||||
METHOD_LIST_END
|
METHOD_LIST_END
|
||||||
|
|
||||||
void registerCitizen(
|
void registerCitizen(
|
||||||
@@ -36,12 +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 loginWithPassword(
|
void startCieLogin(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||||
void changePassword(
|
void completeCieLogin(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
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,
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ std::optional<std::string> queryValue(const drogon::HttpRequestPtr &req,
|
|||||||
}
|
}
|
||||||
} // namespace
|
} // 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(
|
void PublicController::openDataReports(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
@@ -29,3 +37,15 @@ void PublicController::openDataReports(
|
|||||||
callback(visione::http::json(service.openDataReports(queryValue(req, "page"),
|
callback(visione::http::json(service.openDataReports(queryValue(req, "page"),
|
||||||
queryValue(req, "per_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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,12 +6,22 @@ class PublicController : public drogon::HttpController<PublicController>
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
METHOD_LIST_BEGIN
|
METHOD_LIST_BEGIN
|
||||||
|
ADD_METHOD_TO(PublicController::listAreas, "/api/v1/areas", drogon::Get);
|
||||||
ADD_METHOD_TO(PublicController::openDataReports,
|
ADD_METHOD_TO(PublicController::openDataReports,
|
||||||
"/api/v1/open-data/reports",
|
"/api/v1/open-data/reports",
|
||||||
drogon::Get);
|
drogon::Get);
|
||||||
|
ADD_METHOD_TO(PublicController::interventionSummary,
|
||||||
|
"/api/v1/interventions/{1}",
|
||||||
|
drogon::Get);
|
||||||
METHOD_LIST_END
|
METHOD_LIST_END
|
||||||
|
|
||||||
|
void listAreas(const drogon::HttpRequestPtr &req,
|
||||||
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||||
void openDataReports(
|
void openDataReports(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||||
|
void interventionSummary(
|
||||||
|
const drogon::HttpRequestPtr &req,
|
||||||
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||||
|
const std::string &token);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,19 +36,6 @@ std::optional<visione::auth::UserContext> requireUser(
|
|||||||
return user;
|
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,
|
void sendService(Json::Value body,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
@@ -68,10 +55,22 @@ void sendService(Json::Value body,
|
|||||||
status = drogon::k404NotFound;
|
status = drogon::k404NotFound;
|
||||||
}
|
}
|
||||||
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_owned_by_other_operator" ||
|
||||||
|
error == "feedback_already_exists")
|
||||||
{
|
{
|
||||||
status = drogon::k409Conflict;
|
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));
|
callback(visione::http::json(std::move(body), status));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +107,7 @@ void ReportController::create(
|
|||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -128,7 +127,7 @@ void ReportController::list(
|
|||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -141,12 +140,38 @@ void ReportController::list(
|
|||||||
std::move(callback));
|
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(
|
void ReportController::assignedReports(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -162,7 +187,7 @@ void ReportController::openReports(
|
|||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -178,7 +203,7 @@ void ReportController::otherOperatorReports(
|
|||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -195,7 +220,7 @@ void ReportController::detail(
|
|||||||
long long reportId)
|
long long reportId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -209,7 +234,7 @@ void ReportController::interventionSummary(
|
|||||||
long long reportId)
|
long long reportId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -217,13 +242,55 @@ void ReportController::interventionSummary(
|
|||||||
sendService(service.interventionSummary(*user, reportId), std::move(callback));
|
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(
|
void ReportController::updateStatus(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
long long reportId)
|
long long reportId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -238,7 +305,7 @@ void ReportController::takeReport(
|
|||||||
long long reportId)
|
long long reportId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -252,7 +319,7 @@ void ReportController::addMessage(
|
|||||||
long long reportId)
|
long long reportId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -267,7 +334,7 @@ void ReportController::listMessages(
|
|||||||
long long reportId)
|
long long reportId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -282,7 +349,7 @@ void ReportController::flagMessage(
|
|||||||
long long messageId)
|
long long messageId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -298,7 +365,7 @@ void ReportController::assignTeam(
|
|||||||
long long reportId)
|
long long reportId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -313,7 +380,7 @@ void ReportController::addFeedback(
|
|||||||
long long reportId)
|
long long reportId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -328,7 +395,7 @@ void ReportController::flagReport(
|
|||||||
long long reportId)
|
long long reportId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -343,7 +410,7 @@ void ReportController::uploadMedia(
|
|||||||
long long reportId)
|
long long reportId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -368,7 +435,7 @@ void ReportController::uploadMedia(
|
|||||||
auto allowed = service.canAddMedia(*user, reportId);
|
auto allowed = service.canAddMedia(*user, reportId);
|
||||||
if (!allowed.get("ok", false).asBool())
|
if (!allowed.get("ok", false).asBool())
|
||||||
{
|
{
|
||||||
callback(visione::http::json(std::move(allowed), drogon::k403Forbidden));
|
sendService(std::move(allowed), std::move(callback));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ class ReportController : public drogon::HttpController<ReportController>
|
|||||||
METHOD_LIST_BEGIN
|
METHOD_LIST_BEGIN
|
||||||
ADD_METHOD_TO(ReportController::create, "/api/v1/reports", drogon::Post);
|
ADD_METHOD_TO(ReportController::create, "/api/v1/reports", drogon::Post);
|
||||||
ADD_METHOD_TO(ReportController::list, "/api/v1/reports", drogon::Get);
|
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,
|
ADD_METHOD_TO(ReportController::assignedReports,
|
||||||
"/api/v1/operator/reports/assigned",
|
"/api/v1/operator/reports/assigned",
|
||||||
drogon::Get);
|
drogon::Get);
|
||||||
@@ -21,6 +27,15 @@ class ReportController : public drogon::HttpController<ReportController>
|
|||||||
ADD_METHOD_TO(ReportController::interventionSummary,
|
ADD_METHOD_TO(ReportController::interventionSummary,
|
||||||
"/api/v1/reports/{1}/intervention-summary",
|
"/api/v1/reports/{1}/intervention-summary",
|
||||||
drogon::Get);
|
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,
|
ADD_METHOD_TO(ReportController::updateStatus,
|
||||||
"/api/v1/reports/{1}/status",
|
"/api/v1/reports/{1}/status",
|
||||||
drogon::Patch);
|
drogon::Patch);
|
||||||
@@ -54,6 +69,11 @@ class ReportController : public drogon::HttpController<ReportController>
|
|||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||||
void list(const drogon::HttpRequestPtr &req,
|
void list(const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
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(
|
void assignedReports(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||||
@@ -70,6 +90,18 @@ class ReportController : public drogon::HttpController<ReportController>
|
|||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||||
long long reportId);
|
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(
|
void updateStatus(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
|
||||||
|
|||||||
@@ -32,19 +32,6 @@ std::optional<visione::auth::UserContext> requireUser(
|
|||||||
}
|
}
|
||||||
return user;
|
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
|
} // namespace
|
||||||
|
|
||||||
void UserController::listNotifications(
|
void UserController::listNotifications(
|
||||||
@@ -52,7 +39,7 @@ void UserController::listNotifications(
|
|||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -68,7 +55,7 @@ void UserController::markNotificationRead(
|
|||||||
long long notificationId)
|
long long notificationId)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -84,7 +71,7 @@ void UserController::markAllNotificationsRead(
|
|||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -97,7 +84,7 @@ void UserController::rewards(
|
|||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
{
|
{
|
||||||
auto user = requireUser(req, callback);
|
auto user = requireUser(req, callback);
|
||||||
if (!user || blockPasswordChangeRequired(*user, callback))
|
if (!user)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-14
@@ -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
|
|
||||||
|
|||||||
@@ -1,65 +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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -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/`.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
+891
-240
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
first_name VARCHAR(100) NOT NULL,
|
first_name VARCHAR(100) NOT NULL,
|
||||||
last_name VARCHAR(100) NOT NULL,
|
last_name VARCHAR(100) NOT NULL,
|
||||||
email VARCHAR(255) NOT NULL,
|
email VARCHAR(255) NOT NULL,
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
|
||||||
fiscal_code VARCHAR(16) NOT NULL,
|
fiscal_code VARCHAR(16) NOT NULL,
|
||||||
birth_date DATE NULL,
|
birth_date DATE NULL,
|
||||||
birth_place VARCHAR(150) NULL,
|
birth_place VARCHAR(150) NULL,
|
||||||
phone VARCHAR(30) NULL,
|
phone VARCHAR(30) NULL,
|
||||||
points INT NOT NULL DEFAULT 0,
|
points INT NOT NULL DEFAULT 0,
|
||||||
banned BOOLEAN NOT NULL DEFAULT FALSE,
|
banned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
UNIQUE KEY users_email_unique (email),
|
UNIQUE KEY users_email_unique (email),
|
||||||
@@ -82,7 +80,6 @@ CREATE TABLE IF NOT EXISTS areas (
|
|||||||
name VARCHAR(120) NOT NULL,
|
name VARCHAR(120) NOT NULL,
|
||||||
description TEXT NULL,
|
description TEXT NULL,
|
||||||
boundary_geojson JSON NOT NULL,
|
boundary_geojson JSON NOT NULL,
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
UNIQUE KEY areas_name_unique (name)
|
UNIQUE KEY areas_name_unique (name)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -102,7 +99,7 @@ CREATE TABLE IF NOT EXISTS teams (
|
|||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
UNIQUE KEY teams_name_unique (name),
|
UNIQUE KEY teams_name_unique (name),
|
||||||
CONSTRAINT teams_category_fk FOREIGN KEY (team_category_id) REFERENCES team_categories(id),
|
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 (
|
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,
|
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_user_fk FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
CONSTRAINT reports_category_fk FOREIGN KEY (category_id) REFERENCES report_categories(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_status_idx (status),
|
||||||
KEY reports_category_idx (category_id),
|
KEY reports_category_idx (category_id),
|
||||||
KEY reports_area_idx (area_id)
|
KEY reports_area_idx (area_id)
|
||||||
@@ -224,20 +221,16 @@ INSERT IGNORE INTO users (
|
|||||||
first_name,
|
first_name,
|
||||||
last_name,
|
last_name,
|
||||||
email,
|
email,
|
||||||
password_hash,
|
|
||||||
fiscal_code,
|
fiscal_code,
|
||||||
phone,
|
phone
|
||||||
must_change_password
|
|
||||||
) VALUES (
|
) VALUES (
|
||||||
'Gennario',
|
'Vito',
|
||||||
'Vessio',
|
'Leccese',
|
||||||
'admin@visione.gov.it',
|
'admin@visionebari.it',
|
||||||
'pbkdf2_sha256$210000$a2c43ef09bd24ea883023e5f57fa6255d8dd317e312a40ddaa1bd3ccd83dbfa5$26D0C9000B603A424C7FB0DA3BE0CDC1798A9CBA34F391F9B8E4C0E1E4A58806',
|
'LCCVTI62M02A662Q',
|
||||||
'VSSGNR94M08A662P',
|
'380 6180216'
|
||||||
'380 6180216',
|
|
||||||
TRUE
|
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT IGNORE INTO user_roles (user_id, role_id)
|
INSERT IGNORE INTO user_roles (user_id, role_id)
|
||||||
SELECT u.id, r.id FROM users u JOIN roles r
|
SELECT u.id, r.id FROM users u JOIN roles r
|
||||||
WHERE u.email = 'admin@visione.gov.it' AND r.name = 'admin';
|
WHERE u.email = 'admin@visionebari.it' AND r.name = 'admin';
|
||||||
|
|||||||
@@ -1,9 +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'
|
||||||
password_hash = 'pbkdf2_sha256$210000$a2c43ef09bd24ea883023e5f57fa6255d8dd317e312a40ddaa1bd3ccd83dbfa5$26D0C9000B603A424C7FB0DA3BE0CDC1798A9CBA34F391F9B8E4C0E1E4A58806',
|
WHERE email = '[email protected]';
|
||||||
must_change_password = TRUE
|
|
||||||
WHERE email IN ('[email protected]', '[email protected]');
|
|
||||||
|
|||||||
@@ -1,9 +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'
|
||||||
password_hash = 'pbkdf2_sha256$210000$a2c43ef09bd24ea883023e5f57fa6255d8dd317e312a40ddaa1bd3ccd83dbfa5$26D0C9000B603A424C7FB0DA3BE0CDC1798A9CBA34F391F9B8E4C0E1E4A58806',
|
WHERE email = '[email protected]';
|
||||||
must_change_password = TRUE
|
|
||||||
WHERE email IN ('[email protected]', '[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
@@ -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)
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
|||||||
+295
-40
@@ -1,5 +1,6 @@
|
|||||||
#include "services/AdminService.h"
|
#include "services/AdminService.h"
|
||||||
|
|
||||||
|
#include "common/GeoJson.h"
|
||||||
#include "common/Validation.h"
|
#include "common/Validation.h"
|
||||||
#include "repositories/Sql.h"
|
#include "repositories/Sql.h"
|
||||||
#include "services/UserService.h"
|
#include "services/UserService.h"
|
||||||
@@ -7,6 +8,7 @@
|
|||||||
#include <drogon/drogon.h>
|
#include <drogon/drogon.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
namespace visione::services
|
namespace visione::services
|
||||||
{
|
{
|
||||||
@@ -78,28 +80,56 @@ const JsonRowSchema &adminUserSchema()
|
|||||||
static const JsonRowSchema schema{
|
static const JsonRowSchema schema{
|
||||||
{"id", JsonFieldType::UInt64},
|
{"id", JsonFieldType::UInt64},
|
||||||
{"points", JsonFieldType::Int64},
|
{"points", JsonFieldType::Int64},
|
||||||
{"banned", JsonFieldType::Bool},
|
{"banned", JsonFieldType::Bool}};
|
||||||
{"must_change_password", JsonFieldType::Bool}};
|
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
} // namespace
|
|
||||||
|
|
||||||
Json::Value AdminService::listCategories()
|
bool isActiveField(const Json::Value &body)
|
||||||
{
|
{
|
||||||
auto response = success();
|
return body.isMember("active") && body["active"].isBool();
|
||||||
response["categories"] = repositories::resultToJson(
|
|
||||||
drogon::app().getDbClient()->execSqlSync(
|
|
||||||
"SELECT * FROM report_categories ORDER BY name"));
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
Json::Value AdminService::createCategory(const auth::UserContext &user,
|
||||||
const Json::Value &body)
|
const Json::Value &body)
|
||||||
{
|
{
|
||||||
if (!auth::isAdmin(user) || !validation::hasString(body, "name"))
|
if (!auth::isAdmin(user))
|
||||||
{
|
{
|
||||||
return failure("forbidden");
|
return failure("forbidden");
|
||||||
}
|
}
|
||||||
|
if (!validation::hasString(body, "name"))
|
||||||
|
{
|
||||||
|
return failure("invalid_category");
|
||||||
|
}
|
||||||
|
|
||||||
drogon::app().getDbClient()->execSqlSync(
|
drogon::app().getDbClient()->execSqlSync(
|
||||||
"INSERT INTO report_categories (name, description) VALUES (?, ?)",
|
"INSERT INTO report_categories (name, description) VALUES (?, ?)",
|
||||||
@@ -108,22 +138,21 @@ Json::Value AdminService::createCategory(const auth::UserContext &user,
|
|||||||
return success();
|
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,
|
Json::Value AdminService::createArea(const auth::UserContext &user,
|
||||||
const Json::Value &body)
|
const Json::Value &body)
|
||||||
{
|
{
|
||||||
if (!auth::isAdmin(user) || !validation::hasString(body, "name") ||
|
if (!auth::isAdmin(user))
|
||||||
!body["boundary_geojson"].isObject())
|
{
|
||||||
|
return failure("forbidden");
|
||||||
|
}
|
||||||
|
if (!validation::hasString(body, "name") || !body["boundary_geojson"].isObject())
|
||||||
{
|
{
|
||||||
return failure("invalid_area");
|
return failure("invalid_area");
|
||||||
}
|
}
|
||||||
|
if (!isAreaInsideAllowedBoundary(body["boundary_geojson"]))
|
||||||
|
{
|
||||||
|
return failure("area_outside_allowed_boundary");
|
||||||
|
}
|
||||||
|
|
||||||
Json::StreamWriterBuilder writer;
|
Json::StreamWriterBuilder writer;
|
||||||
drogon::app().getDbClient()->execSqlSync(
|
drogon::app().getDbClient()->execSqlSync(
|
||||||
@@ -135,29 +164,38 @@ Json::Value AdminService::createArea(const auth::UserContext &user,
|
|||||||
return success();
|
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,
|
Json::Value AdminService::createTeam(const auth::UserContext &user,
|
||||||
const Json::Value &body)
|
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"))
|
!validation::hasNumber(body, "team_category_id"))
|
||||||
{
|
{
|
||||||
return failure("invalid_team");
|
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())
|
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) "
|
"INSERT INTO teams (team_category_id, area_id, name, description) "
|
||||||
"VALUES (?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?)",
|
||||||
body["team_category_id"].asInt64(),
|
body["team_category_id"].asInt64(),
|
||||||
@@ -167,7 +205,7 @@ Json::Value AdminService::createTeam(const auth::UserContext &user,
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
drogon::app().getDbClient()->execSqlSync(
|
db->execSqlSync(
|
||||||
"INSERT INTO teams (team_category_id, name, description) VALUES (?, ?, ?)",
|
"INSERT INTO teams (team_category_id, name, description) VALUES (?, ?, ?)",
|
||||||
body["team_category_id"].asInt64(),
|
body["team_category_id"].asInt64(),
|
||||||
body["name"].asString(),
|
body["name"].asString(),
|
||||||
@@ -198,10 +236,21 @@ Json::Value AdminService::overviewStats(const auth::UserContext &user)
|
|||||||
"SELECT c.name AS category, COUNT(*) AS total "
|
"SELECT c.name AS category, COUNT(*) AS total "
|
||||||
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
|
"FROM reports r JOIN report_categories c ON c.id = r.category_id "
|
||||||
"GROUP BY c.name ORDER BY total DESC"));
|
"GROUP BY c.name ORDER BY total DESC"));
|
||||||
response["reports_by_team"] = repositories::resultToJson(db->execSqlSync(
|
response["assignments_by_team"] = repositories::resultToJson(db->execSqlSync(
|
||||||
"SELECT t.name AS team, COUNT(*) AS total "
|
"SELECT t.name AS team, COUNT(DISTINCT ass.id) AS total "
|
||||||
"FROM assignments ass JOIN teams t ON t.id = ass.team_id "
|
"FROM assignments ass JOIN teams t ON t.id = ass.team_id "
|
||||||
"GROUP BY t.name ORDER BY total DESC"));
|
"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(
|
response["monthly_reports"] = repositories::resultToJson(db->execSqlSync(
|
||||||
"SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, COUNT(*) AS total "
|
"SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, COUNT(*) AS total "
|
||||||
"FROM reports GROUP BY DATE_FORMAT(created_at, '%Y-%m') "
|
"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> &page,
|
||||||
const std::optional<std::string> &perPage)
|
const std::optional<std::string> &perPage)
|
||||||
{
|
{
|
||||||
if (!auth::canManageReports(user))
|
if (!auth::isAdmin(user))
|
||||||
{
|
{
|
||||||
return failure("forbidden");
|
return failure("forbidden");
|
||||||
}
|
}
|
||||||
@@ -262,7 +311,7 @@ Json::Value AdminService::updateModeration(const auth::UserContext &user,
|
|||||||
long long flagId,
|
long long flagId,
|
||||||
const Json::Value &body)
|
const Json::Value &body)
|
||||||
{
|
{
|
||||||
if (!auth::canManageReports(user))
|
if (!auth::isAdmin(user))
|
||||||
{
|
{
|
||||||
return failure("forbidden");
|
return failure("forbidden");
|
||||||
}
|
}
|
||||||
@@ -324,6 +373,212 @@ Json::Value AdminService::updateModeration(const auth::UserContext &user,
|
|||||||
return success();
|
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)
|
Json::Value AdminService::listUsers(const auth::UserContext &user)
|
||||||
{
|
{
|
||||||
if (!auth::isAdmin(user))
|
if (!auth::isAdmin(user))
|
||||||
@@ -335,13 +590,13 @@ Json::Value AdminService::listUsers(const auth::UserContext &user)
|
|||||||
response["users"] = repositories::resultToJson(
|
response["users"] = repositories::resultToJson(
|
||||||
drogon::app().getDbClient()->execSqlSync(
|
drogon::app().getDbClient()->execSqlSync(
|
||||||
"SELECT u.id, u.first_name, u.last_name, u.email, u.fiscal_code, "
|
"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 "
|
"GROUP_CONCAT(r.name ORDER BY r.name SEPARATOR ',') AS roles "
|
||||||
"FROM users u "
|
"FROM users u "
|
||||||
"LEFT JOIN user_roles ur ON ur.user_id = u.id "
|
"LEFT JOIN user_roles ur ON ur.user_id = u.id "
|
||||||
"LEFT JOIN roles r ON r.id = ur.role_id "
|
"LEFT JOIN roles r ON r.id = ur.role_id "
|
||||||
"GROUP BY u.id, u.first_name, u.last_name, u.email, u.fiscal_code, "
|
"GROUP BY u.id, u.first_name, u.last_name, u.email, u.fiscal_code, "
|
||||||
"u.phone, u.points, u.banned, u.must_change_password "
|
"u.phone, u.points, u.banned "
|
||||||
"ORDER BY u.created_at DESC LIMIT 200"),
|
"ORDER BY u.created_at DESC LIMIT 200"),
|
||||||
adminUserSchema());
|
adminUserSchema());
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
+12
-3
@@ -12,11 +12,8 @@ namespace visione::services
|
|||||||
class AdminService
|
class AdminService
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
Json::Value listCategories();
|
|
||||||
Json::Value createCategory(const auth::UserContext &user, const Json::Value &body);
|
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 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 createTeam(const auth::UserContext &user, const Json::Value &body);
|
||||||
Json::Value overviewStats(const auth::UserContext &user);
|
Json::Value overviewStats(const auth::UserContext &user);
|
||||||
Json::Value listUsers(const auth::UserContext &user);
|
Json::Value listUsers(const auth::UserContext &user);
|
||||||
@@ -27,6 +24,18 @@ class AdminService
|
|||||||
Json::Value updateModeration(const auth::UserContext &user,
|
Json::Value updateModeration(const auth::UserContext &user,
|
||||||
long long flagId,
|
long long flagId,
|
||||||
const Json::Value &body);
|
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,
|
Json::Value updateUserRoles(const auth::UserContext &user,
|
||||||
long long targetUserId,
|
long long targetUserId,
|
||||||
const Json::Value &body);
|
const Json::Value &body);
|
||||||
|
|||||||
+330
-61
@@ -1,20 +1,33 @@
|
|||||||
#include "services/AuthService.h"
|
#include "services/AuthService.h"
|
||||||
|
|
||||||
#include "common/Crypto.h"
|
|
||||||
#include "common/Email.h"
|
#include "common/Email.h"
|
||||||
|
#include "common/Crypto.h"
|
||||||
#include "common/Validation.h"
|
#include "common/Validation.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,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>> ¶ms)
|
||||||
|
{
|
||||||
|
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();
|
||||||
@@ -57,12 +138,113 @@ 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)
|
||||||
{
|
{
|
||||||
const auto missing = validation::missingStrings(
|
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())
|
if (!missing.empty())
|
||||||
{
|
{
|
||||||
auto response = failure("missing_fields");
|
auto response = failure("missing_fields");
|
||||||
@@ -96,12 +278,11 @@ Json::Value AuthService::registerCitizen(const Json::Value &body)
|
|||||||
|
|
||||||
db->execSqlSync(
|
db->execSqlSync(
|
||||||
"INSERT INTO users "
|
"INSERT INTO users "
|
||||||
"(first_name, last_name, email, password_hash, fiscal_code, birth_date, "
|
"(first_name, last_name, email, fiscal_code, birth_date, "
|
||||||
"birth_place, phone) VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?)",
|
"birth_place, phone) VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?)",
|
||||||
body["first_name"].asString(),
|
body["first_name"].asString(),
|
||||||
body["last_name"].asString(),
|
body["last_name"].asString(),
|
||||||
email,
|
email,
|
||||||
crypto::hashPassword(body["password"].asString()),
|
|
||||||
fiscalCode,
|
fiscalCode,
|
||||||
body.get("birth_date", "").asString(),
|
body.get("birth_date", "").asString(),
|
||||||
body.get("birth_place", "").asString(),
|
body.get("birth_place", "").asString(),
|
||||||
@@ -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,68 +364,149 @@ 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::loginWithPassword(const Json::Value &body)
|
Json::Value AuthService::startCieLogin(const Json::Value &body)
|
||||||
{
|
{
|
||||||
const auto missing = validation::missingStrings(body, {"email", "password"});
|
const auto frontendRedirect =
|
||||||
if (!missing.empty())
|
body.get("frontend_redirect_uri", cieDefaultFrontendRedirect()).asString();
|
||||||
|
if (frontendRedirect.empty())
|
||||||
{
|
{
|
||||||
return failure("missing_fields");
|
return failure("missing_frontend_redirect_uri");
|
||||||
}
|
}
|
||||||
|
|
||||||
auto db = drogon::app().getDbClient();
|
const auto serviceBaseUrl = cieAuthBaseUrl();
|
||||||
const auto user = db->execSqlSync(
|
if (serviceBaseUrl.empty())
|
||||||
"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 failure("cie_auth_not_configured");
|
||||||
}
|
}
|
||||||
return issueToken(user[0]["id"].as<long long>());
|
|
||||||
|
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::changePassword(const auth::UserContext &user,
|
Json::Value AuthService::completeCieLogin(const std::string &rawBody,
|
||||||
const Json::Value &body)
|
const Json::Value &body,
|
||||||
|
const std::string &signatureHeader)
|
||||||
{
|
{
|
||||||
const auto missing =
|
if (!validateCieExchangeSignature(rawBody, signatureHeader))
|
||||||
validation::missingStrings(body, {"current_password", "new_password"});
|
{
|
||||||
if (!missing.empty())
|
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");
|
return failure("missing_fields");
|
||||||
}
|
}
|
||||||
if (body["new_password"].asString().size() < 10)
|
if (!validation::isValidFiscalCode(*fiscalCode))
|
||||||
{
|
{
|
||||||
return failure("weak_password");
|
return failure("invalid_fiscal_code");
|
||||||
}
|
}
|
||||||
|
|
||||||
auto db = drogon::app().getDbClient();
|
auto db = drogon::app().getDbClient();
|
||||||
const auto current = db->execSqlSync(
|
const auto session = db->execSqlSync(
|
||||||
"SELECT password_hash FROM users WHERE id = ? AND banned = FALSE",
|
"SELECT id, frontend_redirect_uri, status FROM cie_auth_sessions "
|
||||||
user.id);
|
"WHERE state = ? AND consumed_at IS NULL AND expires_at > NOW() LIMIT 1",
|
||||||
if (current.empty() ||
|
*state);
|
||||||
!crypto::verifyPassword(
|
if (session.empty())
|
||||||
body["current_password"].asString(),
|
|
||||||
current[0]["password_hash"].as<std::string>()))
|
|
||||||
{
|
{
|
||||||
return failure("invalid_credentials");
|
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(
|
db->execSqlSync(
|
||||||
"UPDATE users SET password_hash = ?, must_change_password = FALSE "
|
"UPDATE cie_auth_sessions "
|
||||||
"WHERE id = ?",
|
"SET status = 'AUTHENTICATED', issuer = ?, subject = ?, fiscal_code = ?, "
|
||||||
crypto::hashPassword(body["new_password"].asString()),
|
"authenticated_user_id = ?, result_token = ?, raw_claims = CAST(? AS JSON), "
|
||||||
user.id);
|
"consumed_at = NOW() WHERE id = ?",
|
||||||
return success();
|
*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();
|
||||||
const auto result = db->execSqlSync(
|
const auto result = db->execSqlSync(
|
||||||
"SELECT id, first_name, last_name, email, fiscal_code, phone, points, "
|
"SELECT id, first_name, last_name, email, fiscal_code, phone, points "
|
||||||
"must_change_password FROM users WHERE id = ?",
|
"FROM users WHERE id = ?",
|
||||||
user.id);
|
user.id);
|
||||||
|
|
||||||
auto response = success();
|
auto response = success();
|
||||||
@@ -257,8 +519,6 @@ Json::Value AuthService::currentUser(const auth::UserContext &user)
|
|||||||
response["user"]["fiscal_code"] = result[0]["fiscal_code"].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"]["phone"] = result[0]["phone"].as<std::string>();
|
||||||
response["user"]["points"] = result[0]["points"].as<int>();
|
response["user"]["points"] = result[0]["points"].as<int>();
|
||||||
response["user"]["must_change_password"] =
|
|
||||||
result[0]["must_change_password"].as<bool>();
|
|
||||||
response["user"]["role"] = user.role;
|
response["user"]["role"] = user.role;
|
||||||
for (const auto &role : user.roles)
|
for (const auto &role : user.roles)
|
||||||
{
|
{
|
||||||
@@ -272,25 +532,34 @@ 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"};
|
||||||
if (!body.isMember("field") || !body.isMember("value") ||
|
auto db = drogon::app().getDbClient();
|
||||||
!body["field"].isString() || !body["value"].isString())
|
bool hasUpdates = false;
|
||||||
|
|
||||||
|
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);
|
||||||
|
hasUpdates = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasUpdates)
|
||||||
{
|
{
|
||||||
return failure("missing_fields");
|
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();
|
return success();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +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 loginWithPassword(const Json::Value &body);
|
Json::Value startCieLogin(const Json::Value &body);
|
||||||
Json::Value changePassword(const auth::UserContext &user, 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);
|
||||||
};
|
};
|
||||||
|
|||||||
+382
-22
@@ -1,16 +1,19 @@
|
|||||||
#include "services/ReportService.h"
|
#include "services/ReportService.h"
|
||||||
|
|
||||||
|
#include "common/Crypto.h"
|
||||||
#include "common/GeoJson.h"
|
#include "common/GeoJson.h"
|
||||||
#include "common/Validation.h"
|
#include "common/Validation.h"
|
||||||
#include "repositories/Sql.h"
|
#include "repositories/Sql.h"
|
||||||
#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>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
namespace visione::services
|
namespace visione::services
|
||||||
{
|
{
|
||||||
@@ -34,6 +37,14 @@ Json::Value success()
|
|||||||
return body;
|
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)
|
bool isAllowedStatus(const std::string &status)
|
||||||
{
|
{
|
||||||
static const std::set<std::string> statuses = {
|
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);
|
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)
|
std::optional<long long> findArea(double latitude, double longitude)
|
||||||
{
|
{
|
||||||
const auto result = drogon::app().getDbClient()->execSqlSync(
|
const auto result = drogon::app().getDbClient()->execSqlSync(
|
||||||
"SELECT id, JSON_UNQUOTE(JSON_EXTRACT(boundary_geojson, '$')) AS geojson "
|
"SELECT id, JSON_UNQUOTE(JSON_EXTRACT(boundary_geojson, '$')) AS geojson "
|
||||||
"FROM areas WHERE active = TRUE");
|
"FROM areas");
|
||||||
|
|
||||||
Json::CharReaderBuilder builder;
|
Json::CharReaderBuilder builder;
|
||||||
for (const auto &row : result)
|
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>();
|
const auto raw = row["geojson"].as<std::string>();
|
||||||
std::istringstream stream(raw);
|
std::istringstream stream(raw);
|
||||||
if (Json::parseFromStream(builder, stream, &geojson, &errors) &&
|
if (Json::parseFromStream(builder, stream, &geojson, &errors) &&
|
||||||
geojson::pointInPolygon(latitude, longitude, geojson))
|
geojson::pointInGeometry(latitude, longitude, geojson))
|
||||||
{
|
{
|
||||||
return row["id"].as<long long>();
|
return row["id"].as<long long>();
|
||||||
}
|
}
|
||||||
@@ -109,6 +157,27 @@ bool canModifyReport(const auth::UserContext &user, long long reportId)
|
|||||||
return !result.empty();
|
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)
|
bool canFlagReportContent(const auth::UserContext &user, long long reportId)
|
||||||
{
|
{
|
||||||
if (!auth::canOperateReports(user))
|
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>();
|
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,
|
void notifyReportOwner(long long reportId,
|
||||||
const std::string &type,
|
const std::string &type,
|
||||||
const std::string &title,
|
const std::string &title,
|
||||||
@@ -271,6 +361,50 @@ void notifyReportOwner(long long reportId,
|
|||||||
UserService::createNotification(*owner, type, title, body, 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
|
} // namespace
|
||||||
|
|
||||||
Json::Value ReportService::createReport(const auth::UserContext &user,
|
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 latitude = body["latitude"].asDouble();
|
||||||
const auto longitude = body["longitude"].asDouble();
|
const auto longitude = body["longitude"].asDouble();
|
||||||
|
if (!insideAllowedBoundary(latitude, longitude))
|
||||||
|
{
|
||||||
|
return failure("coordinates_outside_allowed_boundary");
|
||||||
|
}
|
||||||
const auto areaId = findArea(latitude, longitude);
|
const auto areaId = findArea(latitude, longitude);
|
||||||
|
|
||||||
auto db = drogon::app().getDbClient();
|
auto db = drogon::app().getDbClient();
|
||||||
@@ -493,6 +631,43 @@ Json::Value ReportService::otherOperatorReports(
|
|||||||
return pagedReportResponse(result, pageValue, perPageValue);
|
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,
|
Json::Value ReportService::reportDetail(const auth::UserContext &user,
|
||||||
long long reportId)
|
long long reportId)
|
||||||
{
|
{
|
||||||
@@ -549,19 +724,64 @@ Json::Value ReportService::interventionSummary(const auth::UserContext &user,
|
|||||||
{
|
{
|
||||||
return failure("report_not_found");
|
return failure("report_not_found");
|
||||||
}
|
}
|
||||||
auto response = success();
|
return buildInterventionSummaryRow(report[0],
|
||||||
response["summary"] = repositories::rowToJson(report[0], reportSchema());
|
db->execSqlSync(
|
||||||
response["media"] = repositories::resultToJson(db->execSqlSync(
|
|
||||||
"SELECT * FROM report_photos WHERE report_id = ? ORDER BY id",
|
"SELECT * FROM report_photos WHERE report_id = ? ORDER BY id",
|
||||||
reportId),
|
reportId),
|
||||||
reportMediaSchema());
|
db->execSqlSync(
|
||||||
response["latest_messages"] = repositories::resultToJson(db->execSqlSync(
|
|
||||||
"SELECT m.id, m.user_id, u.first_name, u.last_name, m.body, m.created_at "
|
"SELECT m.id, m.user_id, u.first_name, u.last_name, m.body, m.created_at "
|
||||||
"FROM messages m JOIN users u ON u.id = m.user_id "
|
"FROM messages m JOIN users u ON u.id = m.user_id "
|
||||||
"WHERE m.report_id = ? ORDER BY m.created_at DESC LIMIT 5",
|
"WHERE m.report_id = ? ORDER BY m.created_at DESC LIMIT 5",
|
||||||
reportId),
|
reportId),
|
||||||
messageSchema());
|
false);
|
||||||
return response;
|
}
|
||||||
|
|
||||||
|
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,
|
Json::Value ReportService::updateStatus(const auth::UserContext &user,
|
||||||
@@ -725,31 +945,57 @@ Json::Value ReportService::assignTeam(const auth::UserContext &user,
|
|||||||
long long reportId,
|
long long reportId,
|
||||||
const Json::Value &body)
|
const Json::Value &body)
|
||||||
{
|
{
|
||||||
if (!canModifyReport(user, reportId) || !validation::hasNumber(body, "team_id"))
|
if (!canOwnOrModifyReport(user, reportId))
|
||||||
{
|
{
|
||||||
return failure("forbidden");
|
return failure("forbidden");
|
||||||
}
|
}
|
||||||
|
if (!validation::hasNumber(body, "team_id"))
|
||||||
|
{
|
||||||
|
return failure("invalid_team");
|
||||||
|
}
|
||||||
|
|
||||||
auto db = drogon::app().getDbClient();
|
auto db = drogon::app().getDbClient();
|
||||||
const auto current = db->execSqlSync(
|
const auto current = db->execSqlSync(
|
||||||
"SELECT status FROM reports WHERE id = ?",
|
"SELECT status, assigned_operator_id FROM reports WHERE id = ?",
|
||||||
reportId);
|
reportId);
|
||||||
if (current.empty())
|
if (current.empty())
|
||||||
{
|
{
|
||||||
return failure("report_not_found");
|
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>();
|
const auto oldStatus = current[0]["status"].as<std::string>();
|
||||||
if (!canTransition(oldStatus, "ASSIGNED"))
|
if (oldStatus != "OPEN" && !canTransition(oldStatus, "ASSIGNED"))
|
||||||
{
|
{
|
||||||
return failure("invalid_status_transition");
|
return failure("invalid_status_transition");
|
||||||
}
|
}
|
||||||
const auto team = db->execSqlSync(
|
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());
|
body["team_id"].asInt64());
|
||||||
if (team.empty())
|
if (team.empty())
|
||||||
{
|
{
|
||||||
return failure("team_not_found");
|
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(
|
db->execSqlSync(
|
||||||
"UPDATE assignments SET ended_at = NOW() "
|
"UPDATE assignments SET ended_at = NOW() "
|
||||||
@@ -761,8 +1007,6 @@ Json::Value ReportService::assignTeam(const auth::UserContext &user,
|
|||||||
reportId,
|
reportId,
|
||||||
body["team_id"].asInt64(),
|
body["team_id"].asInt64(),
|
||||||
user.id);
|
user.id);
|
||||||
db->execSqlSync("UPDATE reports SET status = 'ASSIGNED' WHERE id = ?",
|
|
||||||
reportId);
|
|
||||||
db->execSqlSync(
|
db->execSqlSync(
|
||||||
"INSERT INTO report_status_history "
|
"INSERT INTO report_status_history "
|
||||||
"(report_id, old_status, new_status, changed_by_user_id, note) "
|
"(report_id, old_status, new_status, changed_by_user_id, note) "
|
||||||
@@ -770,6 +1014,7 @@ Json::Value ReportService::assignTeam(const auth::UserContext &user,
|
|||||||
reportId,
|
reportId,
|
||||||
oldStatus,
|
oldStatus,
|
||||||
user.id);
|
user.id);
|
||||||
|
revokeActiveLinksForReport(reportId);
|
||||||
notifyReportOwner(reportId,
|
notifyReportOwner(reportId,
|
||||||
"team_assigned",
|
"team_assigned",
|
||||||
"Team assigned",
|
"Team assigned",
|
||||||
@@ -813,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -895,6 +1155,92 @@ Json::Value ReportService::flagMessage(const auth::UserContext &user,
|
|||||||
return response;
|
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,
|
Json::Value ReportService::openDataReports(const std::optional<std::string> &page,
|
||||||
const std::optional<std::string> &perPage)
|
const std::optional<std::string> &perPage)
|
||||||
{
|
{
|
||||||
@@ -916,6 +1262,20 @@ Json::Value ReportService::openDataReports(const std::optional<std::string> &pag
|
|||||||
perPageValue,
|
perPageValue,
|
||||||
offset),
|
offset),
|
||||||
openDataReportSchema());
|
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;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,18 @@ class ReportService
|
|||||||
Json::Value assignedReports(const auth::UserContext &user,
|
Json::Value assignedReports(const auth::UserContext &user,
|
||||||
const std::optional<std::string> &page,
|
const std::optional<std::string> &page,
|
||||||
const std::optional<std::string> &perPage);
|
const std::optional<std::string> &perPage);
|
||||||
|
Json::Value listAreas();
|
||||||
Json::Value openReports(const auth::UserContext &user,
|
Json::Value openReports(const auth::UserContext &user,
|
||||||
const std::optional<std::string> &page,
|
const std::optional<std::string> &page,
|
||||||
const std::optional<std::string> &perPage);
|
const std::optional<std::string> &perPage);
|
||||||
Json::Value otherOperatorReports(const auth::UserContext &user,
|
Json::Value otherOperatorReports(const auth::UserContext &user,
|
||||||
const std::optional<std::string> &page,
|
const std::optional<std::string> &page,
|
||||||
const std::optional<std::string> &perPage);
|
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 reportDetail(const auth::UserContext &user, long long reportId);
|
||||||
Json::Value interventionSummary(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,
|
Json::Value updateStatus(const auth::UserContext &user,
|
||||||
long long reportId,
|
long long reportId,
|
||||||
const Json::Value &body);
|
const Json::Value &body);
|
||||||
@@ -48,6 +52,9 @@ class ReportService
|
|||||||
long long reportId,
|
long long reportId,
|
||||||
long long messageId,
|
long long messageId,
|
||||||
const Json::Value &body);
|
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,
|
Json::Value openDataReports(const std::optional<std::string> &page,
|
||||||
const std::optional<std::string> &perPage);
|
const std::optional<std::string> &perPage);
|
||||||
Json::Value canAddMedia(const auth::UserContext &user, long long reportId);
|
Json::Value canAddMedia(const auth::UserContext &user, long long reportId);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+71
-11
@@ -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"));
|
||||||
}
|
}
|
||||||
@@ -65,7 +66,6 @@ DROGON_TEST(SqlScalarSerializationKeepsTypes)
|
|||||||
Json::Value value(Json::objectValue);
|
Json::Value value(Json::objectValue);
|
||||||
value["id"] = Json::UInt64(7);
|
value["id"] = Json::UInt64(7);
|
||||||
value["banned"] = false;
|
value["banned"] = false;
|
||||||
value["must_change_password"] = true;
|
|
||||||
value["points"] = Json::Int64(42);
|
value["points"] = Json::Int64(42);
|
||||||
value["phone"] = "380 6180216";
|
value["phone"] = "380 6180216";
|
||||||
return value;
|
return value;
|
||||||
@@ -73,11 +73,28 @@ DROGON_TEST(SqlScalarSerializationKeepsTypes)
|
|||||||
|
|
||||||
CHECK(json["id"].isUInt64());
|
CHECK(json["id"].isUInt64());
|
||||||
CHECK(json["banned"].isBool());
|
CHECK(json["banned"].isBool());
|
||||||
CHECK(json["must_change_password"].isBool());
|
|
||||||
CHECK(json["points"].isInt64());
|
CHECK(json["points"].isInt64());
|
||||||
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;
|
||||||
@@ -106,18 +123,61 @@ DROGON_TEST(GeoJsonMatchesPointInsidePolygon)
|
|||||||
|
|
||||||
CHECK(visione::geojson::pointInPolygon(41.5, 12.5, polygon));
|
CHECK(visione::geojson::pointInPolygon(41.5, 12.5, polygon));
|
||||||
CHECK(!visione::geojson::pointInPolygon(43.0, 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!");
|
Json::Value container;
|
||||||
CHECK(hash.rfind("pbkdf2_sha256$", 0) == 0);
|
container["type"] = "Polygon";
|
||||||
CHECK(visione::crypto::verifyPassword("CorrectHorse123!", hash));
|
Json::Value outer(Json::arrayValue);
|
||||||
CHECK(!visione::crypto::verifyPassword("wrong", hash));
|
|
||||||
|
|
||||||
const std::string legacy =
|
Json::Value a(Json::arrayValue);
|
||||||
"sha256$local-dev-salt$CD4FF6AC8C9685D08E55AF693970F15F9BFCD4C0DC9F4D3001D202C9DB00291F";
|
a.append(12.0);
|
||||||
CHECK(!visione::crypto::verifyPassword("ChangeMe123!", legacy));
|
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)
|
int main(int argc, char **argv)
|
||||||
|
|||||||
Reference in New Issue
Block a user