From 3c74920cb66b4e6c47c7e8a0eaeed40ffb7e8544 Mon Sep 17 00:00:00 2001 From: arno Date: Sun, 16 Aug 2009 00:23:42 +0200 Subject: [PATCH] web interface to add co-administrators --- COPYING.txt | 4 +- README.txt | 16 +++ api.php | 97 ++++++++++++------ devdoc/api.txt | 12 +++ inc/db/anydb.php | 8 +- inc/db/mysql.php | 8 +- inc/i10n/en/syp.php | 30 +++++- inc/i10n/fr/syp.php | 50 ++++++++- inc/templates_admin.php | 37 ++++++- inc/templates_install.php | 2 +- js/admin.js | 204 ++++++++++++++++++++++++++++++++++--- media/admin.css | 36 ++++++- media/newuser-throbber.gif | Bin 0 -> 9427 bytes 13 files changed, 437 insertions(+), 67 deletions(-) create mode 100644 media/newuser-throbber.gif diff --git a/COPYING.txt b/COPYING.txt index 42134a9..b25feb3 100644 --- a/COPYING.txt +++ b/COPYING.txt @@ -11,8 +11,8 @@ - Jquery is available under both MIT and GPL licences. More informations can be found at http://docs.jquery.com/Licensing -- editor-throbber.gif and pwd-throbber.gif files have been generated with - http://ajaxload.info/ generator and are free for use. +- editor-throbber.gif, newuser-throbber.gif and pwd-throbber.gif files have + been generated with http://ajaxload.info/ generator and are free for use. diff --git a/README.txt b/README.txt index 33f29a3..ce452dd 100644 --- a/README.txt +++ b/README.txt @@ -45,6 +45,22 @@ Upgrade - copy upload/ back. - open http://yoururl.com/upgrade.php with your web browser +co-administrators +----------------- +It is possible to allow other people to upload and manage +pictures/descriptions. In admin interface, select "Add an co-administrator" +and fill informations (user name and password). Then, you need to communicate +to your user its username and password. Only admin can add new users. + +Other co-administrators will be able to add markers, and delete/modifiy them. +They cannot modify markers they have not created. admin is the only user +allowed to manage markers of other users. If you plan to have several +co-administrators, you may want to create a normal user for yourself, and use +it to manage your markers. You will then only use admin account when really +needed. + +Currently, SYP does not allow + server API ---------- The way server side communicates with client side is described at diff --git a/api.php b/api.php index 0d6e727..af8af1a 100644 --- a/api.php +++ b/api.php @@ -6,6 +6,17 @@ function exit_document ($body) { exit ("$body"); } +function success ($reason) { + exit_document (""); +} + +function success_newuser ($username) { + $res = "" . + htmlspecialchars ($user) . + ""; + exit_document ($res); +} + function success_auth ($user) { $res = "" . htmlspecialchars ($user) . @@ -46,14 +57,14 @@ function success_delete_feature ($feature) { exit_document ($res); } -function success ($reason) { - exit_document (""); -} - function error ($reason) { exit_document (""); } +function error_newuser_exists () { + error ("newuser_exists"); +} + function error_feature ($id, $reason) { $res = ""; $res .= "" . $id . ""; @@ -61,30 +72,30 @@ function error_feature ($id, $reason) { exit_document ($res); } -function nochange_error ($id) { +function error_nochange ($id) { error_feature ($id, "nochange"); } -function unreferenced_error ($id) { +function error_unreferenced ($id) { error_feature ($id, "unreferenced"); } -function server_error () { +function error_server () { error ("server"); } -function unauthorized_error () { +function error_unauthorized () { error ("unauthorized"); } -function request_error () { +function error_request () { error ("request"); } -function file_too_big_error () { +function error_file_too_big () { error ("toobig"); } -function notanimage_error () { +function error_notanimage () { error ("notimage"); } @@ -95,12 +106,12 @@ function save_uploaded_file ($file, $con) { $dest = unique_file (UPLOADDIR, $file ["name"], $con); if (!isset ($dest) || (!move_uploaded_file ($file ["tmp_name"], $dest))) { - server_error (); + error_server (); } $mini_dest = getthumbsdir () . "/mini_" . basename_safe ($dest); if (!create_thumbnail_or_copy ($dest, $mini_dest)) { - server_error (); + error_server (); } } return basename_safe ($dest); @@ -109,13 +120,13 @@ function save_uploaded_file ($file, $con) { function img_check_upload ($file) { if (!is_uploaded_file ($file ["tmp_name"])) { if ($file ["error"] == UPLOAD_ERR_INI_SIZE) { - file_too_big_error (); + error_file_too_big (); } else { - server_error (); + error_server (); } } if (!getimagesize ($file ["tmp_name"])) { - notanimage_error (); + error_notanimage (); } } @@ -181,20 +192,20 @@ function check_auth ($con, $user, $pwd, $auth_only) { success_auth ($user); } } else { - unauthorized_error (); + error_unauthorized (); } } if (!$authentificated && !($con->checkpwdmd5 ( $_COOKIE [sprintf ("%suser", DBPREFIX)], $_COOKIE [sprintf ("%sauth", DBPREFIX)]))) { - unauthorized_error (); + error_unauthorized (); } } function main ($con) { if (!isset ($_POST ["request"])) { - request_error (); + error_request (); } $pwd = unquote ($_POST ["password"]); @@ -211,10 +222,10 @@ function main ($con) { $id = $_POST ["fid"]; $feature = $con->getfeature ($id); if (!isset ($feature)) { - unreferenced_error ($id); + error_unreferenced ($id); } if ($feature->user != $user) { - unauthorized_error (); + error_unauthorized (); } // no file uploaded, but editor currently has an image: it means @@ -233,7 +244,7 @@ function main ($con) { try { $new_feature = new feature ($id, $lon, $lat, $imgpath, $title, $description, 0, $user); } catch (Exception $e) { - request_error (); + error_request (); } if (($new_feature->lon == $feature->lon) && @@ -241,7 +252,7 @@ function main ($con) { ($new_feature->title == $feature->title) && ($new_feature->imgpath == $feature->imgpath) && ($new_feature->description == $feature->description)) { - nochange_error ($feature->id); + error_nochange ($feature->id); } $old_imgpath = ""; @@ -252,7 +263,7 @@ function main ($con) { try { $con->save_feature ($new_feature); } catch (Exception $e) { - server_error (); + error_server (); } if ($old_imgpath) { try { @@ -271,12 +282,12 @@ function main ($con) { try { $feature = new feature (null, $lon, $lat, $imgpath, $title, $description, 0, $user); } catch (Exception $e) { - request_error (); + error_request (); } try { $feature = $con->save_feature ($feature); } catch (Exception $e) { - server_error (); + error_server (); } success_feature ($feature, "add"); break; @@ -284,17 +295,17 @@ function main ($con) { $id = $_POST ["fid"]; $feature = $con->getfeature ($id); if (!isset ($feature)) { - unreferenced_error ($id); + error_unreferenced ($id); } if ($feature->user != $user) { - unauthorized_error (); + error_unauthorized (); } $imgpath = $feature->imgpath; try { $con->delete_feature ($feature); } catch (Exception $e) { - server_error (); + error_server (); } try { @@ -302,16 +313,36 @@ function main ($con) { } catch (Exception $e) {} success_delete_feature ($feature); + case "newuser": + if ($user != "admin") { + error_unauthorized (); + } + $newuser_name = unquote ($_POST ["newuser_name"]); + if (!$newuser_name) { + error_request (); + } + $newuser_password = unquote ($_POST ["newuser_password"]); + try { + $con->setpwd ($newuser_name, $newuser_password, false); + } catch (Exception $e) { + if ($e->getMessage () == anydbConnection::err_query) { + error_newuser_exists (); + } else { + error_server (); + } + } + success_newuser ($newuser_name); + break; default: - request_error(); + error_request(); break; } - server_error (); + error_server (); } if (!@include_once ("./inc/settings.php")) { - server_error (); + error_server (); } require_once ("./inc/db/mysql.php"); require_once ("./inc/utils.php"); @@ -319,7 +350,7 @@ require_once ("./inc/utils.php"); try { $connection->connect (DBHOST, DBUSER, DBPWD, DBNAME, DBPREFIX); } catch (Exception $e) { - server_error (); + error_server (); } main ($connection); diff --git a/devdoc/api.txt b/devdoc/api.txt index e02b78d..8cc6fef 100644 --- a/devdoc/api.txt +++ b/devdoc/api.txt @@ -20,6 +20,14 @@ but server may be written in any language. ## auth asks for authentication +## newuser + adds a new user + * `$_POST ["newuser_name"]` must contain user name + * `$_POST ["newuser_password"]` must contain user password + + Only admin can add new users. + + ## add adds a new feature @@ -71,12 +79,16 @@ as _text/html_ * `toobig`: uploaded file was too big * `notation`: uploaded file was not an image * `nochange`: when trying to update a feature, there is nothing to update (ie: no field of the feature has changed) + * `newuser_exists`: when trying to add an user which has the same name as an already registered user ## success handling: * `?user_name?`: authentication was successfull. ?user_name? is name of authenticated user. + * `?user_name?`: + new user addition was successfull. ?user_name? is name of newly added user. + * ` ?id? diff --git a/inc/db/anydb.php b/inc/db/anydb.php index 8ccdf12..596561c 100644 --- a/inc/db/anydb.php +++ b/inc/db/anydb.php @@ -94,10 +94,12 @@ interface anydbConnection { public function create_items_table(); /* - * set password $pwd for user $usrname. If $usrname does not exist, create - * it. + * set password $pwd for user $usrname. + * If $usrname does not exist: + * - if $create_if_not_exists is true: create user. + * - if $create_if_not_exists is false: throws an err_query error. */ - public function setpwd($usrname, $pwd); + public function setpwd($usrname, $pwd, $create_if_not_exists); /* * check that $pwd_md5 is md5 for $username password. diff --git a/inc/db/mysql.php b/inc/db/mysql.php index 043e86d..8e6253f 100644 --- a/inc/db/mysql.php +++ b/inc/db/mysql.php @@ -54,14 +54,18 @@ class mysqlConnection implements anydbConnection { $this->_execute_query ($query); } - public function setpwd ($user_name, $pwd) { + public function setpwd ($user_name, $pwd, $create_if_not_exists) { $usrname_escaped = mysql_real_escape_string ($user_name); $query = sprintf ("SELECT COUNT(*) FROM %susers WHERE name LIKE '%s';", $this->dbprefix, $usrname_escaped); $res = mysql_fetch_array ($this->_execute_query ($query), MYSQL_NUM); if ($res [0] == 1) { - $query = sprintf ("UPDATE %susers SET pwd='%s' WHERE name like '%s';", + if ($create_if_not_exists) { + $query = sprintf ("UPDATE %susers SET pwd='%s' WHERE name like '%s';", $this->dbprefix, md5 ($pwd), $usrname_escaped); + } else { + throw new Exception (anydbConnection::err_query); + } } else { $query = sprintf ("INSERT INTO %susers VALUES ('%s', '%s');", $this->dbprefix, $usrname_escaped, md5 ($pwd)); diff --git a/inc/i10n/en/syp.php b/inc/i10n/en/syp.php index a16e4ce..487e4eb 100644 --- a/inc/i10n/en/syp.php +++ b/inc/i10n/en/syp.php @@ -201,24 +201,48 @@ "Server reply was inconsistent." => "", - "There was an unknown error." - => "", - "Removal took place correctly." => "", "Save took place correctly." => "", + "User name has not been set." + => "", + + "Passwords do not match." + => "", + + "User already exists in database." + => "", + + "User added correctly." + => "", + "Logout" => "", + "Add a co-administrator" + => "", + "close without saving" => "", "close" => "", + "user name:" + => "", + + "user password:" + => "", + + "confirm password:" + => "", + + "Validate user" + => "", + "title:" => "", diff --git a/inc/i10n/fr/syp.php b/inc/i10n/fr/syp.php index ff5c29b..9844ed9 100644 --- a/inc/i10n/fr/syp.php +++ b/inc/i10n/fr/syp.php @@ -326,11 +326,6 @@ "Le serveur a fait une réponse incohérente" , - "There was an unknown error." - => - "Il s'est produit une erreur inconnue." - , - "Removal took place correctly." => "La suppression s'est déroulée correctement." @@ -341,11 +336,36 @@ "La sauvegarde s'est déroulée correctement." , + "User name has not been set." + => + "Il faut un nom pour l'utilisateur." + , + + "Passwords do not match." + => + "Les mots de passe ne correspondent pas." + , + + "User already exists in database." + => + "L'utilisateur existe déjà." + , + + "User added correctly." + => + "Utilisateur ajouté correctement." + , + "Logout" => "Déconnexion" , + "Add a co-administrator" + => + "Ajouter un co-administrateur" + , + "close without saving" => "fermer sans enregistrer" @@ -356,6 +376,26 @@ "fermer" , + "user name:" + => + "nom d'utilisateur :" + , + + "user password:" + => + "mot de passe de l'utilisateur :" + , + + "confirm password:" + => + "confirmer le mot de passe :" + , + + "Validate user" + => + "Valider l'utilisateur" + , + "title:" => "titre :" diff --git a/inc/templates_admin.php b/inc/templates_admin.php index 1f28e79..90bdb52 100644 --- a/inc/templates_admin.php +++ b/inc/templates_admin.php @@ -67,9 +67,12 @@ if (!$usrtblexists || !$itemstblexists) { UnauthorizedError: "", NotimageError: "", UnconsistentError: "", - UnknownError: "", DelSucces: "", - UpdateSucces: "" + UpdateSucces: "", + newUserNonameError: "", + newUserPasswordmatchError: "", + newUserExistsError: "", + newUserSuccess: "" }; var sypSettings = { @@ -99,7 +102,35 @@ if (!$usrtblexists || !$itemstblexists) { diff --git a/inc/templates_install.php b/inc/templates_install.php index 86c8566..e9a96ff 100644 --- a/inc/templates_install.php +++ b/inc/templates_install.php @@ -200,7 +200,7 @@ } par_success (trans ('User table created.')); try { - $connection->setpwd ("admin", $_POST ["admin_pass"]); + $connection->setpwd ("admin", $_POST ["admin_pass"], true); } catch (Exception $e) { par_error_and_leave (trans ('Error when initializing password.')); } diff --git a/js/admin.js b/js/admin.js index d950aeb..8101799 100644 --- a/js/admin.js +++ b/js/admin.js @@ -176,6 +176,9 @@ var Admin = { }, closeEditor: function() { + if ($("#editor").css("display") == "none") { + return; + } if (this.currentFeature && this.currentFeature.layer) { this.selFeatureControl.unselect(this.currentFeature); } @@ -196,6 +199,8 @@ var Admin = { showEditor: function (feature) { $("#newfeature_button").hide(); + userMgr.closeAddUser(); + if (feature.fid) { $("#delete").show(); } else { @@ -263,6 +268,8 @@ var Admin = { }, addNewFeature: function () { + userMgr.closeAddUser(); + function cancel() { $(document).unbind("keydown"); Admin.reset() @@ -285,15 +292,18 @@ var Admin = { cancelCurrentFeature: function() { if (AjaxMgr.running) { - return; + return false; } var feature = this.currentFeature; - if (feature.fid) { - FeatureMgr.move (feature, this.currentFeatureLocation); - } else { - this.dataLayer.removeFeatures([feature]); + if (feature) { + if (feature.fid) { + FeatureMgr.move (feature, this.currentFeatureLocation); + } else { + this.dataLayer.removeFeatures([feature]); + } } this.closeEditor(); + return true; }, reloadLayer: function (layer) { @@ -469,13 +479,12 @@ var FeatureMgr = { case "error": switch (xml.documentElement.getAttribute("reason")) { case "unauthorized": - $("#login_area").show(); - $("#password").val(""); - $("#user").val(sypSettings.loggedUser).focus().select(); + pwdMgr.reset(); $("#cookie_warning").show(); this.reset(); Admin.cancelCurrentFeature(); Admin.reset(); + userMgr.uninit(); break; case "server": this.commError(SypStrings.ServerError); @@ -505,7 +514,7 @@ var FeatureMgr = { $("#image_file").focus(); break; default: - this.commError(SypStrings.UnknownError); + this.commError(SypStrings.UnconsistentError); $("title").focus(); break; } @@ -591,12 +600,12 @@ var FeatureMgr = { commSuccess: function (message) { $("#server_comm").text(message); - $("#server_comm").removeClass().addClass("success"); + $("#server_comm").removeClass("error success").addClass("success"); }, commError: function (message) { $("#server_comm").text(message); - $("#server_comm").removeClass().addClass("error"); + $("#server_comm").removeClass("error success").addClass("error"); } } @@ -666,6 +675,9 @@ var pwdMgr = { reset: function() { this.commError (""); + $("#login_area").show(); + $("#password").val(""); + $("#user").val(sypSettings.loggedUser).focus().select(); }, submit: function () { @@ -693,7 +705,6 @@ var pwdMgr = { }, ajaxReply: function (data) { - $("#pwd_throbber").css("visibility", "hidden"); // here, we need a timeout because onsend timeout sometimes has not been triggered yet window.setTimeout(function() { @@ -723,7 +734,7 @@ var pwdMgr = { this.commError(SypStrings.RequestError); break; default: - this.commError(SypStrings.UnknownError); + this.commError(SypStrings.UnconsistentError); break; } $("#login_error").show(); @@ -737,6 +748,10 @@ var pwdMgr = { user = $(xml).find("USER,user").text(); sypSettings.loggedUser = user; + if (sypSettings.loggedUser == "admin") { + userMgr.init(); + } + if (Admin.selFeatureControl) { Admin.selFeatureControl.destroy(); } @@ -771,6 +786,168 @@ var pwdMgr = { } } +var userMgr = { + _adduserDisplayed: false, + _deluserDisplayed: false, + + init: function() { + if (sypSettings.loggedUser != "admin") { + return; + } + + $("#add_user").show(); + + $("#add_user").click(function () { + userMgr.toggleAddUser(); + return false; + }); + $("#newuser_close").click(function () { + userMgr.closeAddUser() + }); + $("#newuser").submit(function() { + try { + userMgr.add(); + } catch(e) {} + return false; + }); + }, + + uninit: function() { + if (this._adduserDisplayed) { + this.closeAddUser(); + } + $("#add_user").unbind("click"); + $("#add_user").hide(); + $("#newuser_close").unbind("click"); + $("#newuser").unbind("submit"); + }, + + toggleAddUser: function() { + if (this._adduserDisplayed) { + this.closeAddUser(); + } else { + this.showAddUser(); + } + }, + + showAddUser: function() { + if (!Admin.cancelCurrentFeature()) { + return; + } + + $(document).unbind("keydown").keydown(function(e) { + if (e.keyCode == 27) { + userMgr.closeAddUser() + e.preventDefault(); + } + }); + + Admin.reset(); + $("#newuser_area").show(); + $("#newuser_name, #newuser_password, #newuser_password_confirm").val(""); + $("#newuser_name, #newuser_password, #newuser_password_confirm, #newuser_submit").removeAttr('disabled'); + $("#newuser_name").focus();; + this.commError(""); + + this._adduserDisplayed = true; + }, + + closeAddUser: function() { + $("#newuser_area").hide(); + $(document).unbind("keydown"); + this._adduserDisplayed = false; + }, + + add: function() { + var newuser_name = $("#newuser_name").val(); + if (!newuser_name) { + this.commError(SypStrings.newUserNonameError); + $("#newuser_name").focus(); + return; + } + + var newuser_pass = $("#newuser_password").val(); + var newuser_pass_confirm = $("#newuser_password_confirm").val(); + if (newuser_pass != newuser_pass_confirm) { + this.commError(SypStrings.newUserPasswordmatchError); + $("#newuser_password").focus().select(); + return; + } + + this.commError(""); + + AjaxMgr.add({ + form: $("#newuser"), + oncomplete: OpenLayers.Function.bind(this.ajaxReply, this), + onsend: function() { $("#newuser_throbber").css("visibility", "visible"); } + }); + }, + + ajaxReply: function (data) { + $("#newuser_throbber").css("visibility", "hidden"); + if (!data) { + this.commError(SypStrings.ServerError); + return; + } + + var xml = new OpenLayers.Format.XML().read(data); + switch (xml.documentElement.nodeName.toLowerCase()) { + case "error": + switch (xml.documentElement.getAttribute("reason")) { + case "unauthorized": + pwdMgr.reset(); + $("#cookie_warning").show(); + Admin.reset(); + this.uninit(); + break; + case "server": + this.commError(SypStrings.ServerError); + $("#newuser_name").focus().select(); + break; + case "request": + this.commError(SypStrings.RequestError); + $("#newuser_name").focus().select(); + break; + case "newuser_exists": + this.commError(SypStrings.newUserExistsError); + $("#newuser_name").focus().select(); + break; + default: + this.commError(SypStrings.UnconsistentError); + $("#newuser_name").focus().select(); + break; + } + break; + case "success": + switch (xml.documentElement.getAttribute("request")) { + case "newuser": + this.commSuccess(SypStrings.newUserSuccess); + $("#newuser_name, #newuser_password, #newuser_password_confirm, #newuser_submit").attr('disabled', 'disabled'); + break; + default: + this.commError(SypStrings.UnconsistentError); + $("newuser_name").focus().select(); + break; + } + break; + default: + this.commError(SypStrings.UnconsistentError); + $("newuser_name").focus().select(); + break; + } + }, + + commSuccess: function (message) { + $("#newuser_comm").text(message); + $("#newuser_comm").removeClass("error success").addClass("success"); + }, + + commError: function (message) { + $("#newuser_comm").text(message); + $("#newuser_comm").removeClass("error success").addClass("error"); + } +} + $(window).load(function () { // if using .ready, ie triggers an error when trying to access // document.namespaces @@ -803,5 +980,6 @@ $(window).load(function () { $("#image_file").parent().show(); }); + userMgr.init(); Admin.init(); }); diff --git a/media/admin.css b/media/admin.css index 9757300..ec9f82b 100644 --- a/media/admin.css +++ b/media/admin.css @@ -11,10 +11,20 @@ #header { height: 2em; } -#logout { +#add_user { + display: none; +} +#logout a, #add_user a { + text-decoration: none; + color: blue; +} +#user_management { float: right; margin: 0px 10px 0px 0px; } +#user_management p { + margin: 0px; +} #other-language { float: left; margin-top: 1px; @@ -32,6 +42,28 @@ border: #BBBBFF 1px solid; } +/* + * newuser + */ +#newuser_area { + border: 1px solid black; + display: none; + float: right; + clear: right; + width: 35%; +} +#newuser { + margin: 12px; + text-align: center; +} +#newuser_close { + float: right; +} +#newuser_comm { + margin-left: 4px; + margin-right: 4px; +} + /* * map */ @@ -51,7 +83,7 @@ #editor { position: absolute; width: 44%; - top: 2em; + top: 3em; left: 55%; display: none; border: 1px solid black; diff --git a/media/newuser-throbber.gif b/media/newuser-throbber.gif new file mode 100644 index 0000000000000000000000000000000000000000..41bae588b46ddbd85960a13d30dd6690133f3f51 GIT binary patch literal 9427 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 K{Rzy>&;J0Pu;>i{ literal 0 HcmV?d00001 -- 2.39.2