diff --git a/config.json b/config.json index d5de575..a07f642 100644 --- a/config.json +++ b/config.json @@ -40,7 +40,7 @@ "use_gzip": true, "use_brotli": false, "client_max_body_size": "1M", - "client_max_memory_body_size": "64K" + "client_max_memory_body_size": "256K" }, "plugins": [], "custom_config": { @@ -55,6 +55,10 @@ "smtp_port": 1025, "from": "noreply@visione.local" }, + "uploads": { + "public_base_path": "/uploads", + "reports_path": "uploads/reports" + }, "cors": { "allowed_origin": "http://localhost:5173" } diff --git a/controllers/ReportController.cc b/controllers/ReportController.cc index ec2c792..4f067ab 100644 --- a/controllers/ReportController.cc +++ b/controllers/ReportController.cc @@ -4,6 +4,9 @@ #include "common/Http.h" #include "services/ReportService.h" +#include + +#include #include using drogon::HttpResponsePtr; @@ -174,3 +177,65 @@ void ReportController::addFeedback( sendService(service.addFeedback(*user, reportId, body), std::move(callback)); } +void ReportController::uploadPhotos( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId) +{ + auto user = requireUser(req, callback); + if (!user) + { + return; + } + + drogon::MultiPartParser parser; + if (parser.parse(req) != 0 || parser.getFiles().empty()) + { + callback(visione::http::error("invalid_multipart", drogon::k400BadRequest)); + return; + } + + const auto &uploadConfig = drogon::app().getCustomConfig()["uploads"]; + const auto reportsPath = + uploadConfig.get("reports_path", "uploads/reports").asString(); + const auto publicBasePath = + uploadConfig.get("public_base_path", "/uploads").asString(); + + const auto reportDir = + std::filesystem::path(reportsPath) / std::to_string(reportId); + std::filesystem::create_directories(reportDir); + + visione::services::ReportService service; + Json::Value response; + response["ok"] = true; + response["photos"] = Json::arrayValue; + + for (const auto &file : parser.getFiles()) + { + auto extension = std::string(file.getFileExtension()); + if (!extension.empty() && extension.front() != '.') + { + extension = "." + extension; + } + const auto fileName = drogon::utils::getUuid() + extension; + const auto fullPath = reportDir / fileName; + if (file.saveAs(fullPath.string()) != 0) + { + callback(visione::http::error("photo_save_failed", + drogon::k500InternalServerError)); + return; + } + + const auto url = publicBasePath + "/reports/" + std::to_string(reportId) + + "/" + fileName; + auto saved = service.addPhoto(*user, reportId, url); + if (!saved.get("ok", false).asBool()) + { + callback(visione::http::json(std::move(saved), drogon::k403Forbidden)); + return; + } + response["photos"].append(url); + } + + callback(visione::http::json(std::move(response), drogon::k201Created)); +} diff --git a/controllers/ReportController.h b/controllers/ReportController.h index 83df6ac..9d54ab1 100644 --- a/controllers/ReportController.h +++ b/controllers/ReportController.h @@ -24,6 +24,9 @@ class ReportController : public drogon::HttpController ADD_METHOD_TO(ReportController::addFeedback, "/api/v1/reports/{1}/feedback", drogon::Post); + ADD_METHOD_TO(ReportController::uploadPhotos, + "/api/v1/reports/{1}/photos", + drogon::Post); METHOD_LIST_END void create(const drogon::HttpRequestPtr &req, @@ -53,5 +56,8 @@ class ReportController : public drogon::HttpController const drogon::HttpRequestPtr &req, std::function &&callback, long long reportId); + void uploadPhotos( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId); }; - diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index c7dc13e..3ff3ee5 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -28,10 +28,10 @@ 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 one or more multipart files for a report. ## Admin Reference Data - `GET|POST /admin/categories` - `GET|POST /admin/areas` - `GET|POST /admin/teams` - `GET /admin/stats/overview` - diff --git a/docs/MILESTONE_01.md b/docs/MILESTONE_01.md index ea105f3..9feff25 100644 --- a/docs/MILESTONE_01.md +++ b/docs/MILESTONE_01.md @@ -9,6 +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/`. ## To do next - Harden password hashing with Argon2id or bcrypt before production. diff --git a/services/ReportService.cc b/services/ReportService.cc index b22872d..264a4ea 100644 --- a/services/ReportService.cc +++ b/services/ReportService.cc @@ -372,5 +372,20 @@ Json::Value ReportService::addFeedback(const auth::UserContext &user, body.get("comment", "").asString()); return success(); } -} // namespace visione::services +Json::Value ReportService::addPhoto(const auth::UserContext &user, + long long reportId, + const std::string &url) +{ + if (!canAccessReport(user, reportId) || url.empty()) + { + return failure("forbidden"); + } + + drogon::app().getDbClient()->execSqlSync( + "INSERT INTO report_photos (report_id, url) VALUES (?, ?)", + reportId, + url); + return success(); +} +} // namespace visione::services diff --git a/services/ReportService.h b/services/ReportService.h index b3dcf73..582c87e 100644 --- a/services/ReportService.h +++ b/services/ReportService.h @@ -30,6 +30,8 @@ class ReportService Json::Value addFeedback(const auth::UserContext &user, long long reportId, const Json::Value &body); + Json::Value addPhoto(const auth::UserContext &user, + long long reportId, + const std::string &url); }; } // namespace visione::services -