diff --git a/application/Controller/AdminAdvancedController.php b/application/Controller/AdminAdvancedController.php index 75cef84e..8e57c8fc 100644 --- a/application/Controller/AdminAdvancedController.php +++ b/application/Controller/AdminAdvancedController.php @@ -70,6 +70,29 @@ public function ajaxAdminAction() { $this->response->setJsonContent(['success' => true]); return $this->sendResponse(); } + + if ($request->verify_email_manually) { + $user = (new User())->refresh(['id' => (int)$request->user_id, 'email' => $request->user_email]); + + $user->setVerified($request->user_email); + + alert('User email address has been manually set to verified.', 'alert-success'); + $this->response->setContentType('application/json'); + $this->response->setJsonContent(['success' => true]); + return $this->sendResponse(); + } + + if ($request->add_default_email_access) { + $user = (new User())->refresh(['id' => (int)$request->user_id, 'email' => $request->user_email]); + + $user->enableDefaultEmailaccount(); + + alert('User has been given access to the default email sending account.', 'alert-success'); + $this->response->setContentType('application/json'); + $this->response->setJsonContent(['success' => true]); + return $this->sendResponse(); + } + } private function setAdminLevel($user_id, $level) { diff --git a/application/Model/EmailAccount.php b/application/Model/EmailAccount.php index a56c95d7..907110b8 100644 --- a/application/Model/EmailAccount.php +++ b/application/Model/EmailAccount.php @@ -58,6 +58,7 @@ public function changeSettings($posted) { 'port' => $this->account['port'], 'tls' => $this->account['tls'], 'username' => $this->account['username'], + 'reply_to' => $this->account['reply_to'], 'password' => $old_password, ); @@ -72,7 +73,7 @@ public function changeSettings($posted) { $params['password'] = ''; $query = "UPDATE `survey_email_accounts` - SET `from` = :fromm, `from_name` = :from_name, `host` = :host, `port` = :port, `tls` = :tls, `username` = :username, `password` = :password, `auth_key` = :auth_key, `status` = 0 + SET `from` = :fromm, `from_name` = :from_name, `host` = :host, `port` = :port, `tls` = :tls, `username` = :username, `reply_to` = :reply_to, `password` = :password, `auth_key` = :auth_key, `status` = 0 WHERE id = :id LIMIT 1"; $this->db->exec($query, $params); @@ -122,7 +123,12 @@ public function makeMailer() { } $mail->From = $this->account['from']; $mail->FromName = $this->account['from_name']; - $mail->AddReplyTo($this->account['from'], $this->account['from_name']); + if(!empty($this->account['reply_to'])) { + $mail->AddReplyTo($this->account['reply_to'], $this->account['from_name']); + } else { + $mail->AddReplyTo($this->account['from'], $this->account['from_name']); + } + $mail->CharSet = "utf-8"; $mail->WordWrap = 65; // set word wrap to 65 characters if (is_array(Config::get('email.smtp_options'))) { diff --git a/application/Model/Item/Calculate.php b/application/Model/Item/Calculate.php index 8833027b..02f79589 100644 --- a/application/Model/Item/Calculate.php +++ b/application/Model/Item/Calculate.php @@ -5,7 +5,7 @@ class Calculate_Item extends Item { public $type = 'calculate'; public $input_attributes = array('type' => 'hidden'); public $no_user_input_required = true; - public $mysql_field = 'TEXT DEFAULT NULL'; + public $mysql_field = 'MEDIUMTEXT DEFAULT NULL'; public function render() { return $this->render_input(); diff --git a/application/Model/Item/Hidden.php b/application/Model/Item/Hidden.php index b3ab6b79..1598cb2f 100644 --- a/application/Model/Item/Hidden.php +++ b/application/Model/Item/Hidden.php @@ -3,7 +3,7 @@ class Hidden_Item extends Item { public $type = 'hidden'; - public $mysql_field = 'TEXT DEFAULT NULL'; + public $mysql_field = 'MEDIUMTEXT DEFAULT NULL'; public $input_attributes = array('type' => 'hidden'); public $optional = 1; diff --git a/application/Model/Run.php b/application/Model/Run.php index 4f592b35..ec2dce65 100644 --- a/application/Model/Run.php +++ b/application/Model/Run.php @@ -222,74 +222,123 @@ public function getUploadedFiles() { ->fetchAll(); } - public $file_endings = array( - 'image/jpeg' => '.jpg', 'image/png' => '.png', 'image/gif' => '.gif', 'image/tiff' => '.tif', - 'video/mpeg' => '.mpg', 'video/quicktime' => '.mov', 'video/x-flv' => '.flv', 'video/x-f4v' => '.f4v', 'video/x-msvideo' => '.avi', - 'audio/mpeg' => '.mp3', - 'application/pdf' => '.pdf', - 'text/csv' => '.csv', 'text/css' => '.css', 'text/tab-separated-values' => '.tsv', 'text/plain' => '.txt' - ); - public function uploadFiles($files) { $max_size_upload = Config::get('admin_maximum_size_of_uploaded_files'); + $allowed_file_endings = Config::get('allowed_file_endings_for_run_upload'); + // make lookup array $existing_files = $this->getUploadedFiles(); $files_by_names = array(); foreach ($existing_files as $existing_file) { $files_by_names[$existing_file['original_file_name']] = $existing_file['new_file_path']; } - + + // Generate a random directory name for this batch + $batch_directory = 'assets/tmp/admin/' . crypto_token(15, true) . '/'; + + // Ensure the batch directory exists + $destination_dir = APPLICATION_ROOT . 'webroot/' . $batch_directory; + if (!is_dir($destination_dir)) { + mkdir($destination_dir, 0777, true); + } + // loop through files and modify them if necessary for ($i = 0; $i < count($files['tmp_name']); $i++) { - // validate if any error occured on upload + // validate if any error occurred on upload if ($files['error'][$i]) { - $this->errors[] = __("An error occured uploading file '%s'. ERROR CODE: PFUL-%d", $files['name'][$i], $files['error'][$i]); + $this->errors[] = __("An error occurred uploading file '%s'. ERROR CODE: PFUL-%d", $files['name'][$i], $files['error'][$i]); continue; } - + // validate file size $size = (int) $files['size'][$i]; if (!$size || ($size > $max_size_upload * 1048576)) { $this->errors[] = __("The file '%s' is too big or the size could not be determined. The allowed maximum size is %d megabytes.", $files['name'][$i], round($max_size_upload, 2)); continue; } - - // validate mime type + + // validate mime type and file ending $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($files['tmp_name'][$i]); - if (!isset($this->file_endings[$mime])) { - $this->errors[] = __('The file "%s" has the MIME type %s and is not allowed to be uploaded.', $files['name'][$i], $mime); + $original_file_name = $files['name'][$i]; + $file_extension = pathinfo($original_file_name, PATHINFO_EXTENSION); + + // Adjust validation for ambiguous types + if ($mime == 'text/plain' || $mime == "text/x-asm") { + // Add additional cases for other ambiguous MIME types + switch ($file_extension) { + case 'css': + $mime = 'text/css'; + break; + case 'js': + $mime = 'text/javascript'; + break; + case 'svg': + $mime = 'image/svg+xml'; + break; + case 'html': + $mime = 'text/html'; + break; + case 'xml': + $mime = 'application/xml'; + break; + case 'md': + $mime = 'text/markdown'; + break; + case 'yaml': + case 'yml': + $mime = 'application/x-yaml'; + break; + case 'json': + $mime = 'application/json'; + break; + case 'rtf': + $mime = 'application/rtf'; + break; + case 'php': + $mime = 'application/x-httpd-php'; + break; + case 'sh': + $mime = 'application/x-sh'; + break; + } + } + + if (!isset($allowed_file_endings[$mime]) || $allowed_file_endings[$mime] !== $file_extension) { + $this->errors[] = __('The file "%s" has an invalid file extension %s. Expected %s for MIME type %s.', $original_file_name, $file_extension, $allowed_file_endings[$mime], $mime); continue; } - - // validation was OK - $original_file_name = $files['name'][$i]; - if (isset($files_by_names[$original_file_name])) { - // override existing path - $new_file_path = $files_by_names[$original_file_name]; - $this->messages[] = __('The file "%s" was overriden.', $original_file_name); - } else { - $new_file_path = 'assets/tmp/admin/' . crypto_token(33, true) . $this->file_endings[$mime]; + + // Sanitize file name to remove control characters + $sanitized_file_name = preg_replace('/[\x00-\x1F\x7F]/u', '', $original_file_name); // Remove control characters + $new_file_path = $batch_directory . $sanitized_file_name; + + // Ensure the destination path is within the intended directory + $intended_path = $destination_dir . $sanitized_file_name; + if (strpos(realpath(dirname($intended_path)), realpath($destination_dir)) !== 0) { + $this->errors[] = __("The file '%s' could not be uploaded due to an invalid file path.", $sanitized_file_name); + continue; } // save file - $destination_dir = APPLICATION_ROOT . 'webroot/' . $new_file_path; - if (move_uploaded_file($files['tmp_name'][$i], $destination_dir)) { + if (move_uploaded_file($files['tmp_name'][$i], $destination_dir . $original_file_name)) { $this->db->insert_update('survey_uploaded_files', array( 'run_id' => $this->id, 'created' => mysql_now(), 'original_file_name' => $original_file_name, 'new_file_path' => $new_file_path, - ), array( + ), array( 'modified' => mysql_now() )); + $this->messages[] = __('The file "%s" was successfully uploaded.', $original_file_name); } else { $this->errors[] = __("Unable to move uploaded file '%s' to storage location.", $files['name'][$i]); } } - + return empty($this->errors); } + public function deleteFile($id, $filename) { $where = array('id' => (int) $id, 'original_file_name' => $filename); diff --git a/application/Model/User.php b/application/Model/User.php index 9d17bfd0..694b089e 100644 --- a/application/Model/User.php +++ b/application/Model/User.php @@ -295,6 +295,36 @@ public function changeData($password, $data) { return true; } + public function enableDefaultEmailaccount() { + $default_email = Config::get('default_admin_email'); + + if ($default_email !== null && $default_email['host'] !== null) { + // Create a new EmailAccount instance + $emailAccount = new EmailAccount(null, $this->id); + + // Prepare the settings array + $settings = array( + 'from' => $default_email['from'], + 'from_name' => $default_email['from_name'], + 'host' => $default_email['host'], + 'port' => $default_email['port'], + 'tls' => $default_email['tls'], + 'username' => $default_email['username'], + 'password' => $default_email['password'], + 'reply_to' => $this->email + ); + + // Create the email account + $emailAccount->create(); + + // Change settings to the new default email configuration + $emailAccount->changeSettings($settings); + $emailAccount->validate(); + alert("You have been set up with an email account to programmatically send emails from {$default_email['from']}!", 'alert-success'); + } + } + + public function resetPassword($info) { $email = array_val($info, 'email'); $token = array_val($info, 'reset_token'); @@ -328,26 +358,35 @@ public function resetPassword($info) { return false; } - public function verifyEmail($email, $token) { + public function setVerified($email) { + $this->db->update('survey_users', array('email_verification_hash' => null, 'email_verified' => 1), array('email' => $email), array('int', 'int')); + alert('Your email was successfully verified!', 'alert-success'); + $verify_data = $this->db->findRow('survey_users', array('email' => $email), array('email_verification_hash', 'referrer_code')); - if (!$verify_data) { - alert('Incorrect token or email address.', 'alert-danger'); - return false; - } - if (password_verify($token, (string)$verify_data['email_verification_hash'])) { - $this->db->update('survey_users', array('email_verification_hash' => null, 'email_verified' => 1), array('email' => $email), array('int', 'int')); - alert('Your email was successfully verified!', 'alert-success'); + if (in_array($verify_data['referrer_code'], Config::get('referrer_codes'))) { + $this->db->update('survey_users', array('admin' => 1), array('email' => $email)); + $this->id = (int) $this->db->findValue('survey_users', array('email' => $email), array('id')); + $this->load(); + $this->enableDefaultEmailaccount(); - if (in_array($verify_data['referrer_code'], Config::get('referrer_codes'))) { - $this->db->update('survey_users', array('admin' => 1), array('email' => $email)); - alert('You now have the rights to create your own studies!', 'alert-success'); - } - return true; + alert('You now have the rights to create your own studies!', 'alert-success'); } else { alert('Your email verification token was invalid or oudated. Please try copy-pasting the link in your email and removing any spaces.', 'alert-danger'); return false; } + return true; + } + + public function verifyEmail($email, $token) { + $verify_data = $this->db->findRow('survey_users', array('email' => $email), array('email_verification_hash')); + if (!$verify_data) { + alert('Incorrect token or email address.', 'alert-danger'); + return false; + } + if (password_verify($token, (string)$verify_data['email_verification_hash'])) { + return $this->setVerified($email); + } } public function resendVerificationEmail($verificationHash) { diff --git a/bin/add_user.php b/bin/add_user.php index 8f7fda6d..eabcc358 100644 --- a/bin/add_user.php +++ b/bin/add_user.php @@ -27,8 +27,8 @@ print("$users exist already"); -if($config['level'] > 0 && $users > 0) { - throw new Exception("Cannot create admins when users already exist"); +if($config['level'] > 1 && $users > 0) { + print("Cannot create superadmins when users already exist"); } $inserted = $db->insert('survey_users', array( diff --git a/config-dist/settings.php b/config-dist/settings.php index 70f2ce45..6b2e8f54 100644 --- a/config-dist/settings.php +++ b/config-dist/settings.php @@ -43,7 +43,8 @@ 'r_lib_path' => '/usr/local/lib/R/site-library' ); -// email SMTP and queueing configuration +// email SMTP and queueing configuration for emails sent by the formr app itself +// for example for email confirmation and password reset $settings['email'] = array( 'host' => 'smtp.example.com', 'port' => 587, @@ -66,6 +67,18 @@ 'smtp_options' => array(), ); +// email SMTP and queueing configuration for emails sent by formr admins in studies +// maybe not be the same as the formr app email +$settings['default_admin_email'] = array( + 'host' => NULL, + 'port' => NULL, + 'tls' => true, + 'from' => NULL, + 'from_name' => NULL, + 'username' => NULL, + 'password' => NULL +); + // should PHP and MySQL errors be displayed to the users when formr is not running locally? If 0, they are only logged $settings['display_errors_when_live'] = 0; $settings['display_errors'] = 0; @@ -83,6 +96,25 @@ // Maximum size allowed for uploaded files in MB $settings['admin_maximum_size_of_uploaded_files'] = 50; +$settings['allowed_file_endings_for_run_upload'] = array( + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/tiff' => 'tif', + 'video/mpeg' => 'mpg', + 'video/quicktime' => 'mov', + 'video/x-flv' => 'flv', + 'video/x-f4v' => 'f4v', + 'video/x-msvideo' => 'avi', + 'audio/mpeg' => 'mp3', + 'application/pdf' => 'pdf', + 'text/csv' => 'csv', + 'text/javascript' => 'js', + 'text/css' => 'css', + 'text/tab-separated-values' => 'tsv', + 'text/plain' => 'txt', + 'text/html' => 'html' +); // Directory for exported runs $settings['run_exports_dir'] = APPLICATION_ROOT . 'documentation/run_components'; diff --git a/sql/patches/037_mediumtexts.sql b/sql/patches/037_mediumtexts.sql new file mode 100644 index 00000000..b47e4992 --- /dev/null +++ b/sql/patches/037_mediumtexts.sql @@ -0,0 +1,4 @@ +ALTER TABLE `survey_items` CHANGE `label` `label` mediumtext COLLATE utf8mb4_unicode_ci; +ALTER TABLE `survey_run_sessions` CHANGE `session` `session` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL; +ALTER TABLE `survey_users` CHANGE `user_code` `user_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL; +ALTER TABLE `survey_email_accounts` ADD `reply_to` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL; diff --git a/templates/admin/account/login.php b/templates/admin/account/login.php index fe1b4f24..dc3c8c39 100755 --- a/templates/admin/account/login.php +++ b/templates/admin/account/login.php @@ -57,6 +57,8 @@

 

Forgot password? +

 

+ Sign Up diff --git a/templates/admin/account/register.php b/templates/admin/account/register.php index 003f3e69..74fef428 100755 --- a/templates/admin/account/register.php +++ b/templates/admin/account/register.php @@ -34,13 +34,13 @@
- diff --git a/templates/admin/advanced/settings/docu-page.php b/templates/admin/advanced/settings/docu-page.php index 39ec7f20..88f45899 100644 --- a/templates/admin/advanced/settings/docu-page.php +++ b/templates/admin/advanced/settings/docu-page.php @@ -18,7 +18,7 @@
- +

Users would send requests to this email for admin accounts.

diff --git a/templates/admin/advanced/user_management.php b/templates/admin/advanced/user_management.php index 566d9e31..8901fdc8 100644 --- a/templates/admin/advanced/user_management.php +++ b/templates/admin/advanced/user_management.php @@ -1,4 +1,7 @@ - +
@@ -59,6 +62,8 @@ + ' :'' ?> + ' ?> diff --git a/templates/admin/mail/edit.php b/templates/admin/mail/edit.php index 7987625f..12bba56a 100644 --- a/templates/admin/mail/edit.php +++ b/templates/admin/mail/edit.php @@ -69,6 +69,15 @@
+
+ +
+ +
+
+
"> diff --git a/templates/admin/mail/index.php b/templates/admin/mail/index.php index 1facb410..fb084946 100644 --- a/templates/admin/mail/index.php +++ b/templates/admin/mail/index.php @@ -80,7 +80,7 @@
- +
@@ -104,6 +104,17 @@
+ +
+ +
+ +
+
+ +
diff --git a/templates/admin/run/upload_files.php b/templates/admin/run/upload_files.php index c8c6b082..6d30e1e1 100644 --- a/templates/admin/run/upload_files.php +++ b/templates/admin/run/upload_files.php @@ -23,11 +23,13 @@
  • Choose as many files as you'd like.
  • +
  • You have to have the necessary rights to upload the file here. Do not upload pictures of people without their consent, respect the license conditions of software etc.
  • You will be able to browse them by name here, but you'll have to copy a randomly-generated link to embed them.
  • To embed images, use the following Markdown syntax: ![image description for blind users](image link), so in a concrete example ![Picture of a guitar](https://formr.org/assets/tmp/admin/mkWpDTv5Um2ijGs1SJbH1uw9Bn2ctysD8N3tbkuwalOM.png). You can embed images anywhere you can use Markdown (e.g. in item and choice labels, feedback, emails).
  • We do not prevent users from sharing the links with others. If your users see an image/video, there is no way of preventing them from re-sharing it, if you're not looking over their shoulders.
    Users can always take a photo of the screen, even if you could prevent screenshots. Hence, we saw no point in generating single-use links for the images (so that users can't share the picture directly). Please be aware of this and don't use formr to show confidential information in an un-supervised setting. However, because the links are large random numbers, it's fairly safe to use formr to upload confidential information to be shown in the lab, the images cannot be discovered by people who don't have access to the study.
  • +
  • The following file types are allowed:
diff --git a/webroot/assets/common/js/run_users.js b/webroot/assets/common/js/run_users.js index 9883dd31..8783f2e2 100644 --- a/webroot/assets/common/js/run_users.js +++ b/webroot/assets/common/js/run_users.js @@ -168,6 +168,46 @@ }).modal('show'); } + + function verifyEmailManually(e) { + /*jshint validthis:true */ + + var userId = parseInt($(this).data('user'), 10); + var userEmail = $(this).data('email'); + if (!userId || !userEmail) { + return; + } + var $btn = $(this); + + var data = {user_id: userId, user_email: userEmail, verify_email_manually: true}; + postdata(saAjaxUrl, data, function (response) { + if (response && response.success) { + $btn.css("border-color", "green"); + $btn.css("color", "green"); + $btn.off('click'); + } + }); + } + + function addDefaultEmailAccess(e) { + /*jshint validthis:true */ + + var userId = parseInt($(this).data('user'), 10); + var userEmail = $(this).data('email'); + if (!userId || !userEmail) { + return; + } + var $btn = $(this); + + var data = {user_id: userId, user_email: userEmail, add_default_email_access: true}; + postdata(saAjaxUrl, data, function (response) { + if (response && response.success) { + $btn.css("border-color", "green"); + $btn.css("color", "green"); + $btn.off('click'); + } + }); + } function deleteUserSession(e) { /*jshint validthis:true */ @@ -420,6 +460,8 @@ $(this).find('.fa').addClass('fa-stop').removeClass('fa-play'); }); $('.api-btn').click(userAPIAccess); + $('.verify-email-btn').click(verifyEmailManually); + $('.add-email-btn').click(addDefaultEmailAccess); $('.del-btn').click(userDelete); $('.sessions-search-switch').click(toggleSessionSearch); if ($(".hidden_debug_message").length > 0) {