|
@@ -0,0 +1,544 @@
|
|
|
|
|
+#include "project_controller.hpp"
|
|
|
|
|
+
|
|
|
|
|
+#include "common/time_utils.hpp"
|
|
|
|
|
+#include "common/uuid.hpp"
|
|
|
|
|
+#include "logging/logger.hpp"
|
|
|
|
|
+
|
|
|
|
|
+namespace smartbotic::webserver::api {
|
|
|
|
|
+
|
|
|
|
|
+using namespace common;
|
|
|
|
|
+using auth::Action;
|
|
|
|
|
+using auth::InstanceRole;
|
|
|
|
|
+using auth::ProjectRole;
|
|
|
|
|
+
|
|
|
|
|
+namespace {
|
|
|
|
|
+constexpr const char* PROJECTS = "projects";
|
|
|
|
|
+
|
|
|
|
|
+// Everything that belongs to a project, so the migration and the delete guard
|
|
|
|
|
+// do not each keep their own half-remembered list.
|
|
|
|
|
+const std::vector<std::string>& ownedCollections() {
|
|
|
|
|
+ static const std::vector<std::string> collections = {
|
|
|
|
|
+ "workflows", "credentials", "workflow_groups"
|
|
|
|
|
+ };
|
|
|
|
|
+ return collections;
|
|
|
|
|
+}
|
|
|
|
|
+} // namespace
|
|
|
|
|
+
|
|
|
|
|
+ProjectController::ProjectController(storage::StorageClient& storage,
|
|
|
|
|
+ auth::AuthMiddleware& middleware,
|
|
|
|
|
+ auth::AccessControl& access,
|
|
|
|
|
+ auth::AuthStore& auth_store)
|
|
|
|
|
+ : storage_(storage), middleware_(middleware), access_(access), auth_store_(auth_store) {}
|
|
|
|
|
+
|
|
|
|
|
+void ProjectController::registerRoutes(httplib::Server& server) {
|
|
|
|
|
+ server.Get("/api/v1/projects", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ listProjects(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ server.Post("/api/v1/projects", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ createProject(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ server.Get(R"(/api/v1/projects/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ getProject(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ server.Put(R"(/api/v1/projects/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ updateProject(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ server.Delete(R"(/api/v1/projects/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ deleteProject(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ server.Post(R"(/api/v1/projects/([^/]+)/members)", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ addMember(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ server.Put(R"(/api/v1/projects/([^/]+)/members/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ updateMember(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ server.Delete(R"(/api/v1/projects/([^/]+)/members/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ removeMember(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+nlohmann::json ProjectController::describe(const nlohmann::json& project,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ nlohmann::json out = project;
|
|
|
|
|
+
|
|
|
|
|
+ // The UI draws "Anna (editor)", not a row of identifiers.
|
|
|
|
|
+ nlohmann::json members = nlohmann::json::array();
|
|
|
|
|
+ for (const auto& member : project.value("members", nlohmann::json::array())) {
|
|
|
|
|
+ nlohmann::json entry = member;
|
|
|
|
|
+ auto user = auth_store_.getUser(member.value("userId", ""));
|
|
|
|
|
+ if (user.ok()) {
|
|
|
|
|
+ entry["username"] = user.value().username;
|
|
|
|
|
+ entry["email"] = user.value().email;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // A member whose account has been deleted. Said plainly rather than
|
|
|
|
|
+ // shown as a blank row somebody has to guess about.
|
|
|
|
|
+ entry["username"] = "(deleted user)";
|
|
|
|
|
+ }
|
|
|
|
|
+ members.push_back(entry);
|
|
|
|
|
+ }
|
|
|
|
|
+ out["members"] = members;
|
|
|
|
|
+
|
|
|
|
|
+ const std::string id = project.value("_id", "");
|
|
|
|
|
+ out["myRole"] = auth::projectRoleToString(access_.roleIn(ctx, id));
|
|
|
|
|
+
|
|
|
|
|
+ // What a project holds, so the UI can warn before deleting one and show a
|
|
|
|
|
+ // count beside its name.
|
|
|
|
|
+ int64_t workflows = 0;
|
|
|
|
|
+ storage::QueryOptions options;
|
|
|
|
|
+ options.page_size = 1000;
|
|
|
|
|
+ options.filters.push_back({"projectId", id});
|
|
|
|
|
+ auto found = storage_.query("workflows", options);
|
|
|
|
|
+ if (found.ok()) workflows = static_cast<int64_t>(found.value().documents.size());
|
|
|
|
|
+ out["workflowCount"] = workflows;
|
|
|
|
|
+
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void ProjectController::listProjects(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ (void)req;
|
|
|
|
|
+
|
|
|
|
|
+ auto reachable = access_.projectsFor(ctx);
|
|
|
|
|
+
|
|
|
|
|
+ storage::QueryOptions options;
|
|
|
|
|
+ options.page_size = 1000;
|
|
|
|
|
+ auto result = storage_.query(PROJECTS, options);
|
|
|
|
|
+ if (result.failed()) {
|
|
|
|
|
+ sendError(res, result.error().message(), 500);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ nlohmann::json projects = nlohmann::json::array();
|
|
|
|
|
+ for (const auto& project : result.value().documents) {
|
|
|
|
|
+ const std::string id = project.value("_id", "");
|
|
|
|
|
+ if (!reachable.contains(id)) continue;
|
|
|
|
|
+ projects.push_back(describe(project, ctx));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ sendJson(res, {{"projects", projects}});
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void ProjectController::getProject(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ const std::string id = req.matches[1];
|
|
|
|
|
+
|
|
|
|
|
+ auto project = storage_.get(PROJECTS, id);
|
|
|
|
|
+ if (project.failed()) {
|
|
|
|
|
+ sendError(res, "Project not found", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!access_.allowed(ctx, id, Action::Read)) {
|
|
|
|
|
+ // Not "forbidden": whether a project exists is itself something only
|
|
|
|
|
+ // its members should learn.
|
|
|
|
|
+ sendError(res, "Project not found", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ sendJson(res, describe(project.value(), ctx));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void ProjectController::createProject(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ nlohmann::json body;
|
|
|
|
|
+ try {
|
|
|
|
|
+ body = nlohmann::json::parse(req.body);
|
|
|
|
|
+ } catch (...) {
|
|
|
|
|
+ sendError(res, "Invalid JSON body", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const std::string name = body.value("name", "");
|
|
|
|
|
+ if (name.empty()) {
|
|
|
|
|
+ sendError(res, "A project needs a name", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const std::string id = "prj_" + UUID::generate();
|
|
|
|
|
+ nlohmann::json project = {
|
|
|
|
|
+ {"name", name},
|
|
|
|
|
+ {"description", body.value("description", "")},
|
|
|
|
|
+ {"type", "team"},
|
|
|
|
|
+ {"ownerId", ctx.user_id},
|
|
|
|
|
+ // The creator is an admin of it, or they could make a project and then
|
|
|
|
|
+ // not be able to put anybody in it.
|
|
|
|
|
+ {"members", nlohmann::json::array({
|
|
|
|
|
+ nlohmann::json{{"userId", ctx.user_id}, {"role", "admin"}, {"addedAt", TimeUtils::nowMs()}}
|
|
|
|
|
+ })},
|
|
|
|
|
+ {"createdAt", TimeUtils::nowMs()},
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ auto inserted = storage_.insert(PROJECTS, project, id);
|
|
|
|
|
+ if (inserted.failed()) {
|
|
|
|
|
+ sendError(res, inserted.error().message(), 500);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ project["_id"] = id;
|
|
|
|
|
+
|
|
|
|
|
+ LOG_INFO("Project {} ({}) created by {}", id, name, ctx.username);
|
|
|
|
|
+ sendJson(res, describe(project, ctx), 201);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void ProjectController::updateProject(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ const std::string id = req.matches[1];
|
|
|
|
|
+
|
|
|
|
|
+ auto existing = storage_.get(PROJECTS, id);
|
|
|
|
|
+ if (existing.failed()) {
|
|
|
|
|
+ sendError(res, "Project not found", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!access_.allowed(ctx, id, Action::Manage)) {
|
|
|
|
|
+ sendError(res, "Only a project admin can change the project", 403);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ nlohmann::json body;
|
|
|
|
|
+ try {
|
|
|
|
|
+ body = nlohmann::json::parse(req.body);
|
|
|
|
|
+ } catch (...) {
|
|
|
|
|
+ sendError(res, "Invalid JSON body", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ nlohmann::json patch;
|
|
|
|
|
+ if (body.contains("name") && body["name"].is_string() && !body["name"].get<std::string>().empty()) {
|
|
|
|
|
+ patch["name"] = body["name"];
|
|
|
|
|
+ }
|
|
|
|
|
+ if (body.contains("description")) patch["description"] = body["description"];
|
|
|
|
|
+ if (patch.empty()) {
|
|
|
|
|
+ sendError(res, "Nothing to change - send a name or a description", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto updated = storage_.update(PROJECTS, id, patch, 0, true);
|
|
|
|
|
+ if (updated.failed()) {
|
|
|
|
|
+ sendError(res, updated.error().message(), 500);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto after = storage_.get(PROJECTS, id);
|
|
|
|
|
+ sendJson(res, describe(after.ok() ? after.value() : existing.value(), ctx));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void ProjectController::deleteProject(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ const std::string id = req.matches[1];
|
|
|
|
|
+
|
|
|
|
|
+ auto project = storage_.get(PROJECTS, id);
|
|
|
|
|
+ if (project.failed()) {
|
|
|
|
|
+ sendError(res, "Project not found", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (project.value().value("type", "") == "personal") {
|
|
|
|
|
+ sendError(res, "A personal project cannot be deleted - it is where somebody's own work lives", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!access_.allowed(ctx, id, Action::Manage)) {
|
|
|
|
|
+ sendError(res, "Only a project admin can delete the project", 403);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Deleting a project must not take a workflow with it by accident, and must
|
|
|
|
|
+ // not leave one behind that nobody can reach either. So it is refused while
|
|
|
|
|
+ // anything is still in it, and the caller is told what and how much.
|
|
|
|
|
+ nlohmann::json holding = nlohmann::json::object();
|
|
|
|
|
+ int64_t total = 0;
|
|
|
|
|
+ for (const auto& collection : ownedCollections()) {
|
|
|
|
|
+ storage::QueryOptions options;
|
|
|
|
|
+ options.page_size = 1000;
|
|
|
|
|
+ options.filters.push_back({"projectId", id});
|
|
|
|
|
+ auto found = storage_.query(collection, options);
|
|
|
|
|
+ if (found.ok() && !found.value().documents.empty()) {
|
|
|
|
|
+ holding[collection] = found.value().documents.size();
|
|
|
|
|
+ total += static_cast<int64_t>(found.value().documents.size());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (total > 0) {
|
|
|
|
|
+ nlohmann::json error = {
|
|
|
|
|
+ {"error", "This project still holds work. Move it somewhere else first, or delete it."},
|
|
|
|
|
+ {"holding", holding},
|
|
|
|
|
+ };
|
|
|
|
|
+ res.status = 409;
|
|
|
|
|
+ res.set_content(error.dump(), "application/json");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto removed = storage_.remove(PROJECTS, id);
|
|
|
|
|
+ if (removed.failed()) {
|
|
|
|
|
+ sendError(res, removed.error().message(), 500);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ LOG_INFO("Project {} deleted by {}", id, ctx.username);
|
|
|
|
|
+ sendJson(res, {{"success", true}, {"id", id}});
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void ProjectController::addMember(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ const std::string id = req.matches[1];
|
|
|
|
|
+
|
|
|
|
|
+ auto project = storage_.get(PROJECTS, id);
|
|
|
|
|
+ if (project.failed()) {
|
|
|
|
|
+ sendError(res, "Project not found", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (project.value().value("type", "") == "personal") {
|
|
|
|
|
+ sendError(res, "A personal project is one person's own - share a team project instead", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!access_.allowed(ctx, id, Action::Manage)) {
|
|
|
|
|
+ sendError(res, "Only a project admin can add members", 403);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ nlohmann::json body;
|
|
|
|
|
+ try {
|
|
|
|
|
+ body = nlohmann::json::parse(req.body);
|
|
|
|
|
+ } catch (...) {
|
|
|
|
|
+ sendError(res, "Invalid JSON body", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const std::string user_id = body.value("userId", "");
|
|
|
|
|
+ const std::string role = body.value("role", "editor");
|
|
|
|
|
+ if (user_id.empty()) {
|
|
|
|
|
+ sendError(res, "Which user? Send a userId", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (auth::projectRoleFromString(role) == ProjectRole::None) {
|
|
|
|
|
+ sendError(res, "Role has to be admin, editor or viewer", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (auth_store_.getUser(user_id).failed()) {
|
|
|
|
|
+ sendError(res, "No such user", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto members = project.value().value("members", nlohmann::json::array());
|
|
|
|
|
+ for (const auto& member : members) {
|
|
|
|
|
+ if (member.value("userId", "") == user_id) {
|
|
|
|
|
+ sendError(res, "That user is already a member - change their role instead", 409);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ members.push_back({{"userId", user_id}, {"role", role}, {"addedAt", TimeUtils::nowMs()},
|
|
|
|
|
+ {"addedBy", ctx.user_id}});
|
|
|
|
|
+
|
|
|
|
|
+ auto updated = storage_.update(PROJECTS, id, {{"members", members}}, 0, true);
|
|
|
|
|
+ if (updated.failed()) {
|
|
|
|
|
+ sendError(res, updated.error().message(), 500);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto after = storage_.get(PROJECTS, id);
|
|
|
|
|
+ LOG_INFO("User {} added to project {} as {} by {}", user_id, id, role, ctx.username);
|
|
|
|
|
+ sendJson(res, describe(after.ok() ? after.value() : project.value(), ctx), 201);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void ProjectController::updateMember(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ const std::string id = req.matches[1];
|
|
|
|
|
+ const std::string user_id = req.matches[2];
|
|
|
|
|
+
|
|
|
|
|
+ auto project = storage_.get(PROJECTS, id);
|
|
|
|
|
+ if (project.failed()) {
|
|
|
|
|
+ sendError(res, "Project not found", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!access_.allowed(ctx, id, Action::Manage)) {
|
|
|
|
|
+ sendError(res, "Only a project admin can change roles", 403);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ nlohmann::json body;
|
|
|
|
|
+ try {
|
|
|
|
|
+ body = nlohmann::json::parse(req.body);
|
|
|
|
|
+ } catch (...) {
|
|
|
|
|
+ sendError(res, "Invalid JSON body", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const std::string role = body.value("role", "");
|
|
|
|
|
+ if (auth::projectRoleFromString(role) == ProjectRole::None) {
|
|
|
|
|
+ sendError(res, "Role has to be admin, editor or viewer", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto members = project.value().value("members", nlohmann::json::array());
|
|
|
|
|
+ bool found = false;
|
|
|
|
|
+ int admins = 0;
|
|
|
|
|
+ for (auto& member : members) {
|
|
|
|
|
+ if (member.value("role", "") == "admin") admins++;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (auto& member : members) {
|
|
|
|
|
+ if (member.value("userId", "") != user_id) continue;
|
|
|
|
|
+ // A project with nobody who can manage it is a project nobody can add
|
|
|
|
|
+ // anyone to, rename, or delete.
|
|
|
|
|
+ if (member.value("role", "") == "admin" && role != "admin" && admins <= 1) {
|
|
|
|
|
+ sendError(res, "This is the project's only admin - make somebody else an admin first", 409);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ member["role"] = role;
|
|
|
|
|
+ found = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!found) {
|
|
|
|
|
+ sendError(res, "That user is not a member of this project", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto updated = storage_.update(PROJECTS, id, {{"members", members}}, 0, true);
|
|
|
|
|
+ if (updated.failed()) {
|
|
|
|
|
+ sendError(res, updated.error().message(), 500);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto after = storage_.get(PROJECTS, id);
|
|
|
|
|
+ sendJson(res, describe(after.ok() ? after.value() : project.value(), ctx));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void ProjectController::removeMember(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ const std::string id = req.matches[1];
|
|
|
|
|
+ const std::string user_id = req.matches[2];
|
|
|
|
|
+
|
|
|
|
|
+ auto project = storage_.get(PROJECTS, id);
|
|
|
|
|
+ if (project.failed()) {
|
|
|
|
|
+ sendError(res, "Project not found", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // Leaving a project yourself needs no special power; removing somebody else
|
|
|
|
|
+ // does.
|
|
|
|
|
+ const bool leaving = (user_id == ctx.user_id);
|
|
|
|
|
+ if (!leaving && !access_.allowed(ctx, id, Action::Manage)) {
|
|
|
|
|
+ sendError(res, "Only a project admin can remove members", 403);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto members = project.value().value("members", nlohmann::json::array());
|
|
|
|
|
+ nlohmann::json kept = nlohmann::json::array();
|
|
|
|
|
+ int admins = 0;
|
|
|
|
|
+ for (const auto& member : members) {
|
|
|
|
|
+ if (member.value("role", "") == "admin") admins++;
|
|
|
|
|
+ }
|
|
|
|
|
+ bool found = false;
|
|
|
|
|
+ for (const auto& member : members) {
|
|
|
|
|
+ if (member.value("userId", "") == user_id) {
|
|
|
|
|
+ if (member.value("role", "") == "admin" && admins <= 1) {
|
|
|
|
|
+ sendError(res, "This is the project's only admin - make somebody else an admin first", 409);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ found = true;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ kept.push_back(member);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!found) {
|
|
|
|
|
+ sendError(res, "That user is not a member of this project", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto updated = storage_.update(PROJECTS, id, {{"members", kept}}, 0, true);
|
|
|
|
|
+ if (updated.failed()) {
|
|
|
|
|
+ sendError(res, updated.error().message(), 500);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto after = storage_.get(PROJECTS, id);
|
|
|
|
|
+ LOG_INFO("User {} removed from project {} by {}", user_id, id, ctx.username);
|
|
|
|
|
+ sendJson(res, describe(after.ok() ? after.value() : project.value(), ctx));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void ProjectController::migrateExistingRecords() {
|
|
|
|
|
+ // Everybody gets a personal project, including accounts made before there
|
|
|
|
|
+ // were projects at all.
|
|
|
|
|
+ auto users = auth_store_.listUsers(1, 1000);
|
|
|
|
|
+ if (users.failed()) {
|
|
|
|
|
+ LOG_WARN("Could not read users while setting up projects: {}", users.error().message());
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ std::string fallback_project;
|
|
|
|
|
+ for (const auto& user : users.value()) {
|
|
|
|
|
+ auto project = access_.ensurePersonalProject(user.id, user.username);
|
|
|
|
|
+ if (project.failed()) {
|
|
|
|
|
+ LOG_WARN("Could not create a personal project for {}: {}", user.username,
|
|
|
|
|
+ project.error().message());
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ // Work that predates projects goes to whoever runs the installation.
|
|
|
|
|
+ const auto role = auth::instanceRoleFromString(user.role);
|
|
|
|
|
+ if (fallback_project.empty() || role == InstanceRole::Owner) {
|
|
|
|
|
+ if (fallback_project.empty() || role == InstanceRole::Owner) {
|
|
|
|
|
+ fallback_project = project.value().value("_id", "");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (fallback_project.empty()) {
|
|
|
|
|
+ LOG_WARN("No personal project to file existing work into - skipping");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (const auto& collection : ownedCollections()) {
|
|
|
|
|
+ storage::QueryOptions options;
|
|
|
|
|
+ options.page_size = 1000;
|
|
|
|
|
+ auto found = storage_.query(collection, options);
|
|
|
|
|
+ if (found.failed()) continue;
|
|
|
|
|
+
|
|
|
|
|
+ int moved = 0;
|
|
|
|
|
+ for (const auto& record : found.value().documents) {
|
|
|
|
|
+ if (!record.value("projectId", std::string()).empty()) continue;
|
|
|
|
|
+ const std::string id = record.value("_id", "");
|
|
|
|
|
+ if (id.empty()) continue;
|
|
|
|
|
+
|
|
|
|
|
+ // Its creator's own project if that is known, the owner's otherwise.
|
|
|
|
|
+ std::string target = fallback_project;
|
|
|
|
|
+ const std::string owner = record.value("ownerId", "");
|
|
|
|
|
+ if (!owner.empty()) {
|
|
|
|
|
+ auto personal = access_.personalProjectFor(owner);
|
|
|
|
|
+ if (personal.ok()) target = personal.value();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto updated = storage_.update(collection, id, {{"projectId", target}}, 0, true);
|
|
|
|
|
+ if (updated.ok()) moved++;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (moved > 0) {
|
|
|
|
|
+ LOG_INFO("Filed {} record(s) in {} into a project", moved, collection);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void ProjectController::sendJson(httplib::Response& res, const nlohmann::json& data, int status) {
|
|
|
|
|
+ res.status = status;
|
|
|
|
|
+ res.set_content(data.dump(), "application/json");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void ProjectController::sendError(httplib::Response& res, const std::string& message, int status) {
|
|
|
|
|
+ res.status = status;
|
|
|
|
|
+ res.set_content(nlohmann::json{{"error", message}}.dump(), "application/json");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+} // namespace smartbotic::webserver::api
|