diff --git a/common/GeoJson.cc b/common/GeoJson.cc index 02f69ec..9e4826d 100644 --- a/common/GeoJson.cc +++ b/common/GeoJson.cc @@ -1,5 +1,6 @@ #include "common/GeoJson.h" +#include #include namespace visione::geojson @@ -11,17 +12,12 @@ struct Point double x; 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) { return false; @@ -41,10 +37,27 @@ bool pointInPolygon(double latitude, double longitude, const Json::Value &geojso const auto x = longitude; const auto y = latitude; bool inside = false; + constexpr double epsilon = 1e-9; for (size_t i = 0, j = points.size() - 1; i < points.size(); j = i++) { const auto &pi = points[i]; 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 = ((pi.y > y) != (pj.y > y)) && (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; } -} // 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 diff --git a/common/GeoJson.h b/common/GeoJson.h index 45a817e..21a1305 100644 --- a/common/GeoJson.h +++ b/common/GeoJson.h @@ -5,5 +5,6 @@ namespace visione::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 - diff --git a/controllers/AdminController.cc b/controllers/AdminController.cc index eca51a6..9cedba7 100644 --- a/controllers/AdminController.cc +++ b/controllers/AdminController.cc @@ -73,6 +73,10 @@ void sendAdmin(Json::Value body, { status = drogon::k422UnprocessableEntity; } + else if (error == "area_outside_allowed_boundary") + { + status = drogon::k422UnprocessableEntity; + } callback(visione::http::json(std::move(body), status)); } } // namespace diff --git a/controllers/ReportController.cc b/controllers/ReportController.cc index 30cfad3..c4a5c16 100644 --- a/controllers/ReportController.cc +++ b/controllers/ReportController.cc @@ -79,6 +79,10 @@ void sendService(Json::Value body, { status = drogon::k422UnprocessableEntity; } + else if (error == "coordinates_outside_allowed_boundary") + { + status = drogon::k422UnprocessableEntity; + } callback(visione::http::json(std::move(body), status)); } diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index e5d4320..6d3e727 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -51,6 +51,7 @@ Authorization: Bearer public external-team summary link - `GET /open-data/reports` anonymized reports in `RESOLVED` or `CLOSED` + filtered to Bari municipal boundary ## User Reference / Personal - `GET /report-categories` @@ -63,6 +64,7 @@ Authorization: Bearer ## Report Endpoints - `POST /reports` create citizen report, optional inline `media`, area inferred by GeoJSON + coordinates must stay inside allowed Bari municipal boundary - `GET /reports` citizen sees own reports; operator/admin see all - `GET /reports/{id}` @@ -111,6 +113,7 @@ Authorization: Bearer - `PATCH /admin/categories/{id}` - `POST /admin/areas` - `PATCH /admin/areas/{id}` + area polygon must stay inside allowed Bari municipal boundary - `POST /admin/teams` - `PATCH /admin/teams/{id}` - `GET /admin/users` @@ -153,3 +156,5 @@ Authorization: Bearer - `assignments` is historical external team linkage only. - Feedback does not auto-close report. - Public intervention summary exposes citizen phone by design, but not email or full surname. +- Report coordinates are constrained to Bari municipal boundary. +- Admin-created area polygons must stay inside Bari municipal boundary. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 930e595..09a297b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -47,6 +47,8 @@ Effective permission helpers: - `take` exists for `OPEN -> UNDER_REVIEW` without team assignment. - External team link is public, revocable, tied to current assignment, hashed in `intervention_access_links`. +- Spatial scope is constrained to Bari municipal boundary from + `allowed_boundaries.code = 'BARI'`. ## Status Machine - Allowed statuses: `OPEN`, `UNDER_REVIEW`, `ASSIGNED`, `IN_PROGRESS`, @@ -68,6 +70,7 @@ Effective permission helpers: - `moderation_flags` - `report_category_team_categories` - `intervention_access_links` +- `allowed_boundaries` ## HTTP/Error Conventions - `401`: missing/invalid auth @@ -94,4 +97,5 @@ Output path: hardcoded allowlist, not user input. - Public intervention summary currently excludes email and full surname, but still exposes citizen phone by design. +- Allowed-area containment for admin areas is vertex-based, simple not strict GIS-hard. - No frontend doc set here beyond API/OpenAPI docs. diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 76b6693..abfdfd9 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -36,6 +36,8 @@ Schema truth is `migrations/*.sql`. This file is compact human map of current DB - `report_category_team_categories` explicit compatibility mapping: report category -> team category +- `allowed_boundaries` + active allowed municipal containment geometry, currently seeded with `BARI` ## Report Domain - `reports` @@ -101,3 +103,4 @@ Schema truth is `migrations/*.sql`. This file is compact human map of current DB - No dedicated table for external team personnel - No route-optimization/routing tables - No audit table for intervention link access beyond `last_accessed_at` +- Containment check for admin area polygons is simple vertex-inside check diff --git a/docs/LLM_CONTEXT.md b/docs/LLM_CONTEXT.md index 986af8d..80411b4 100644 --- a/docs/LLM_CONTEXT.md +++ b/docs/LLM_CONTEXT.md @@ -40,9 +40,10 @@ public revocable link generated by operator/admin. ### Citizen flow 1. Register/login. 2. `POST /reports` with category, coordinates, address, description, optional media array. -3. Backend infers `area_id` by GeoJSON polygon match when possible. -4. Report starts `OPEN`, status history row inserted. -5. Citizen can: +3. Backend first rejects coordinates outside Bari municipal boundary. +4. Backend infers `area_id` by GeoJSON polygon match when possible. +5. Report starts `OPEN`, status history row inserted. +6. Citizen can: - list own reports - read detail/history/media - chat on own report @@ -72,6 +73,7 @@ public revocable link generated by operator/admin. ### Admin flow - Create/update categories, areas, teams +- Area GeoJSON must stay inside Bari municipal boundary - List users, edit roles, ban/unban - Read stats - Review moderation queue and resolve/dismiss flags @@ -89,6 +91,13 @@ public revocable link generated by operator/admin. - `GET /interventions/{token}` returns public summary for field team: report data, location, media, assigned team, masked citizen identity, phone. +## Spatial Boundary Rule +- `allowed_boundaries` stores active municipal allowed area. +- Current seed uses `BARI`. +- Report coordinates outside boundary are rejected. +- Admin area polygons outside boundary are rejected. +- Open data output is filtered again by same boundary. + ## Current Notifications / Rewards / Moderation - Notifications: local in-app only. - Reward points: awarded once per `(report_id, reason)`. diff --git a/docs/PROJECT_SPEC.md b/docs/PROJECT_SPEC.md index 61dd9e8..b52ae42 100644 --- a/docs/PROJECT_SPEC.md +++ b/docs/PROJECT_SPEC.md @@ -38,6 +38,7 @@ moderate abuse, and inspect operational stats. - create report with category, coordinates, address, description - optional inline media on creation - later media upload up to total 5 files +- coordinates must stay inside Bari municipal boundary - area inferred by GeoJSON polygon match - citizen report list/detail/history/media - operator queues: @@ -79,6 +80,7 @@ moderate abuse, and inspect operational stats. ### Admin - create/update categories - create/update areas + area polygons must stay inside Bari municipal boundary - create/update teams - list users, update roles, ban/unban - global stats including area/category/team resolution aggregates @@ -87,6 +89,7 @@ moderate abuse, and inspect operational stats. - active area list - anonymized resolved/closed open-data export - public intervention token endpoint +- open-data output filtered to Bari municipal boundary ## Current Workflow Summary ### Citizen diff --git a/migrations/008_allowed_boundaries_bari.sql b/migrations/008_allowed_boundaries_bari.sql new file mode 100644 index 0000000..093d3ea --- /dev/null +++ b/migrations/008_allowed_boundaries_bari.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS allowed_boundaries ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(32) NOT NULL, + name VARCHAR(160) NOT NULL, + boundary_geojson JSON NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY allowed_boundaries_code_unique (code) +); + +INSERT IGNORE INTO allowed_boundaries (code, name, boundary_geojson, active) VALUES ( + 'BARI', + 'Comune di Bari', + CAST('{"type":"Polygon","coordinates":[[[16.860374039921055,41.02812984947979],[16.86085505796236,41.02649094418411],[16.860417134525388,41.023129289095166],[16.85086496198963,41.01658268089827],[16.84963974271688,41.0160706318868],[16.845547851665494,41.014529174355125],[16.8449454661771,41.01488081741504],[16.843269437529745,41.0168106683665],[16.841227327527385,41.01945604996147],[16.83895359472939,41.02837441704182],[16.838101773468118,41.034028635149674],[16.838097033950998,41.03468571583374],[16.83813246717045,41.03729682265392],[16.838197963420264,41.038268476686554],[16.838412581334655,41.03974273653888],[16.840007960556985,41.04974354220538],[16.840663817347338,41.05095592502575],[16.84151693475956,41.05252591217294],[16.841868660361147,41.05303263020327],[16.84201069502807,41.05387309091224],[16.842315257901994,41.05627234068723],[16.84226001417035,41.05682941970235],[16.841361091638802,41.05920312359524],[16.837561927974534,41.06538303249887],[16.837159383522607,41.065918897017944],[16.83372422364001,41.066567795392785],[16.832378308537084,41.06647092934481],[16.830045403472873,41.0661221533165],[16.829716019011663,41.0683668681536],[16.830029029682986,41.06907195202555],[16.82960919000224,41.071878398719285],[16.829296075686507,41.072019015541855],[16.828743033207186,41.07226600093673],[16.82724178180576,41.07293542177442],[16.827017745540164,41.07292599283786],[16.826695093328762,41.07291205944086],[16.82647130666826,41.072929605518944],[16.82613387717016,41.072966162100464],[16.82592026281926,41.0729942552182],[16.825114518256363,41.07319101850856],[16.824140113270442,41.07349376403511],[16.821144665897567,41.074494608400464],[16.820253639204697,41.07502329424965],[16.81859259930014,41.07647273959381],[16.816127962787732,41.080257700952444],[16.816712900388822,41.083393636600995],[16.81698089229272,41.08404560417174],[16.80971947837104,41.085773647561254],[16.808707807653505,41.08581565180717],[16.805643298787366,41.08644294244137],[16.804520242872435,41.08684787690888],[16.804317966197065,41.08699661573333],[16.80409448821796,41.08716704306904],[16.80425438697132,41.087536260117794],[16.80477957973148,41.08797729716111],[16.805722304794802,41.088529319679246],[16.80597788224059,41.08867833823609],[16.80618550284904,41.088799135440865],[16.806605826258885,41.08905837958063],[16.80778023186532,41.089833004277956],[16.80854558718666,41.09034311582546],[16.808879627716326,41.090851147554034],[16.809216137467473,41.093999577238165],[16.80889077105267,41.096333951204834],[16.808536303728427,41.09772564309586],[16.80825083884334,41.09819945016573],[16.7995469569034,41.110079232542894],[16.792214257776095,41.116709890993626],[16.790553175113114,41.11819720464861],[16.78665328180153,41.11856834105808],[16.783769874462248,41.11893072070429],[16.770080540315885,41.1126000064912],[16.76872980678085,41.1120438795179],[16.76758809748983,41.11159501560659],[16.766311046301745,41.11143422514403],[16.764917643457448,41.1114207636613],[16.761285525779424,41.1115635528085],[16.7608168494921,41.111824741660605],[16.760560588895945,41.112071535005654],[16.75966201764231,41.11264526027249],[16.757796527554078,41.113743343196035],[16.757080343000446,41.114124753174025],[16.756312975544315,41.1143251877071],[16.753937742255378,41.11471098446257],[16.75269031549237,41.114750468541324],[16.749972578047384,41.11483098266584],[16.750220054411205,41.11501216669816],[16.750643339714717,41.1156987762845],[16.751348154179468,41.117607916368165],[16.751506624054084,41.11821798225715],[16.75106460112454,41.12352915413235],[16.74624363986053,41.12670297549276],[16.741957547562887,41.13047922873242],[16.73812565863042,41.134678984707826],[16.738157749484337,41.13500071046767],[16.738358404455923,41.13541450821903],[16.73863412485707,41.13585922330977],[16.739849100417338,41.13767997606425],[16.730854613404773,41.1445001586495],[16.729693250017064,41.14517238081903],[16.72914819161267,41.1453441365846],[16.72878630929254,41.14542929696564],[16.72843570444542,41.14550919636345],[16.727588687753126,41.1458452802942],[16.72545020830767,41.14754160021886],[16.723575217482065,41.14907615501169],[16.723210170547212,41.14961361850982],[16.723195617450397,41.14964159183473],[16.72326324464167,41.15321338892672],[16.717279466508685,41.15480901996406],[16.717427889135912,41.15515890085865],[16.717954580849813,41.15616706442467],[16.724683269222957,41.15765214261939],[16.729690135081025,41.15829038794544],[16.73120511847411,41.16463452914071],[16.731534635762486,41.1665683003368],[16.73160533609631,41.1697643982715],[16.733296421282922,41.16968236902949],[16.738581726020545,41.16909352864561],[16.73997034825747,41.16890517960862],[16.755566168544444,41.16512802332243],[16.755758703429606,41.16506999365929],[16.761112684841844,41.16300893648061],[16.776843374546782,41.160463584950705],[16.779135953620045,41.159720759665376],[16.77931670144429,41.15961400947751],[16.786981248006764,41.15302443331703],[16.79172899522457,41.14866399684703],[16.794526775307546,41.1471464867],[16.801264331488753,41.1438355173234],[16.802421433061287,41.14333380400394],[16.809634877705935,41.14032729985337],[16.82824138727643,41.1368162332474],[16.829967221565898,41.13654525468816],[16.83078543356337,41.13647129481102],[16.831826254675068,41.136467568524154],[16.83248132076565,41.13647223161319],[16.83349528732561,41.136517561848926],[16.833921254927073,41.13654688998017],[16.83437563823751,41.13661476053176],[16.838770346196995,41.13793558650853],[16.83996426230241,41.13878724140275],[16.845900013575374,41.1362595721936],[16.852001567314762,41.129440020271176],[16.85227331103624,41.12927518363446],[16.85434752405822,41.12876177163212],[16.855190275453918,41.1286207249052],[16.855832906686164,41.12862161143023],[16.858262607625395,41.12871575189577],[16.85973372699767,41.12880368713501],[16.86308493815093,41.12958567896683],[16.865746640570684,41.130322731814296],[16.870892014187035,41.13570670910537],[16.870939797386416,41.136400669900645],[16.871167743803948,41.1398284858357],[16.866847553746897,41.14299318320936],[16.866683377498166,41.14308768748682],[16.86587723578296,41.14321281006195],[16.864941784597768,41.143277089971754],[16.863558591771998,41.14335412575683],[16.857102102039214,41.143676032437384],[16.85538913325187,41.14395554021982],[16.86321895344672,41.143573137857736],[16.866108481342522,41.143428586314315],[16.866719593194315,41.143388838116515],[16.867102946584406,41.143360240827846],[16.867907208165942,41.14321949432243],[16.86822054178435,41.14315297301374],[16.8682865340456,41.14313269245718],[16.86910017350902,41.14287208354822],[16.86936897087629,41.14275689095533],[16.869628912985693,41.14264230383996],[16.869967721295986,41.142490807826796],[16.870283187874357,41.14234316400119],[16.87070219610927,41.142091683516796],[16.870985199626517,41.14189678756882],[16.871581767208983,41.14136544456863],[16.87176980019509,41.14117257824267],[16.872025694775942,41.14087608371212],[16.872162488609302,41.14070248579609],[16.872238124666733,41.140564582452996],[16.872377057956676,41.14030986699407],[16.872420775915018,41.14020339821574],[16.872552443065455,41.1397152667794],[16.872610858499602,41.13896452333908],[16.87260678005339,41.138337283569626],[16.872500638430648,41.136021184231524],[16.872491510324373,41.13589585797434],[16.872464940114632,41.13572449906237],[16.872232563229158,41.134510179336296],[16.872170851299504,41.134219781445765],[16.87214134100576,41.134122847183185],[16.872032426990476,41.13393465891905],[16.871947124750587,41.13384381049225],[16.87184330323529,41.13354956058465],[16.87147274220043,41.13079056457789],[16.871484268499376,41.13068855917591],[16.87150706082882,41.13053179844312],[16.87267618696732,41.1259822914674],[16.872723651167984,41.12588231295047],[16.87712707550647,41.123444928745776],[16.877819203242364,41.12306667271209],[16.87905208987702,41.12259721300556],[16.8792533484412,41.12252713122944],[16.879479140652062,41.12245086387399],[16.8904752833854,41.11877403682196],[16.89278175256206,41.11814496074559],[16.916038429614456,41.11242989222315],[16.928565253847086,41.111823843842686],[16.928816152256896,41.11175923391908],[16.929292462623685,41.111616032890645],[16.930509978622084,41.11044761072346],[16.93863656511355,41.10573243861498],[16.94838346023062,41.10217720884369],[16.96064170794006,41.09788876808378],[16.97169512932349,41.0961726825173],[16.98130001093131,41.09482192280713],[16.982123678637358,41.094676777507296],[16.984758215219454,41.09411968867017],[16.985056432442192,41.093907715856325],[16.988939508134397,41.09208266402053],[16.99762254955192,41.08813196576687],[17.008387265901764,41.08568132017749],[17.011966158325205,41.08519473191001],[17.015619911722787,41.08478600138602],[17.01577404022742,41.08480669670999],[17.016907462260647,41.085008452922466],[17.017538930299715,41.085189131447315],[17.01766702626244,41.08519140579579],[17.030708610973157,41.08316390922615],[17.033016419940488,41.082446460471104],[17.033379185591247,41.08220509505829],[17.02628746369414,41.07427089687486],[17.01459759357838,41.07759313212777],[16.99162825395144,41.08402546724872],[16.984597913706452,41.0859981920555],[16.97880743096678,41.0876591710624],[16.96178238925969,41.09203375008453],[16.959957207779627,41.09247555123579],[16.95574270678771,41.09326782588098],[16.946137597365233,41.09329619985123],[16.929448751699095,41.08414341528107],[16.92566812703412,41.08178722691269],[16.924094820612876,41.078976773645884],[16.923784350986065,41.076924574124476],[16.918119104310904,41.074806496631695],[16.91233797610389,41.07332589276791],[16.9109047422569,41.07299984631195],[16.907056115811187,41.0720894027183],[16.90534170962195,41.07083581489816],[16.899222021105892,41.064713199669924],[16.8992353121471,41.06437941017213],[16.899437612834415,41.06272808658039],[16.898383625482385,41.06280979227398],[16.8952114817728,41.0621064152502],[16.893872080839998,41.061865890693284],[16.89291691184468,41.06175630293735],[16.891819760140162,41.06170147567493],[16.890749137883656,41.06169428918586],[16.884979674757794,41.061803838300094],[16.882802520243064,41.06198522531695],[16.874188546969144,41.0594155975232],[16.872940788405014,41.05733771113167],[16.87286128935391,41.056650437769896],[16.873438731975522,41.055643602297714],[16.87432906907614,41.054619748488115],[16.874937062273318,41.0530260283722],[16.87333241086042,41.04586712934338],[16.872592301588824,41.04586403231307],[16.87162984454777,41.045988671320316],[16.867659607936442,41.04563174531287],[16.864084288938876,41.045049633738465],[16.863817684226085,41.04436170809352],[16.863656244824213,41.04214613507806],[16.861427973630796,41.03234438477908],[16.860374039921055,41.02812984947979]]]}' + AS JSON), + TRUE +); diff --git a/services/AdminService.cc b/services/AdminService.cc index ec3c6da..de41d2c 100644 --- a/services/AdminService.cc +++ b/services/AdminService.cc @@ -1,5 +1,6 @@ #include "services/AdminService.h" +#include "common/GeoJson.h" #include "common/Validation.h" #include "repositories/Sql.h" #include "services/UserService.h" @@ -7,6 +8,7 @@ #include #include +#include namespace visione::services { @@ -87,6 +89,35 @@ bool isActiveField(const Json::Value &body) { return body.isMember("active") && body["active"].isBool(); } + +std::optional 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()); + 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, @@ -119,6 +150,10 @@ Json::Value AdminService::createArea(const auth::UserContext &user, { return failure("invalid_area"); } + if (!isAreaInsideAllowedBoundary(body["boundary_geojson"])) + { + return failure("area_outside_allowed_boundary"); + } Json::StreamWriterBuilder writer; drogon::app().getDbClient()->execSqlSync( @@ -410,6 +445,10 @@ Json::Value AdminService::updateArea(const auth::UserContext &user, } 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 = ?", diff --git a/services/ReportService.cc b/services/ReportService.cc index 2f93580..a2c2966 100644 --- a/services/ReportService.cc +++ b/services/ReportService.cc @@ -75,6 +75,34 @@ std::string maskedLastName(const std::string &lastName) return std::string(1, static_cast(std::toupper(lastName.front()))) + "."; } +std::optional 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()); + 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 findArea(double latitude, double longitude) { const auto result = drogon::app().getDbClient()->execSqlSync( @@ -89,7 +117,7 @@ std::optional findArea(double latitude, double longitude) const auto raw = row["geojson"].as(); std::istringstream stream(raw); if (Json::parseFromStream(builder, stream, &geojson, &errors) && - geojson::pointInPolygon(latitude, longitude, geojson)) + geojson::pointInGeometry(latitude, longitude, geojson)) { return row["id"].as(); } @@ -397,6 +425,10 @@ Json::Value ReportService::createReport(const auth::UserContext &user, const auto latitude = body["latitude"].asDouble(); const auto longitude = body["longitude"].asDouble(); + if (!insideAllowedBoundary(latitude, longitude)) + { + return failure("coordinates_outside_allowed_boundary"); + } const auto areaId = findArea(latitude, longitude); auto db = drogon::app().getDbClient(); @@ -1215,6 +1247,20 @@ Json::Value ReportService::openDataReports(const std::optional &pag perPageValue, offset), 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; } diff --git a/test/test_main.cc b/test/test_main.cc index 25970d8..c2ce70a 100644 --- a/test/test_main.cc +++ b/test/test_main.cc @@ -106,6 +106,61 @@ DROGON_TEST(GeoJsonMatchesPointInsidePolygon) CHECK(visione::geojson::pointInPolygon(41.5, 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(GeoJsonAcceptsContainedPolygon) +{ + Json::Value container; + container["type"] = "Polygon"; + Json::Value outer(Json::arrayValue); + + Json::Value a(Json::arrayValue); + a.append(12.0); + 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)); } DROGON_TEST(PasswordHashSupportsPbkdf2Only)