4.7.2 delete maps
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-21 09:18:14 +02:00
parent 7a850937b0
commit 199f8c0537
9 changed files with 199 additions and 59 deletions

View File

@@ -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<std::string>();
if (!map_group.empty())
return map_group == session.group_id;
}
return true;
}
std::string AuthService::extractToken(const httplib::Request& req) const

View File

@@ -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<nlohmann::json> loginPassword(const std::string& username,
const std::string& password,

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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<const char*>(sqlite3_column_text(stmt, 13)));
zones = nlohmann::json::parse(reinterpret_cast<const char*>(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<nlohmann::json> 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<nlohmann::json> MapStore::create(const nlohmann::json& payload, st
std::lock_guard<std::mutex> 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<nlohmann::json> 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<double>());
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<double>());
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<double>());
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<double>());
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<double>());
else
sqlite3_bind_null(stmt, 9);
if (payload.contains("resolution") && payload["resolution"].is_number())
sqlite3_bind_double(stmt, 10, payload["resolution"].get<double>());
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<nlohmann::json> 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<std::mutex> 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<double>());
sqlite3_bind_double(stmt, 5, merged["width"].get<double>());
else
sqlite3_bind_null(stmt, 5);
if (merged["height"].is_number())
sqlite3_bind_double(stmt, 6, merged["height"].get<double>());
else
sqlite3_bind_null(stmt, 6);
if (merged["height"].is_number())
sqlite3_bind_double(stmt, 7, merged["height"].get<double>());
if (merged["resolution"].is_number())
sqlite3_bind_double(stmt, 7, merged["resolution"].get<double>());
else
sqlite3_bind_null(stmt, 7);
if (merged["resolution"].is_number())
sqlite3_bind_double(stmt, 8, merged["resolution"].get<double>());
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)

View File

@@ -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.",

View File

@@ -1264,6 +1264,18 @@
</form>
</dialog>
<dialog id="mapsDeleteDialog" class="mapsMirDialog">
<div class="mapsMirDialogPanel">
<h2 class="mapsMirDialogTitle" data-i18n="maps.deleteDialog.title">Delete map?</h2>
<p id="mapsDeleteDialogText" class="mapsMirDialogText"></p>
<p id="mapsDeleteDialogActiveWarn" class="mapsMirDialogHint" hidden data-i18n="maps.deleteDialog.activeWarning">This map is currently active on the robot.</p>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="mapsDeleteNoBtn" data-i18n="common.no">No</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="mapsDeleteYesBtn" data-i18n="common.delete">Delete</button>
</div>
</div>
</dialog>
<dialog id="mapEditorMenuDialog" class="mapsMirDialog mapsMirDialog--mapMenu">
<h2 class="mapsMirDialogTitle mapsMirMapMenuTitle" data-i18n="maps.menu.title">Upload, download and record maps</h2>
<div class="mapsMirMapMenuGrid">

View File

@@ -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: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 3l3 4-3 4M8 3l3 4-3 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
@@ -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, "&amp;")
@@ -216,7 +232,7 @@
? `<div class="mapsMirRowActions">
<button type="button" class="mapsMirIconBtn" data-edit="${map.id}" data-i18n-title="common.edit" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
<button type="button" class="mapsMirIconBtn" data-view="${map.id}" data-i18n-title="maps.view" title="${escapeHtml(t("maps.view"))}">${ICONS.view}</button>
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${map.id}" data-i18n-title="common.delete" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>
${canDeleteMap(map) ? `<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${map.id}" data-i18n-title="common.delete" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>` : ""}
</div>`
: `<div class="mapsMirRowActions">
<button type="button" class="mapsMirIconBtn" data-view="${map.id}" data-i18n-title="maps.view" title="${escapeHtml(t("maps.view"))}">${ICONS.view}</button>
@@ -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;
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));
});

View File

@@ -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;