diff --git a/README.md b/README.md index 1a00568..a08dbff 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ cmake --build build The API listens on `http://localhost:5555`. 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: diff --git a/config.json b/config.json index a07f642..45324ad 100644 --- a/config.json +++ b/config.json @@ -39,7 +39,7 @@ "handle_sig_term": true, "use_gzip": true, "use_brotli": false, - "client_max_body_size": "1M", + "client_max_body_size": "50M", "client_max_memory_body_size": "256K" }, "plugins": [], diff --git a/controllers/ReportController.cc b/controllers/ReportController.cc index 7288acb..d4fd1e4 100644 --- a/controllers/ReportController.cc +++ b/controllers/ReportController.cc @@ -63,25 +63,30 @@ void sendService(Json::Value body, callback(visione::http::json(std::move(body), status)); } -bool isAllowedImage(const drogon::HttpFile &file) +struct MediaType { - const auto type = file.getContentType(); - return type == drogon::CT_IMAGE_JPG || type == drogon::CT_IMAGE_PNG || - type == drogon::CT_IMAGE_WEBP; -} + std::string kind; + std::string contentType; +}; -std::string imageContentType(const drogon::HttpFile &file) +std::optional allowedMediaType(const drogon::HttpFile &file) { switch (file.getContentType()) { case drogon::CT_IMAGE_JPG: - return "image/jpeg"; + return MediaType{"IMAGE", "image/jpeg"}; case drogon::CT_IMAGE_PNG: - return "image/png"; + return MediaType{"IMAGE", "image/png"}; 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: - return "application/octet-stream"; + return std::nullopt; } } } // namespace @@ -226,7 +231,7 @@ void ReportController::addFeedback( sendService(service.addFeedback(*user, reportId, body), std::move(callback)); } -void ReportController::uploadPhotos( +void ReportController::uploadMedia( const drogon::HttpRequestPtr &req, std::function &&callback, long long reportId) @@ -254,7 +259,7 @@ void ReportController::uploadPhotos( std::filesystem::path(reportsPath) / std::to_string(reportId); visione::services::ReportService service; - auto allowed = service.canAddPhoto(*user, reportId); + auto allowed = service.canAddMedia(*user, reportId); if (!allowed.get("ok", false).asBool()) { callback(visione::http::json(std::move(allowed), drogon::k403Forbidden)); @@ -262,16 +267,16 @@ void ReportController::uploadPhotos( } if (parser.getFiles().size() > - static_cast(allowed.get("remaining_photo_slots", 0).asInt())) + static_cast(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; } 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)); return; } @@ -281,10 +286,12 @@ void ReportController::uploadPhotos( Json::Value response; response["ok"] = true; + response["media"] = Json::arrayValue; response["photos"] = Json::arrayValue; for (const auto &file : parser.getFiles()) { + const auto mediaType = allowedMediaType(file).value(); auto extension = std::string(file.getFileExtension()); if (!extension.empty() && extension.front() != '.') { @@ -294,24 +301,30 @@ void ReportController::uploadPhotos( const auto fullPath = reportDir / fileName; if (file.saveAs(fullPath.string()) != 0) { - callback(visione::http::error("photo_save_failed", + callback(visione::http::error("media_save_failed", drogon::k500InternalServerError)); return; } const auto url = publicBasePath + "/reports/" + std::to_string(reportId) + "/" + fileName; - auto saved = service.addPhoto(*user, + auto saved = service.addMedia(*user, reportId, url, file.getFileName(), - imageContentType(file), + mediaType.contentType, + mediaType.kind, file.fileLength()); if (!saved.get("ok", false).asBool()) { callback(visione::http::json(std::move(saved), drogon::k403Forbidden)); return; } + Json::Value item; + item["url"] = url; + item["kind"] = mediaType.kind; + item["content_type"] = mediaType.contentType; + response["media"].append(item); response["photos"].append(url); } diff --git a/controllers/ReportController.h b/controllers/ReportController.h index 24ee23c..e6d6a3b 100644 --- a/controllers/ReportController.h +++ b/controllers/ReportController.h @@ -27,7 +27,10 @@ class ReportController : public drogon::HttpController ADD_METHOD_TO(ReportController::addFeedback, "/api/v1/reports/{1}/feedback", 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", drogon::Post); METHOD_LIST_END @@ -63,7 +66,7 @@ class ReportController : public drogon::HttpController const drogon::HttpRequestPtr &req, std::function &&callback, long long reportId); - void uploadPhotos( + void uploadMedia( const drogon::HttpRequestPtr &req, std::function &&callback, long long reportId); diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index b9c33ee..76e2768 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -30,7 +30,9 @@ Authorization: Bearer - `GET /reports/{id}/messages`: list report messages. - `POST /reports/{id}/assignments`: assign a team. - `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 - `GET|POST /admin/categories` diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 54d8b14..21a9705 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -14,7 +14,8 @@ Important tables: - `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 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. - `feedback`: citizen rating and comment after resolution. - `reward_events`: indirect gamification points ledger. diff --git a/docs/LLM_CONTEXT.md b/docs/LLM_CONTEXT.md index 8327aeb..591a8fd 100644 --- a/docs/LLM_CONTEXT.md +++ b/docs/LLM_CONTEXT.md @@ -1,7 +1,8 @@ # 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 photos. +Citizens create reports with category, position, address, description and media +attachments. Operators manage reports, assign teams, update status and chat with citizens. 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` and `https://drogonframework.github.io/drogon-docs/`. - `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. diff --git a/docs/MILESTONE_01.md b/docs/MILESTONE_01.md index f4e2ed1..73eaf22 100644 --- a/docs/MILESTONE_01.md +++ b/docs/MILESTONE_01.md @@ -9,7 +9,7 @@ - OTP email delivery through MailHog. - Focused regression tests for technical endpoints and core validation helpers. - Local admin seed: `admin@visione.local` / `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. ## To do next diff --git a/docs/MILESTONE_02.md b/docs/MILESTONE_02.md index ef88cab..558baf2 100644 --- a/docs/MILESTONE_02.md +++ b/docs/MILESTONE_02.md @@ -8,7 +8,7 @@ - Report ownership workflow with assigned operator and take endpoint. - Valid report status transitions. - Citizen report filters scoped to own reports. -- Report photo upload validation and metadata storage. +- Report media upload validation and metadata storage. ## To do next - Add broader API tests once the frontend contracts stabilize. @@ -16,4 +16,3 @@ - Add moderation flow for abusive reports/messages. - Add open-data exports and statistics refinements. - Design CIE/SPID integration. - diff --git a/migrations/005_report_media.sql b/migrations/005_report_media.sql new file mode 100644 index 0000000..68d00fa --- /dev/null +++ b/migrations/005_report_media.sql @@ -0,0 +1,3 @@ +ALTER TABLE report_photos + ADD COLUMN media_kind ENUM('IMAGE', 'VIDEO') NOT NULL DEFAULT 'IMAGE' + AFTER content_type; diff --git a/services/ReportService.cc b/services/ReportService.cc index 29b3ee7..f9c2b62 100644 --- a/services/ReportService.cc +++ b/services/ReportService.cc @@ -181,16 +181,19 @@ Json::Value ReportService::createReport(const auth::UserContext &user, reportId, 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( - "INSERT INTO report_photos (report_id, url) VALUES (?, ?)", + "INSERT INTO report_photos (report_id, url, media_kind) " + "VALUES (?, ?, 'IMAGE')", reportId, - photo.asString()); + item.asString()); } } } @@ -281,9 +284,10 @@ Json::Value ReportService::reportDetail(const auth::UserContext &user, response["history"] = repositories::resultToJson(db->execSqlSync( "SELECT * FROM report_status_history WHERE report_id = ? ORDER BY id", reportId)); - response["photos"] = repositories::resultToJson(db->execSqlSync( + response["media"] = repositories::resultToJson(db->execSqlSync( "SELECT * FROM report_photos WHERE report_id = ? ORDER BY id", reportId)); + response["photos"] = response["media"]; return response; } @@ -482,7 +486,7 @@ Json::Value ReportService::addFeedback(const auth::UserContext &user, return success(); } -Json::Value ReportService::canAddPhoto(const auth::UserContext &user, +Json::Value ReportService::canAddMedia(const auth::UserContext &user, long long reportId) { if (!canAccessReport(user, reportId)) @@ -495,22 +499,23 @@ Json::Value ReportService::canAddPhoto(const auth::UserContext &user, reportId); if (!count.empty() && count[0]["total"].as() >= 5) { - return failure("photo_limit_reached"); + return failure("media_limit_reached"); } auto response = success(); - response["remaining_photo_slots"] = + response["remaining_media_slots"] = 5 - (count.empty() ? 0 : count[0]["total"].as()); return response; } -Json::Value ReportService::addPhoto(const auth::UserContext &user, +Json::Value ReportService::addMedia(const auth::UserContext &user, long long reportId, const std::string &url, const std::string &originalFilename, const std::string &contentType, + const std::string &mediaKind, size_t sizeBytes) { - auto allowed = canAddPhoto(user, reportId); + auto allowed = canAddMedia(user, reportId); if (!allowed.get("ok", false).asBool() || url.empty()) { return allowed; @@ -518,12 +523,13 @@ Json::Value ReportService::addPhoto(const auth::UserContext &user, drogon::app().getDbClient()->execSqlSync( "INSERT INTO report_photos " - "(report_id, url, original_filename, content_type, size_bytes) " - "VALUES (?, ?, ?, ?, ?)", + "(report_id, url, original_filename, content_type, media_kind, size_bytes) " + "VALUES (?, ?, ?, ?, ?, ?)", reportId, url, originalFilename, contentType, + mediaKind, static_cast(sizeBytes)); return success(); } diff --git a/services/ReportService.h b/services/ReportService.h index bcb5dd8..4481ade 100644 --- a/services/ReportService.h +++ b/services/ReportService.h @@ -31,12 +31,13 @@ class ReportService Json::Value addFeedback(const auth::UserContext &user, long long reportId, const Json::Value &body); - Json::Value canAddPhoto(const auth::UserContext &user, long long reportId); - Json::Value addPhoto(const auth::UserContext &user, + Json::Value canAddMedia(const auth::UserContext &user, long long reportId); + Json::Value addMedia(const auth::UserContext &user, long long reportId, const std::string &url, const std::string &originalFilename, const std::string &contentType, + const std::string &mediaKind, size_t sizeBytes); }; } // namespace visione::services