Add report media uploads

This commit is contained in:
gabdevele
2026-06-02 20:38:44 +02:00 Unverified
parent b59536757b
commit 1ef41bb2a3
12 changed files with 77 additions and 46 deletions
+3 -1
View File
@@ -28,7 +28,9 @@ cmake --build build
The API listens on `http://localhost:5555`. The API listens on `http://localhost:5555`.
MailHog listens on `http://localhost:8025`. MailHog listens on `http://localhost:8025`.
Local CORS allows `http://localhost:5173` by default for the Vue frontend. Local CORS allows `http://localhost:5173` by default for the future Vue frontend.
Multipart uploads currently accept report images and videos up to the app body
limit configured in `config.json`.
Local seeded admin: Local seeded admin:
+1 -1
View File
@@ -39,7 +39,7 @@
"handle_sig_term": true, "handle_sig_term": true,
"use_gzip": true, "use_gzip": true,
"use_brotli": false, "use_brotli": false,
"client_max_body_size": "1M", "client_max_body_size": "50M",
"client_max_memory_body_size": "256K" "client_max_memory_body_size": "256K"
}, },
"plugins": [], "plugins": [],
+32 -19
View File
@@ -63,25 +63,30 @@ void sendService(Json::Value body,
callback(visione::http::json(std::move(body), status)); callback(visione::http::json(std::move(body), status));
} }
bool isAllowedImage(const drogon::HttpFile &file) struct MediaType
{ {
const auto type = file.getContentType(); std::string kind;
return type == drogon::CT_IMAGE_JPG || type == drogon::CT_IMAGE_PNG || std::string contentType;
type == drogon::CT_IMAGE_WEBP; };
}
std::string imageContentType(const drogon::HttpFile &file) std::optional<MediaType> allowedMediaType(const drogon::HttpFile &file)
{ {
switch (file.getContentType()) switch (file.getContentType())
{ {
case drogon::CT_IMAGE_JPG: case drogon::CT_IMAGE_JPG:
return "image/jpeg"; return MediaType{"IMAGE", "image/jpeg"};
case drogon::CT_IMAGE_PNG: case drogon::CT_IMAGE_PNG:
return "image/png"; return MediaType{"IMAGE", "image/png"};
case drogon::CT_IMAGE_WEBP: case drogon::CT_IMAGE_WEBP:
return "image/webp"; return MediaType{"IMAGE", "image/webp"};
case drogon::CT_VIDEO_MP4:
return MediaType{"VIDEO", "video/mp4"};
case drogon::CT_VIDEO_WEBM:
return MediaType{"VIDEO", "video/webm"};
case drogon::CT_VIDEO_QUICKTIME:
return MediaType{"VIDEO", "video/quicktime"};
default: default:
return "application/octet-stream"; return std::nullopt;
} }
} }
} // namespace } // namespace
@@ -226,7 +231,7 @@ void ReportController::addFeedback(
sendService(service.addFeedback(*user, reportId, body), std::move(callback)); sendService(service.addFeedback(*user, reportId, body), std::move(callback));
} }
void ReportController::uploadPhotos( void ReportController::uploadMedia(
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)
@@ -254,7 +259,7 @@ void ReportController::uploadPhotos(
std::filesystem::path(reportsPath) / std::to_string(reportId); std::filesystem::path(reportsPath) / std::to_string(reportId);
visione::services::ReportService service; visione::services::ReportService service;
auto allowed = service.canAddPhoto(*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)); callback(visione::http::json(std::move(allowed), drogon::k403Forbidden));
@@ -262,16 +267,16 @@ void ReportController::uploadPhotos(
} }
if (parser.getFiles().size() > if (parser.getFiles().size() >
static_cast<size_t>(allowed.get("remaining_photo_slots", 0).asInt())) static_cast<size_t>(allowed.get("remaining_media_slots", 0).asInt()))
{ {
callback(visione::http::error("too_many_photos", drogon::k400BadRequest)); callback(visione::http::error("too_many_media", drogon::k400BadRequest));
return; return;
} }
for (const auto &file : parser.getFiles()) for (const auto &file : parser.getFiles())
{ {
if (!isAllowedImage(file)) if (!allowedMediaType(file))
{ {
callback(visione::http::error("unsupported_photo_type", callback(visione::http::error("unsupported_media_type",
drogon::k400BadRequest)); drogon::k400BadRequest));
return; return;
} }
@@ -281,10 +286,12 @@ void ReportController::uploadPhotos(
Json::Value response; Json::Value response;
response["ok"] = true; response["ok"] = true;
response["media"] = Json::arrayValue;
response["photos"] = Json::arrayValue; response["photos"] = Json::arrayValue;
for (const auto &file : parser.getFiles()) for (const auto &file : parser.getFiles())
{ {
const auto mediaType = allowedMediaType(file).value();
auto extension = std::string(file.getFileExtension()); auto extension = std::string(file.getFileExtension());
if (!extension.empty() && extension.front() != '.') if (!extension.empty() && extension.front() != '.')
{ {
@@ -294,24 +301,30 @@ void ReportController::uploadPhotos(
const auto fullPath = reportDir / fileName; const auto fullPath = reportDir / fileName;
if (file.saveAs(fullPath.string()) != 0) if (file.saveAs(fullPath.string()) != 0)
{ {
callback(visione::http::error("photo_save_failed", callback(visione::http::error("media_save_failed",
drogon::k500InternalServerError)); drogon::k500InternalServerError));
return; return;
} }
const auto url = publicBasePath + "/reports/" + std::to_string(reportId) + const auto url = publicBasePath + "/reports/" + std::to_string(reportId) +
"/" + fileName; "/" + fileName;
auto saved = service.addPhoto(*user, auto saved = service.addMedia(*user,
reportId, reportId,
url, url,
file.getFileName(), file.getFileName(),
imageContentType(file), mediaType.contentType,
mediaType.kind,
file.fileLength()); file.fileLength());
if (!saved.get("ok", false).asBool()) if (!saved.get("ok", false).asBool())
{ {
callback(visione::http::json(std::move(saved), drogon::k403Forbidden)); callback(visione::http::json(std::move(saved), drogon::k403Forbidden));
return; return;
} }
Json::Value item;
item["url"] = url;
item["kind"] = mediaType.kind;
item["content_type"] = mediaType.contentType;
response["media"].append(item);
response["photos"].append(url); response["photos"].append(url);
} }
+5 -2
View File
@@ -27,7 +27,10 @@ class ReportController : public drogon::HttpController<ReportController>
ADD_METHOD_TO(ReportController::addFeedback, ADD_METHOD_TO(ReportController::addFeedback,
"/api/v1/reports/{1}/feedback", "/api/v1/reports/{1}/feedback",
drogon::Post); drogon::Post);
ADD_METHOD_TO(ReportController::uploadPhotos, ADD_METHOD_TO(ReportController::uploadMedia,
"/api/v1/reports/{1}/media",
drogon::Post);
ADD_METHOD_TO(ReportController::uploadMedia,
"/api/v1/reports/{1}/photos", "/api/v1/reports/{1}/photos",
drogon::Post); drogon::Post);
METHOD_LIST_END METHOD_LIST_END
@@ -63,7 +66,7 @@ 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 uploadPhotos( void uploadMedia(
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);
+3 -1
View File
@@ -30,7 +30,9 @@ Authorization: Bearer <token>
- `GET /reports/{id}/messages`: list report messages. - `GET /reports/{id}/messages`: list report messages.
- `POST /reports/{id}/assignments`: assign a team. - `POST /reports/{id}/assignments`: assign a team.
- `POST /reports/{id}/feedback`: add citizen feedback. - `POST /reports/{id}/feedback`: add citizen feedback.
- `POST /reports/{id}/photos`: upload up to five JPEG/PNG/WebP multipart files. - `POST /reports/{id}/media`: upload up to five multipart media files. Supported
types are JPEG, PNG, WebP, MP4, WebM and QuickTime. The legacy
`/reports/{id}/photos` path is kept as a compatibility alias.
## Admin Reference Data ## Admin Reference Data
- `GET|POST /admin/categories` - `GET|POST /admin/categories`
+2 -1
View File
@@ -14,7 +14,8 @@ Important tables:
- `reports`: citizen reports with coordinates, address, status, area and assigned operator. - `reports`: citizen reports with coordinates, address, status, area and assigned operator.
- `report_status_history`: immutable status transition log. - `report_status_history`: immutable status transition log.
- `assignments`: report-to-team assignment history. - `assignments`: report-to-team assignment history.
- `report_photos`: uploaded photo URL, original filename, content type and size. - `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. - `messages`: citizen/operator report conversation.
- `feedback`: citizen rating and comment after resolution. - `feedback`: citizen rating and comment after resolution.
- `reward_events`: indirect gamification points ledger. - `reward_events`: indirect gamification points ledger.
+4 -3
View File
@@ -1,7 +1,8 @@
# Visione Backend LLM Context # Visione Backend LLM Context
Visione is a C++20/Drogon backend for a municipal urban issue reporting system. Visione is a C++20/Drogon backend for a municipal urban issue reporting system.
Citizens create reports with category, position, address, description and photos. Citizens create reports with category, position, address, description and media
attachments.
Operators manage reports, assign teams, update status and chat with citizens. Operators manage reports, assign teams, update status and chat with citizens.
Admins manage roles, users, areas, teams and reference data. Admins manage roles, users, areas, teams and reference data.
@@ -16,5 +17,5 @@ Important conventions:
- Use Drogon references from `/home/gabdevele/Projects/snitchitaly/backend/drogon` - Use Drogon references from `/home/gabdevele/Projects/snitchitaly/backend/drogon`
and `https://drogonframework.github.io/drogon-docs/`. and `https://drogonframework.github.io/drogon-docs/`.
- `AGENTS.md` is the durable agent instruction entrypoint. - `AGENTS.md` is the durable agent instruction entrypoint.
- `docs/MILESTONE_01.md` describes what has been implemented and what comes next. - `docs/MILESTONE_01.md` and later milestone files describe what has been
implemented and what comes next.
+1 -1
View File
@@ -9,7 +9,7 @@
- OTP email delivery through MailHog. - OTP email delivery through MailHog.
- Focused regression tests for technical endpoints and core validation helpers. - Focused regression tests for technical endpoints and core validation helpers.
- Local admin seed: `[email protected]` / `ChangeMe123!`, with forced password change flag. - Local admin seed: `[email protected]` / `ChangeMe123!`, with forced password change flag.
- Multipart report photo upload endpoint storing files under `uploads/reports/`. - Multipart report media upload endpoint storing files under `uploads/reports/`.
- PBKDF2-HMAC-SHA256 password hashes for credentials. Legacy SHA-256 hashes are intentionally rejected. - PBKDF2-HMAC-SHA256 password hashes for credentials. Legacy SHA-256 hashes are intentionally rejected.
## To do next ## To do next
+1 -2
View File
@@ -8,7 +8,7 @@
- Report ownership workflow with assigned operator and take endpoint. - Report ownership workflow with assigned operator and take endpoint.
- Valid report status transitions. - Valid report status transitions.
- Citizen report filters scoped to own reports. - Citizen report filters scoped to own reports.
- Report photo upload validation and metadata storage. - Report media upload validation and metadata storage.
## To do next ## To do next
- Add broader API tests once the frontend contracts stabilize. - Add broader API tests once the frontend contracts stabilize.
@@ -16,4 +16,3 @@
- Add moderation flow for abusive reports/messages. - Add moderation flow for abusive reports/messages.
- Add open-data exports and statistics refinements. - Add open-data exports and statistics refinements.
- Design CIE/SPID integration. - Design CIE/SPID integration.
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE report_photos
ADD COLUMN media_kind ENUM('IMAGE', 'VIDEO') NOT NULL DEFAULT 'IMAGE'
AFTER content_type;
+19 -13
View File
@@ -181,16 +181,19 @@ Json::Value ReportService::createReport(const auth::UserContext &user,
reportId, reportId,
user.id); user.id);
if (body["photos"].isArray()) const auto &mediaItems =
body["media"].isArray() ? body["media"] : body["photos"];
if (mediaItems.isArray())
{ {
for (const auto &photo : body["photos"]) for (const auto &item : mediaItems)
{ {
if (photo.isString() && !photo.asString().empty()) if (item.isString() && !item.asString().empty())
{ {
db->execSqlSync( db->execSqlSync(
"INSERT INTO report_photos (report_id, url) VALUES (?, ?)", "INSERT INTO report_photos (report_id, url, media_kind) "
"VALUES (?, ?, 'IMAGE')",
reportId, reportId,
photo.asString()); item.asString());
} }
} }
} }
@@ -281,9 +284,10 @@ Json::Value ReportService::reportDetail(const auth::UserContext &user,
response["history"] = repositories::resultToJson(db->execSqlSync( response["history"] = repositories::resultToJson(db->execSqlSync(
"SELECT * FROM report_status_history WHERE report_id = ? ORDER BY id", "SELECT * FROM report_status_history WHERE report_id = ? ORDER BY id",
reportId)); reportId));
response["photos"] = repositories::resultToJson(db->execSqlSync( response["media"] = repositories::resultToJson(db->execSqlSync(
"SELECT * FROM report_photos WHERE report_id = ? ORDER BY id", "SELECT * FROM report_photos WHERE report_id = ? ORDER BY id",
reportId)); reportId));
response["photos"] = response["media"];
return response; return response;
} }
@@ -482,7 +486,7 @@ Json::Value ReportService::addFeedback(const auth::UserContext &user,
return success(); return success();
} }
Json::Value ReportService::canAddPhoto(const auth::UserContext &user, Json::Value ReportService::canAddMedia(const auth::UserContext &user,
long long reportId) long long reportId)
{ {
if (!canAccessReport(user, reportId)) if (!canAccessReport(user, reportId))
@@ -495,22 +499,23 @@ Json::Value ReportService::canAddPhoto(const auth::UserContext &user,
reportId); reportId);
if (!count.empty() && count[0]["total"].as<int>() >= 5) if (!count.empty() && count[0]["total"].as<int>() >= 5)
{ {
return failure("photo_limit_reached"); return failure("media_limit_reached");
} }
auto response = success(); auto response = success();
response["remaining_photo_slots"] = response["remaining_media_slots"] =
5 - (count.empty() ? 0 : count[0]["total"].as<int>()); 5 - (count.empty() ? 0 : count[0]["total"].as<int>());
return response; return response;
} }
Json::Value ReportService::addPhoto(const auth::UserContext &user, Json::Value ReportService::addMedia(const auth::UserContext &user,
long long reportId, long long reportId,
const std::string &url, const std::string &url,
const std::string &originalFilename, const std::string &originalFilename,
const std::string &contentType, const std::string &contentType,
const std::string &mediaKind,
size_t sizeBytes) size_t sizeBytes)
{ {
auto allowed = canAddPhoto(user, reportId); auto allowed = canAddMedia(user, reportId);
if (!allowed.get("ok", false).asBool() || url.empty()) if (!allowed.get("ok", false).asBool() || url.empty())
{ {
return allowed; return allowed;
@@ -518,12 +523,13 @@ Json::Value ReportService::addPhoto(const auth::UserContext &user,
drogon::app().getDbClient()->execSqlSync( drogon::app().getDbClient()->execSqlSync(
"INSERT INTO report_photos " "INSERT INTO report_photos "
"(report_id, url, original_filename, content_type, size_bytes) " "(report_id, url, original_filename, content_type, media_kind, size_bytes) "
"VALUES (?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?)",
reportId, reportId,
url, url,
originalFilename, originalFilename,
contentType, contentType,
mediaKind,
static_cast<unsigned long long>(sizeBytes)); static_cast<unsigned long long>(sizeBytes));
return success(); return success();
} }
+3 -2
View File
@@ -31,12 +31,13 @@ class ReportService
Json::Value addFeedback(const auth::UserContext &user, Json::Value addFeedback(const auth::UserContext &user,
long long reportId, long long reportId,
const Json::Value &body); const Json::Value &body);
Json::Value canAddPhoto(const auth::UserContext &user, long long reportId); Json::Value canAddMedia(const auth::UserContext &user, long long reportId);
Json::Value addPhoto(const auth::UserContext &user, Json::Value addMedia(const auth::UserContext &user,
long long reportId, long long reportId,
const std::string &url, const std::string &url,
const std::string &originalFilename, const std::string &originalFilename,
const std::string &contentType, const std::string &contentType,
const std::string &mediaKind,
size_t sizeBytes); size_t sizeBytes);
}; };
} // namespace visione::services } // namespace visione::services