From 199f8c0537e66799051c2b3c83f6612a8a88d6e9 Mon Sep 17 00:00:00 2001 From: HiepLM Date: Sun, 21 Jun 2026 09:18:14 +0200 Subject: [PATCH] 4.7.2 delete maps --- src/auth/auth_service.cpp | 10 ++- src/auth/auth_service.hpp | 3 +- src/server/api_media_routes.cpp | 19 ++++++ src/storage/database.cpp | 20 +++++- src/storage/map_store.cpp | 116 ++++++++++++++++++-------------- www/i18n.js | 8 +++ www/index.html | 12 ++++ www/maps.js | 62 +++++++++++++++-- www/style.css | 8 +++ 9 files changed, 199 insertions(+), 59 deletions(-) diff --git a/src/auth/auth_service.cpp b/src/auth/auth_service.cpp index 40be25d..e2db99c 100644 --- a/src/auth/auth_service.cpp +++ b/src/auth/auth_service.cpp @@ -152,9 +152,15 @@ void AuthService::saveUnlocked() db_.setDocument("auth", data_); } -const AuthSession* AuthService::currentSession() const +bool AuthService::canDeleteMap(const nlohmann::json& map, const AuthSession& session) { - return tls_session_; + if (map.contains("created_by_group") && map["created_by_group"].is_string()) + { + const std::string map_group = map["created_by_group"].get(); + if (!map_group.empty()) + return map_group == session.group_id; + } + return true; } std::string AuthService::extractToken(const httplib::Request& req) const diff --git a/src/auth/auth_service.hpp b/src/auth/auth_service.hpp index 6969a3b..6982bad 100644 --- a/src/auth/auth_service.hpp +++ b/src/auth/auth_service.hpp @@ -30,7 +30,8 @@ public: httplib::Server::HandlerResponse preRoute(const httplib::Request& req, httplib::Response& res); - const AuthSession* currentSession() const; + static const AuthSession* activeSession() { return tls_session_; } + static bool canDeleteMap(const nlohmann::json& map, const AuthSession& session); std::optional loginPassword(const std::string& username, const std::string& password, diff --git a/src/server/api_media_routes.cpp b/src/server/api_media_routes.cpp index 27f314b..77d4918 100644 --- a/src/server/api_media_routes.cpp +++ b/src/server/api_media_routes.cpp @@ -1,5 +1,6 @@ #include "server/api_server.hpp" +#include "auth/auth_service.hpp" #include "util/file_util.hpp" #include "util/http_util.hpp" @@ -89,6 +90,11 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr) { return HttpUtil::jsonError(res, 400, "invalid JSON"); } + if (const AuthSession* session = AuthService::activeSession()) + { + body["created_by_user"] = session->user_id; + body["created_by_group"] = session->group_id; + } std::string err; const auto created = map_store_.create(body, err); if (!created) @@ -121,6 +127,19 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr) svr.Delete(R"(/api/maps/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); const std::string id = req.matches[1]; + const auto map = map_store_.find(id); + if (!map) + return HttpUtil::jsonError(res, 404, "map not found"); + + if (const char* disabled = std::getenv("LM_AUTH_DISABLED"); !disabled || std::string(disabled) != "1") + { + const AuthSession* session = AuthService::activeSession(); + if (!session) + return HttpUtil::jsonError(res, 401, "authentication required"); + if (!AuthService::canDeleteMap(*map, *session)) + return HttpUtil::jsonError(res, 403, "cannot delete map from another user group"); + } + std::string err; if (!map_store_.remove(id, err)) return HttpUtil::jsonError(res, 404, err); diff --git a/src/storage/database.cpp b/src/storage/database.cpp index 3ab66b9..0843b38 100644 --- a/src/storage/database.cpp +++ b/src/storage/database.cpp @@ -55,6 +55,8 @@ CREATE TABLE IF NOT EXISTS maps ( description TEXT NOT NULL DEFAULT '', site_id TEXT, created_by TEXT NOT NULL DEFAULT '', + created_by_user TEXT, + created_by_group TEXT, width REAL, height REAL, resolution REAL, @@ -268,7 +270,7 @@ bool tableHasColumn(sqlite3* db, const char* table, const char* column) bool Database::applySchemaMigrations(std::string& err) { - const std::string ver = getMeta("schema_version").value_or("1"); + std::string ver = getMeta("schema_version").value_or("1"); if (ver == "1") { @@ -317,6 +319,22 @@ bool Database::applySchemaMigrations(std::string& err) setMeta("schema_version", "2"); } + ver = getMeta("schema_version").value_or("1"); + if (ver == "2") + { + if (!tableHasColumn(db_, "maps", "created_by_user")) + { + if (!execSql(db_, "ALTER TABLE maps ADD COLUMN created_by_user TEXT", err)) + return false; + } + if (!tableHasColumn(db_, "maps", "created_by_group")) + { + if (!execSql(db_, "ALTER TABLE maps ADD COLUMN created_by_group TEXT", err)) + return false; + } + setMeta("schema_version", "3"); + } + return true; } diff --git a/src/storage/map_store.cpp b/src/storage/map_store.cpp index 8ff5caf..fa55d1c 100644 --- a/src/storage/map_store.cpp +++ b/src/storage/map_store.cpp @@ -14,9 +14,9 @@ namespace { constexpr const char* kBaseImageName = "map_base.png"; constexpr const char* kMapSelect = - "SELECT id, name, description, site_id, created_by, width, height, resolution, " - "origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, created_at, updated_at " - "FROM maps"; + "SELECT id, name, description, site_id, created_by, created_by_user, created_by_group, " + "width, height, resolution, origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, " + "created_at, updated_at FROM maps"; nlohmann::json rowToJson(sqlite3_stmt* stmt) { @@ -32,11 +32,11 @@ nlohmann::json rowToJson(sqlite3_stmt* stmt) }; nlohmann::json zones = nlohmann::json::array(); - if (sqlite3_column_type(stmt, 13) != SQLITE_NULL) + if (sqlite3_column_type(stmt, 15) != SQLITE_NULL) { try { - zones = nlohmann::json::parse(reinterpret_cast(sqlite3_column_text(stmt, 13))); + zones = nlohmann::json::parse(reinterpret_cast(sqlite3_column_text(stmt, 15))); } catch (...) { @@ -49,17 +49,19 @@ nlohmann::json rowToJson(sqlite3_stmt* stmt) {"description", textOrNull(2)}, {"site_id", textOrNull(3)}, {"created_by", textOrNull(4)}, - {"width", realOrNull(5)}, - {"height", realOrNull(6)}, - {"resolution", realOrNull(7)}, - {"origin_x", realOrNull(8)}, - {"origin_y", realOrNull(9)}, - {"origin_yaw", realOrNull(10)}, - {"image_file", textOrNull(11)}, - {"yaml_file", textOrNull(12)}, + {"created_by_user", textOrNull(5)}, + {"created_by_group", textOrNull(6)}, + {"width", realOrNull(7)}, + {"height", realOrNull(8)}, + {"resolution", realOrNull(9)}, + {"origin_x", realOrNull(10)}, + {"origin_y", realOrNull(11)}, + {"origin_yaw", realOrNull(12)}, + {"image_file", textOrNull(13)}, + {"yaml_file", textOrNull(14)}, {"zones", zones}, - {"created_at", textOrNull(14)}, - {"updated_at", textOrNull(15)}}; + {"created_at", textOrNull(16)}, + {"updated_at", textOrNull(17)}}; } } // namespace @@ -119,6 +121,8 @@ std::optional MapStore::create(const nlohmann::json& payload, st const std::string description = payload.value("description", ""); const std::string site_id = payload.value("site_id", ""); const std::string created_by = payload.value("created_by", ""); + const std::string created_by_user = payload.value("created_by_user", ""); + const std::string created_by_group = payload.value("created_by_group", ""); const auto zones = payload.contains("zones") ? payload["zones"] : nlohmann::json::array(); std::error_code ec; @@ -132,10 +136,10 @@ std::optional MapStore::create(const nlohmann::json& payload, st std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), - "INSERT INTO maps(id, name, description, site_id, created_by, width, height, resolution, " - "origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, " - "created_at, updated_at) " - "VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)", + "INSERT INTO maps(id, name, description, site_id, created_by, created_by_user, " + "created_by_group, width, height, resolution, origin_x, origin_y, origin_yaw, image_file, " + "yaml_file, zones_json, created_at, updated_at) " + "VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18)", -1, &stmt, nullptr) != SQLITE_OK) @@ -152,27 +156,35 @@ std::optional MapStore::create(const nlohmann::json& payload, st else sqlite3_bind_text(stmt, 4, site_id.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 5, created_by.c_str(), -1, SQLITE_TRANSIENT); - if (payload.contains("width") && payload["width"].is_number()) - sqlite3_bind_double(stmt, 6, payload["width"].get()); - else + if (created_by_user.empty()) sqlite3_bind_null(stmt, 6); - if (payload.contains("height") && payload["height"].is_number()) - sqlite3_bind_double(stmt, 7, payload["height"].get()); else + sqlite3_bind_text(stmt, 6, created_by_user.c_str(), -1, SQLITE_TRANSIENT); + if (created_by_group.empty()) sqlite3_bind_null(stmt, 7); - if (payload.contains("resolution") && payload["resolution"].is_number()) - sqlite3_bind_double(stmt, 8, payload["resolution"].get()); + else + sqlite3_bind_text(stmt, 7, created_by_group.c_str(), -1, SQLITE_TRANSIENT); + if (payload.contains("width") && payload["width"].is_number()) + sqlite3_bind_double(stmt, 8, payload["width"].get()); else sqlite3_bind_null(stmt, 8); - sqlite3_bind_double(stmt, 9, payload.value("origin_x", 0.0)); - sqlite3_bind_double(stmt, 10, payload.value("origin_y", 0.0)); - sqlite3_bind_double(stmt, 11, payload.value("origin_yaw", 0.0)); - sqlite3_bind_null(stmt, 12); - sqlite3_bind_null(stmt, 13); + if (payload.contains("height") && payload["height"].is_number()) + sqlite3_bind_double(stmt, 9, payload["height"].get()); + else + sqlite3_bind_null(stmt, 9); + if (payload.contains("resolution") && payload["resolution"].is_number()) + sqlite3_bind_double(stmt, 10, payload["resolution"].get()); + else + sqlite3_bind_null(stmt, 10); + sqlite3_bind_double(stmt, 11, payload.value("origin_x", 0.0)); + sqlite3_bind_double(stmt, 12, payload.value("origin_y", 0.0)); + sqlite3_bind_double(stmt, 13, payload.value("origin_yaw", 0.0)); + sqlite3_bind_null(stmt, 14); + sqlite3_bind_null(stmt, 15); const std::string zones_str = zones.dump(); - sqlite3_bind_text(stmt, 14, zones_str.c_str(), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(stmt, 15, now.c_str(), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(stmt, 16, now.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 16, zones_str.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 17, now.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 18, now.c_str(), -1, SQLITE_TRANSIENT); if (sqlite3_step(stmt) != SQLITE_DONE) { @@ -188,6 +200,10 @@ std::optional MapStore::create(const nlohmann::json& payload, st created["description"] = description; created["site_id"] = site_id.empty() ? nullptr : nlohmann::json(site_id); created["created_by"] = created_by; + if (!created_by_user.empty()) + created["created_by_user"] = created_by_user; + if (!created_by_group.empty()) + created["created_by_group"] = created_by_group; if (payload.contains("width") && payload["width"].is_number()) created["width"] = payload["width"]; if (payload.contains("height") && payload["height"].is_number()) @@ -216,7 +232,7 @@ bool MapStore::update(const std::string& id, const nlohmann::json& payload, std: nlohmann::json merged = *existing; for (const char* key : - {"name", "description", "site_id", "created_by", "width", "height", "resolution", "origin_x", "origin_y", "origin_yaw"}) + {"name", "description", "site_id", "width", "height", "resolution", "origin_x", "origin_y", "origin_yaw"}) { if (payload.contains(key)) merged[key] = payload[key]; @@ -237,9 +253,8 @@ bool MapStore::update(const std::string& id, const nlohmann::json& payload, std: std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), - "UPDATE maps SET name=?2, description=?3, site_id=?4, created_by=?5, width=?6, height=?7, " - "resolution=?8, origin_x=?9, origin_y=?10, origin_yaw=?11, zones_json=?12, updated_at=?13 " - "WHERE id=?1", + "UPDATE maps SET name=?2, description=?3, site_id=?4, width=?5, height=?6, resolution=?7, " + "origin_x=?8, origin_y=?9, origin_yaw=?10, zones_json=?11, updated_at=?12 WHERE id=?1", -1, &stmt, nullptr) != SQLITE_OK) @@ -256,24 +271,23 @@ bool MapStore::update(const std::string& id, const nlohmann::json& payload, std: sqlite3_bind_null(stmt, 4); else sqlite3_bind_text(stmt, 4, site_id.c_str(), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(stmt, 5, merged.value("created_by", "").c_str(), -1, SQLITE_TRANSIENT); if (merged["width"].is_number()) - sqlite3_bind_double(stmt, 6, merged["width"].get()); + sqlite3_bind_double(stmt, 5, merged["width"].get()); + else + sqlite3_bind_null(stmt, 5); + if (merged["height"].is_number()) + sqlite3_bind_double(stmt, 6, merged["height"].get()); else sqlite3_bind_null(stmt, 6); - if (merged["height"].is_number()) - sqlite3_bind_double(stmt, 7, merged["height"].get()); + if (merged["resolution"].is_number()) + sqlite3_bind_double(stmt, 7, merged["resolution"].get()); else sqlite3_bind_null(stmt, 7); - if (merged["resolution"].is_number()) - sqlite3_bind_double(stmt, 8, merged["resolution"].get()); - else - sqlite3_bind_null(stmt, 8); - sqlite3_bind_double(stmt, 9, merged.value("origin_x", 0.0)); - sqlite3_bind_double(stmt, 10, merged.value("origin_y", 0.0)); - sqlite3_bind_double(stmt, 11, merged.value("origin_yaw", 0.0)); - sqlite3_bind_text(stmt, 12, zones_str.c_str(), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(stmt, 13, now.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_double(stmt, 8, merged.value("origin_x", 0.0)); + sqlite3_bind_double(stmt, 9, merged.value("origin_y", 0.0)); + sqlite3_bind_double(stmt, 10, merged.value("origin_yaw", 0.0)); + sqlite3_bind_text(stmt, 11, zones_str.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 12, now.c_str(), -1, SQLITE_TRANSIENT); const bool ok = sqlite3_step(stmt) == SQLITE_DONE; if (!ok) diff --git a/www/i18n.js b/www/i18n.js index 6758502..1593bb5 100644 --- a/www/i18n.js +++ b/www/i18n.js @@ -334,6 +334,10 @@ "maps.sitesDialog.empty": "Chưa có site.", "maps.sitesDialog.deleteConfirm": "Xóa site \"{name}\"?", "maps.deleteConfirm": "Xóa map \"{name}\"?", + "maps.deleteDialog.title": "Xóa map?", + "maps.deleteDialog.text": "Xóa map \"{name}\"? Hành động không hoàn tác.", + "maps.deleteDialog.activeWarning": "Map này đang là map hoạt động trên robot.", + "maps.deleteForbidden": "Bạn không thể xóa map thuộc nhóm người dùng khác.", "maps.error.nameEmpty": "Tên map không được để trống.", "maps.error.noImage": "Map chưa có ảnh — upload PNG trước khi kích hoạt.", "maps.error.pngOnly": "Chỉ chấp nhận file PNG.", @@ -948,6 +952,10 @@ "maps.sitesDialog.empty": "No sites yet.", "maps.sitesDialog.deleteConfirm": "Delete site \"{name}\"?", "maps.deleteConfirm": "Delete map \"{name}\"?", + "maps.deleteDialog.title": "Delete map?", + "maps.deleteDialog.text": "Delete map \"{name}\"? This cannot be undone.", + "maps.deleteDialog.activeWarning": "This map is currently active on the robot.", + "maps.deleteForbidden": "You cannot delete a map from another user group.", "maps.error.nameEmpty": "Map name is required.", "maps.error.noImage": "Map has no image — upload a PNG before activating.", "maps.error.pngOnly": "Only PNG files are accepted.", diff --git a/www/index.html b/www/index.html index b1bf254..69e35a8 100644 --- a/www/index.html +++ b/www/index.html @@ -1264,6 +1264,18 @@ + +
+

Delete map?

+

+ +
+ + +
+
+
+

Upload, download and record maps

diff --git a/www/maps.js b/www/maps.js index d2aeaa6..9c747fa 100644 --- a/www/maps.js +++ b/www/maps.js @@ -25,8 +25,11 @@ const sitesDialogEl = el("mapsSitesDialog"); const sitesListEl = el("mapsSitesList"); const siteFormDialogEl = el("mapsSiteFormDialog"); + const deleteDialogEl = el("mapsDeleteDialog"); const createSiteSelectEl = el("mapsCreateSite"); + let deleteDialogResolve = null; + const SITE_ICONS = { chevron: ``, edit: ``, @@ -48,6 +51,19 @@ return window.AuthApp?.canWrite?.("maps") ?? true; } + function currentUser() { + return window.AuthApp?.getUser?.() || null; + } + + function canDeleteMap(map) { + if (!canWrite() || !map) return false; + const user = currentUser(); + if (!user) return true; + const mapGroup = map.created_by_group; + if (mapGroup) return mapGroup === user.group_id; + return true; + } + function escapeHtml(str) { return String(str) .replace(/&/g, "&") @@ -216,7 +232,7 @@ ? `
- + ${canDeleteMap(map) ? `` : ""}
` : `
@@ -310,11 +326,31 @@ }); } + function confirmDeleteMap(map) { + return new Promise((resolve) => { + deleteDialogResolve = resolve; + const textEl = el("mapsDeleteDialogText"); + const activeWarnEl = el("mapsDeleteDialogActiveWarn"); + if (textEl) textEl.textContent = t("maps.deleteDialog.text", { name: map.name || map.id }); + if (activeWarnEl) { + const isActive = map.id === store.activeMapId; + activeWarnEl.hidden = !isActive; + if (isActive) activeWarnEl.textContent = t("maps.deleteDialog.activeWarning"); + } + deleteDialogEl?.showModal(); + }); + } + async function deleteMapFromList(mapId) { const map = findMap(mapId); - if (!map) return; - if (!confirm(t("maps.deleteConfirm", { name: map.name }))) return; - await api(`/api/maps/${encodeURIComponent(map.id)}`, { method: "DELETE" }); + if (!map || !canDeleteMap(map)) return; + if (!(await confirmDeleteMap(map))) return; + try { + await api(`/api/maps/${encodeURIComponent(map.id)}`, { method: "DELETE" }); + } catch (e) { + alert(e.message || t("maps.deleteForbidden")); + return; + } store.maps = store.maps.filter((m) => m.id !== map.id); if (store.activeMapId === map.id) store.activeMapId = null; renderList(); @@ -465,6 +501,8 @@ name, site_id, created_by: user?.display_name || user?.username || "", + created_by_user: user?.id || "", + created_by_group: user?.group_id || "", resolution: 0.05, origin_x: 0, origin_y: 0, @@ -515,6 +553,22 @@ el("mapsCreateForm")?.addEventListener("submit", (evt) => { createMap(evt).catch((e) => alert(e.message)); }); + el("mapsDeleteYesBtn")?.addEventListener("click", () => { + deleteDialogEl?.close(); + deleteDialogResolve?.(true); + deleteDialogResolve = null; + }); + el("mapsDeleteNoBtn")?.addEventListener("click", () => { + deleteDialogEl?.close(); + deleteDialogResolve?.(false); + deleteDialogResolve = null; + }); + deleteDialogEl?.addEventListener("cancel", (evt) => { + evt.preventDefault(); + deleteDialogEl.close(); + deleteDialogResolve?.(false); + deleteDialogResolve = null; + }); el("mapsSiteForm")?.addEventListener("submit", (evt) => { saveSite(evt).catch((e) => alert(e.message)); }); diff --git a/www/style.css b/www/style.css index cb7d5ff..3e5b637 100644 --- a/www/style.css +++ b/www/style.css @@ -3259,6 +3259,14 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event .mapsMirBtn--outline:hover { background: #f5f5f5; } +.mapsMirBtn--danger { + background: #c0392b; + color: #fff; + border: 1px solid #c0392b; +} + +.mapsMirBtn--danger:hover { background: #a93226; } + .mapsMirActiveHint { margin-bottom: 12px; padding: 8px 12px;