index : flyspray | |
Archlinux32 customized Flyspray installation | gitolite user |
summaryrefslogtreecommitdiff |
author | Andreas Baumann <mail@andreasbaumann.cc> | 2020-02-01 09:05:48 +0100 |
---|---|---|
committer | Andreas Baumann <mail@andreasbaumann.cc> | 2020-02-01 09:05:48 +0100 |
commit | 6854cb3f4d8219cf1829e32122eb2502a916eae9 (patch) | |
tree | 350feb504587d932e02837a1442b059759927646 /includes |
-rw-r--r-- | includes/.htaccess | 1 | ||||
-rw-r--r-- | includes/GithubProvider.php | 60 | ||||
-rw-r--r-- | includes/class.backend.php | 1869 | ||||
-rw-r--r-- | includes/class.csp.php | 106 | ||||
-rw-r--r-- | includes/class.database.php | 434 | ||||
-rw-r--r-- | includes/class.effort.php | 302 | ||||
-rw-r--r-- | includes/class.flyspray.php | 1471 | ||||
-rw-r--r-- | includes/class.gpc.php | 257 | ||||
-rw-r--r-- | includes/class.jabber2.php | 943 | ||||
-rw-r--r-- | includes/class.notify.php | 1114 | ||||
-rw-r--r-- | includes/class.project.php | 474 | ||||
-rw-r--r-- | includes/class.recaptcha.php | 33 | ||||
-rw-r--r-- | includes/class.tpl.php | 1525 | ||||
-rw-r--r-- | includes/class.user.php | 555 | ||||
-rw-r--r-- | includes/constants.inc.php | 96 | ||||
-rw-r--r-- | includes/events.inc.php | 308 | ||||
-rw-r--r-- | includes/fix.inc.php | 205 | ||||
-rw-r--r-- | includes/i18n.inc.php | 166 | ||||
-rw-r--r-- | includes/modify.inc.php | 3053 | ||||
-rw-r--r-- | includes/password_compat.php | 319 | ||||
-rw-r--r-- | includes/utf8.inc.php | 118 |
diff --git a/includes/.htaccess b/includes/.htaccess new file mode 100644 index 0000000..31f17f9 --- /dev/null +++ b/includes/.htaccess @@ -0,0 +1 @@ +Deny From All diff --git a/includes/GithubProvider.php b/includes/GithubProvider.php new file mode 100644 index 0000000..5639383 --- /dev/null +++ b/includes/GithubProvider.php @@ -0,0 +1,60 @@ +<?php + +use League\OAuth2\Client\Provider\Github; +use League\OAuth2\Client\Token\AccessToken as AccessToken; + +/** + * A workaround for fetching the users email address if the user does not have a + * public email address. + */ +class GithubProvider extends Github +{ + public function userDetails($response, AccessToken $token) + { + $user = parent::userDetails($response, $token); + + // Fetch the primary email address + if (!$user->email) { + $emails = $this->fetchUserEmails($token); + $emails = json_decode($emails); + $email = null; + + foreach ($emails as $email) { + if ($email->primary) { + $email = $email->email; + break; + } + } + + $user->email = $email; + } + + return $user; + } + + protected function fetchUserEmails(AccessToken $token) + { + $url = "https://api.github.com/user/emails?access_token={$token}"; + + try { + + $client = $this->getHttpClient(); + $client->setBaseUrl($url); + + if ($this->headers) { + $client->setDefaultOption('headers', $this->headers); + } + + $request = $client->get()->send(); + $response = $request->getBody(); + + } catch (BadResponseException $e) { + // @codeCoverageIgnoreStart + $raw_response = explode("\n", $e->getResponse()); + throw new IDPException(end($raw_response)); + // @codeCoverageIgnoreEnd + } + + return $response; + } +}
\ No newline at end of file diff --git a/includes/class.backend.php b/includes/class.backend.php new file mode 100644 index 0000000..f440db9 --- /dev/null +++ b/includes/class.backend.php @@ -0,0 +1,1869 @@ +<?php +/** + * Flyspray + * + * Backend class + * + * This script contains reusable functions we use to modify + * various things in the Flyspray database tables. + * + * @license http://opensource.org/licenses/lgpl-license.php Lesser GNU Public License + * @package flyspray + * @author Tony Collins, Florian Schmitz + */ + +if (!defined('IN_FS')) { + die('Do not access this file directly.'); +} + +abstract class Backend +{ + /** + * Adds the user $user_id to the notifications list of $tasks + * @param integer $user_id + * @param array $tasks + * @param bool $do Force execution independent of user permissions + * @access public + * @return bool + * @version 1.0 + */ + public static function add_notification($user_id, $tasks, $do = false) + { + global $db, $user; + + settype($tasks, 'array'); + + $user_id = Flyspray::validUserId($user_id); + + if (!$user_id || !count($tasks)) { + return false; + } + + $sql = $db->query(' SELECT * + FROM {tasks} + WHERE ' . substr(str_repeat(' task_id = ? OR ', count($tasks)), 0, -3), + $tasks); + + while ($row = $db->fetchRow($sql)) { + // -> user adds himself + if ($user->id == $user_id) { + if (!$user->can_view_task($row) && !$do) { + continue; + } + // -> user is added by someone else + } else { + if (!$user->perms('manage_project', $row['project_id']) && !$do) { + continue; + } + } + + $notif = $db->query('SELECT notify_id + FROM {notifications} + WHERE task_id = ? and user_id = ?', + array($row['task_id'], $user_id)); + + if (!$db->countRows($notif)) { + $db->query('INSERT INTO {notifications} (task_id, user_id) + VALUES (?,?)', array($row['task_id'], $user_id)); + Flyspray::logEvent($row['task_id'], 9, $user_id); + } + } + + return (bool) $db->countRows($sql); + } + + + /** + * Removes a user $user_id from the notifications list of $tasks + * @param integer $user_id + * @param array $tasks + * @access public + * @return void + * @version 1.0 + */ + + public static function remove_notification($user_id, $tasks) + { + global $db, $user; + + settype($tasks, 'array'); + + if (!count($tasks)) { + return; + } + + $sql = $db->query(' SELECT * + FROM {tasks} + WHERE ' . substr(str_repeat(' task_id = ? OR ', count($tasks)), 0, -3), + $tasks); + + while ($row = $db->fetchRow($sql)) { + // -> user removes himself + if ($user->id == $user_id) { + if (!$user->can_view_task($row)) { + continue; + } + // -> user is removed by someone else + } else { + if (!$user->perms('manage_project', $row['project_id'])) { + continue; + } + } + + $db->query('DELETE FROM {notifications} + WHERE task_id = ? AND user_id = ?', + array($row['task_id'], $user_id)); + if ($db->affectedRows()) { + Flyspray::logEvent($row['task_id'], 10, $user_id); + } + } + } + + + /** + * Assigns one or more $tasks only to a user $user_id + * @param integer $user_id + * @param array $tasks + * @access public + * @return void + * @version 1.0 + */ + public static function assign_to_me($user_id, $tasks) + { + global $db, $notify; + + $user = $GLOBALS['user']; + if ($user_id != $user->id) { + $user = new User($user_id); + } + + settype($tasks, 'array'); + if (!count($tasks)) { + return; + } + + $sql = $db->query(' SELECT * + FROM {tasks} + WHERE ' . substr(str_repeat(' task_id = ? OR ', count($tasks)), 0, -3), + $tasks); + + while ($row = $db->fetchRow($sql)) { + if (!$user->can_take_ownership($row)) { + continue; + } + + $db->query('DELETE FROM {assigned} + WHERE task_id = ?', + array($row['task_id'])); + + $db->query('INSERT INTO {assigned} + (task_id, user_id) + VALUES (?,?)', + array($row['task_id'], $user->id)); + + if ($db->affectedRows()) { + $current_proj = new Project($row['project_id']); + Flyspray::logEvent($row['task_id'], 19, $user->id, implode(' ', Flyspray::getAssignees($row['task_id']))); + $notify->create(NOTIFY_OWNERSHIP, $row['task_id'], null, null, NOTIFY_BOTH, $current_proj->prefs['lang_code']); + } + + if ($row['item_status'] == STATUS_UNCONFIRMED || $row['item_status'] == STATUS_NEW) { + $db->query('UPDATE {tasks} SET item_status = 3 WHERE task_id = ?', array($row['task_id'])); + Flyspray::logEvent($row['task_id'], 3, 3, 1, 'item_status'); + } + } + } + + /** + * Adds a user $user_id to the assignees of one or more $tasks + * @param integer $user_id + * @param array $tasks + * @param bool $do Force execution independent of user permissions + * @access public + * @return void + * @version 1.0 + */ + public static function add_to_assignees($user_id, $tasks, $do = false) + { + global $db, $notify; + + settype($tasks, 'array'); + + $user = $GLOBALS['user']; + if ($user_id != $user->id) { + $user = new User($user_id); + } + + settype($tasks, 'array'); + if (!count($tasks)) { + return; + } + + $sql = $db->query(' SELECT * + FROM {tasks} + WHERE ' . substr(str_repeat(' task_id = ? OR ', count($tasks)), 0, -3), + $tasks); + + while ($row = $db->fetchRow($sql)) { + if (!$user->can_add_to_assignees($row) && !$do) { + continue; + } + + $db->replace('{assigned}', array('user_id'=> $user->id, 'task_id'=> $row['task_id']), array('user_id','task_id')); + + if ($db->affectedRows()) { + $current_proj = new Project($row['project_id']); + Flyspray::logEvent($row['task_id'], 29, $user->id, implode(' ', Flyspray::getAssignees($row['task_id']))); + $notify->create(NOTIFY_ADDED_ASSIGNEES, $row['task_id'], null, null, NOTIFY_BOTH, $current_proj->prefs['lang_code']); + } + + if ($row['item_status'] == STATUS_UNCONFIRMED || $row['item_status'] == STATUS_NEW) { + $db->query('UPDATE {tasks} SET item_status = 3 WHERE task_id = ?', array($row['task_id'])); + Flyspray::logEvent($row['task_id'], 3, 3, 1, 'item_status'); + } + } + } + + /** + * Adds a vote from $user_id to the task $task_id + * @param integer $user_id + * @param integer $task_id + * @access public + * @return bool + * @version 1.0 + */ + public static function add_vote($user_id, $task_id) + { + global $db; + + $user = $GLOBALS['user']; + if ($user_id != $user->id) { + $user = new User($user_id); + } + + $task = Flyspray::getTaskDetails($task_id); + + if (!$task) { + return false; + } + + if ($user->can_vote($task) > 0) { + + if($db->query("INSERT INTO {votes} (user_id, task_id, date_time) + VALUES (?,?,?)", array($user->id, $task_id, time()))) { + // TODO: Log event in a later version. + return true; + } + } + return false; + } + + /** + * Removes a vote from $user_id to the task $task_id + * @param integer $user_id + * @param integer $task_id + * @access public + * @return bool + * @version 1.0 + */ + public static function remove_vote($user_id, $task_id) + { + global $db; + + $user = $GLOBALS['user']; + if ($user_id != $user->id) { + $user = new User($user_id); + } + + $task = Flyspray::getTaskDetails($task_id); + + if (!$task) { + return false; + } + + if ($user->can_vote($task) == -2) { + + if($db->query("DELETE FROM {votes} WHERE user_id = ? and task_id = ?", + array($user->id, $task_id))) { + // TODO: Log event in a later version. + return true; + } + } + return false; + } + + /** + * Adds a comment to $task + * @param array $task + * @param string $comment_text + * @param integer $time for synchronisation with other functions + * @access public + * @return bool + * @version 1.0 + */ + public static function add_comment($task, $comment_text, $time = null) + { + global $conf, $db, $user, $notify, $proj; + + if (!($user->perms('add_comments', $task['project_id']) && (!$task['is_closed'] || $user->perms('comment_closed', $task['project_id'])))) { + return false; + } + + if($conf['general']['syntax_plugin'] != 'dokuwiki'){ + $purifierconfig = HTMLPurifier_Config::createDefault(); + $purifier = new HTMLPurifier($purifierconfig); + $comment_text = $purifier->purify($comment_text); + } + + if (!is_string($comment_text) || !strlen($comment_text)) { + return false; + } + + $time = !is_numeric($time) ? time() : $time ; + + $db->query('INSERT INTO {comments} + (task_id, date_added, last_edited_time, user_id, comment_text) + VALUES ( ?, ?, ?, ?, ? )', + array($task['task_id'], $time, $time, $user->id, $comment_text)); + $cid = $db->Insert_ID(); + Backend::upload_links($task['task_id'], $cid); + Flyspray::logEvent($task['task_id'], 4, $cid); + + if (Backend::upload_files($task['task_id'], $cid)) { + $notify->create(NOTIFY_COMMENT_ADDED, $task['task_id'], 'files', null, NOTIFY_BOTH, $proj->prefs['lang_code']); + } else { + $notify->create(NOTIFY_COMMENT_ADDED, $task['task_id'], null, null, NOTIFY_BOTH, $proj->prefs['lang_code']); + } + + + return true; + } + + /** + * Upload files for a comment or a task + * @param integer $task_id + * @param integer $comment_id if it is 0, the files will be attached to the task itself + * @param string $source name of the file input + * @access public + * @return bool + * @version 1.0 + */ + public static function upload_files($task_id, $comment_id = 0, $source = 'userfile') + { + global $db, $notify, $conf, $user; + + $task = Flyspray::getTaskDetails($task_id); + + if (!$user->perms('create_attachments', $task['project_id'])) { + return false; + } + + $res = false; + + if (!isset($_FILES[$source]['error'])) { + return false; + } + + foreach ($_FILES[$source]['error'] as $key => $error) { + if ($error != UPLOAD_ERR_OK) { + continue; + } + + + $fname = substr($task_id . '_' . md5(uniqid(mt_rand(), true)), 0, 30); + $path = BASEDIR .'/attachments/'. $fname ; + + $tmp_name = $_FILES[$source]['tmp_name'][$key]; + + // Then move the uploaded file and remove exe permissions + if(!@move_uploaded_file($tmp_name, $path)) { + //upload failed. continue + continue; + } + + @chmod($path, 0644); + $res = true; + + // Use a different MIME type + $fileparts = explode( '.', $_FILES[$source]['name'][$key]); + $extension = end($fileparts); + if (isset($conf['attachments'][$extension])) { + $_FILES[$source]['type'][$key] = $conf['attachments'][$extension]; + //actually, try really hard to get the real filetype, not what the browser reports. + } elseif($type = Flyspray::check_mime_type($path)) { + $_FILES[$source]['type'][$key] = $type; + }// we can try even more, however, far too much code is needed. + + $db->query("INSERT INTO {attachments} + ( task_id, comment_id, file_name, + file_type, file_size, orig_name, + added_by, date_added) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + array($task_id, $comment_id, $fname, + $_FILES[$source]['type'][$key], + $_FILES[$source]['size'][$key], + $_FILES[$source]['name'][$key], + $user->id, time())); + $attid = $db->insert_ID(); + Flyspray::logEvent($task_id, 7, $attid, $_FILES[$source]['name'][$key]); + } + + return $res; + } + + public static function upload_links($task_id, $comment_id = 0, $source = 'userlink') + { + global $db, $user; + + $task = Flyspray::getTaskDetails($task_id); + + if (!$user->perms('create_attachments', $task['project_id'])) { + return false; + } + + if (!isset($_POST[$source])) { + return false; + } + + $res = false; + foreach($_POST[$source] as $text) { + $text = filter_var($text, FILTER_SANITIZE_URL); + + if( preg_match( '/^\s*(javascript:|data:)/', $text)){ + continue; + } + + if(empty($text)) { + continue; + } + + $res = true; + + // Insert into database + $db->query("INSERT INTO {links} (task_id, comment_id, url, added_by, date_added) VALUES (?, ?, ?, ?, ?)", + array($task_id, $comment_id, $text, $user->id, time())); + // TODO: Log event in a later version. + } + + return $res; + } + + /** + * Delete one or more attachments of a task or comment + * @param array $attachments + * @access public + * @return void + * @version 1.0 + */ + public static function delete_files($attachments) + { + global $db, $user; + + settype($attachments, 'array'); + if (!count($attachments)) { + return; + } + + $sql = $db->query(' SELECT t.*, a.* + FROM {attachments} a + LEFT JOIN {tasks} t ON t.task_id = a.task_id + WHERE ' . substr(str_repeat(' attachment_id = ? OR ', count($attachments)), 0, -3), + $attachments); + + while ($task = $db->fetchRow($sql)) { + if (!$user->perms('delete_attachments', $task['project_id'])) { + continue; + } + + $db->query('DELETE FROM {attachments} WHERE attachment_id = ?', + array($task['attachment_id'])); + @unlink(BASEDIR . '/attachments/' . $task['file_name']); + Flyspray::logEvent($task['task_id'], 8, $task['orig_name']); + } + } + + public static function delete_links($links) + { + global $db, $user; + + settype($links, 'array'); + + if(!count($links)) { + return; + } + + $sql = $db->query('SELECT t.*, l.* FROM {links} l LEFT JOIN {tasks} t ON t.task_id = l.task_id WHERE '.substr(str_repeat('link_id = ? OR ', count($links)), 0, -3), $links); + + //Delete from database + while($task = $db->fetchRow($sql)) { + if (!$user->perms('delete_attachments', $task['project_id'])) { + continue; + } + + $db->query('DELETE FROM {links} WHERE link_id = ?', array($task['link_id'])); + // TODO: Log event in a later version. + } + } + + /** + * Cleans a username (length, special chars, spaces) + * @param string $user_name + * @access public + * @return string + */ + public static function clean_username($user_name) + { + // Limit length + $user_name = substr(trim($user_name), 0, 32); + // Remove doubled up spaces and control chars + $user_name = preg_replace('![\x00-\x1f\s]+!u', ' ', $user_name); + // Strip special chars + return utf8_keepalphanum($user_name); + } + + public static function getAdminAddresses() { + global $db; + + $emails = array(); + $jabbers = array(); + $onlines = array(); + + $sql = $db->query('SELECT DISTINCT u.user_id, u.email_address, u.jabber_id, + u.notify_online, u.notify_type, u.notify_own, u.lang_code + FROM {users} u + JOIN {users_in_groups} ug ON u.user_id = ug.user_id + JOIN {groups} g ON g.group_id = ug.group_id + WHERE g.is_admin = 1 AND u.account_enabled = 1'); + + Notifications::assignRecipients($db->fetchAllArray($sql), $emails, $jabbers, $onlines); + + return array($emails, $jabbers, $onlines); + } + + public static function getProjectManagerAddresses($project_id) { + global $db; + + $emails = array(); + $jabbers = array(); + $onlines = array(); + + $sql = $db->query('SELECT DISTINCT u.user_id, u.email_address, u.jabber_id, + u.notify_online, u.notify_type, u.notify_own, u.lang_code + FROM {users} u + JOIN {users_in_groups} ug ON u.user_id = ug.user_id + JOIN {groups} g ON g.group_id = ug.group_id + WHERE g.manage_project = 1 AND g.project_id = ? AND u.account_enabled = 1', + array($project_id)); + + Notifications::assignRecipients($db->fetchAllArray($sql), $emails, $jabbers, $onlines); + + return array($emails, $jabbers, $onlines); + } + /** + * Creates a new user + * @param string $user_name + * @param string $password + * @param string $real_name + * @param string $jabber_id + * @param string $email + * @param integer $notify_type + * @param integer $time_zone + * @param integer $group_in + * @access public + * @return bool false if username is already taken + * @version 1.0 + * @notes This function does not have any permission checks (checked elsewhere) + */ + public static function create_user($user_name, $password, $real_name, $jabber_id, $email, $notify_type, $time_zone, $group_in, $enabled, $oauth_uid = '', $oauth_provider = '', $profile_image = '') + { + global $fs, $db, $notify, $baseurl; + + $user_name = Backend::clean_username($user_name); + + // TODO Handle this whole create_user better concerning return false. Why did it fail? + # 'notassigned' and '-1' are possible filtervalues for advanced task search + if( empty($user_name) || ctype_digit($user_name) || $user_name == '-1' || $user_name=='notassigned' ) { + return false; + } + + // Limit length + $real_name = substr(trim($real_name), 0, 100); + // Remove doubled up spaces and control chars + $real_name = preg_replace('![\x00-\x1f\s]+!u', ' ', $real_name); + + # 'notassigned' and '-1' are possible filtervalues for advanced task search, lets avoid them + if( ctype_digit($real_name) || $real_name == '-1' || $real_name=='notassigned' ) { + return false; + } + + // Check to see if the username is available + $sql = $db->query('SELECT COUNT(*) FROM {users} WHERE user_name = ?', array($user_name)); + + if ($db->fetchOne($sql)) { + return false; + } + + $auto = false; + // Autogenerate a password + if (!$password) { + $auto = true; + $password = substr(md5(uniqid(mt_rand(), true)), 0, mt_rand(8, 12)); + } + + // Check the emails before inserting anything to database. + $emailList = explode(';',$email); + foreach ($emailList as $mail) { //Still need to do: check email + $count = $db->query("SELECT COUNT(*) FROM {user_emails} WHERE email_address = ?",array($mail)); + $count = $db->fetchOne($count); + if ($count > 0) { + Flyspray::show_error("Email address has alredy been taken"); + return false; + } + } + + $db->query("INSERT INTO {users} + ( user_name, user_pass, real_name, jabber_id, profile_image, magic_url, + email_address, notify_type, account_enabled, + tasks_perpage, register_date, time_zone, dateformat, + dateformat_extended, oauth_uid, oauth_provider, lang_code) + VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, 25, ?, ?, ?, ?, ?, ?, ?)", + array($user_name, Flyspray::cryptPassword($password), $real_name, strtolower($jabber_id), + $profile_image, '', strtolower($email), $notify_type, $enabled, time(), $time_zone, '', '', $oauth_uid, $oauth_provider, $fs->prefs['lang_code'])); + + // Get this user's id for the record + $uid = Flyspray::userNameToId($user_name); + + foreach ($emailList as $mail) { + if ($mail != '') { + $db->query("INSERT INTO {user_emails}(id,email_address,oauth_uid,oauth_provider) VALUES (?,?,?,?)", + array($uid,strtolower($mail),$oauth_uid, $oauth_provider)); + } + } + + // Now, create a new record in the users_in_groups table + $db->query('INSERT INTO {users_in_groups} (user_id, group_id) + VALUES (?, ?)', array($uid, $group_in)); + + Flyspray::logEvent(0, 30, serialize(Flyspray::getUserDetails($uid))); + + $varnames = array('iwatch','atome','iopened'); + + $toserialize = array('string' => NULL, + 'type' => array (''), + 'sev' => array (''), + 'due' => array (''), + 'dev' => NULL, + 'cat' => array (''), + 'status' => array ('open'), + 'order' => NULL, + 'sort' => NULL, + 'percent' => array (''), + 'opened' => NULL, + 'search_in_comments' => NULL, + 'search_for_all' => NULL, + 'reported' => array (''), + 'only_primary' => NULL, + 'only_watched' => NULL); + + foreach($varnames as $tmpname) { + if($tmpname == 'iwatch') { + $tmparr = array('only_watched' => '1'); + } elseif ($tmpname == 'atome') { + $tmparr = array('dev'=> $uid); + } elseif($tmpname == 'iopened') { + $tmparr = array('opened'=> $uid); + } + $$tmpname = $tmparr + $toserialize; + } + + // Now give him his default searches + $db->query('INSERT INTO {searches} (user_id, name, search_string, time) + VALUES (?, ?, ?, ?)', + array($uid, L('taskswatched'), serialize($iwatch), time())); + $db->query('INSERT INTO {searches} (user_id, name, search_string, time) + VALUES (?, ?, ?, ?)', + array($uid, L('assignedtome'), serialize($atome), time())); + $db->query('INSERT INTO {searches} (user_id, name, search_string, time) + VALUES (?, ?, ?, ?)', + array($uid, L('tasksireported'), serialize($iopened), time())); + + if ($jabber_id) { + Notifications::jabberRequestAuth($jabber_id); + } + + // Send a user his details (his username might be altered, password auto-generated) + // dont send notifications if the user logged in using oauth + if (!$oauth_provider) { + $recipients = self::getAdminAddresses(); + $newuser = array(); + + // Add the right message here depending on $enabled. + if ($enabled === 0) { + $newuser[0][$email] = array('recipient' => $email, 'lang' => $fs->prefs['lang_code']); + + } else { + $newuser[0][$email] = array('recipient' => $email, 'lang' => $fs->prefs['lang_code']); + } + + // Notify the appropriate users + if ($fs->prefs['notify_registration']) { + $notify->create(NOTIFY_NEW_USER, null, + array($baseurl, $user_name, $real_name, $email, $jabber_id, $password, $auto), + $recipients, NOTIFY_EMAIL); + } + // And also the new user + $notify->create(NOTIFY_OWN_REGISTRATION, null, + array($baseurl, $user_name, $real_name, $email, $jabber_id, $password, $auto), + $newuser, NOTIFY_EMAIL); + } + + // If the account is created as not enabled, no matter what any + // preferences might say or how the registration was made in first + // place, it MUST be first approved by an admin. And a small + // work-around: there's no field for email, so we use reason_given + // for that purpose. + if ($enabled === 0) { + Flyspray::adminRequest(3, 0, 0, $uid, $email); + } + + return true; + } + + /** + * Deletes a user + * @param integer $uid + * @access public + * @return bool + * @version 1.0 + */ + public static function delete_user($uid) + { + global $db, $user; + + if (!$user->perms('is_admin')) { + return false; + } + + $userDetails = Flyspray::getUserDetails($uid); + + if (is_file(BASEDIR.'/avatars/'.$userDetails['profile_image'])) { + unlink(BASEDIR.'/avatars/'.$userDetails['profile_image']); + } + + $tables = array('users', 'users_in_groups', 'searches', 'notifications', 'assigned', 'votes', 'effort'); + # FIXME Deleting a users effort without asking when user is deleted may not be wanted in every situation. + # For example for billing a project and the deleted user worked for a project. + # The better solution is to just deactivate the user, but maybe there are cases a user MUSt be deleted from the database. + # Move that effort to an 'anonymous users' effort if the effort(s) was legal and should be measured for project(s)? + foreach ($tables as $table) { + if (!$db->query('DELETE FROM ' .'{' . $table .'}' . ' WHERE user_id = ?', array($uid))) { + return false; + } + } + + if (!empty($userDetails['profile_image']) && is_file(BASEDIR.'/avatars/'.$userDetails['profile_image'])) { + unlink(BASEDIR.'/avatars/'.$userDetails['profile_image']); + } + + $db->query('DELETE FROM {registrations} WHERE email_address = ?', + array($userDetails['email_address'])); + + $db->query('DELETE FROM {user_emails} WHERE id = ?', + array($uid)); + + $db->query('DELETE FROM {reminders} WHERE to_user_id = ? OR from_user_id = ?', + array($uid, $uid)); + + // for the unusual situuation that a user ID is re-used, make sure that the new user doesn't + // get permissions for a task automatically + $db->query('UPDATE {tasks} SET opened_by = 0 WHERE opened_by = ?', array($uid)); + + Flyspray::logEvent(0, 31, serialize($userDetails)); + + return true; + } + + + /** + * Deletes a project + * @param integer $pid + * @param integer $move_to to which project contents of the project are moved + * @access public + * @return bool + * @version 1.0 + */ + public static function delete_project($pid, $move_to = 0) + { + global $db, $user; + + if (!$user->perms('manage_project', $pid)) { + return false; + } + + // Delete all project's tasks related information + if (!$move_to) { + $task_ids = $db->query('SELECT task_id FROM {tasks} WHERE project_id = ' . intval($pid)); + $task_ids = $db->fetchCol($task_ids); + // What was supposed to be in tables field_values, notification_threads + // and redundant, they do not exist in database? + $tables = array('admin_requests', 'assigned', 'attachments', 'comments', + 'dependencies', 'related', 'history', + 'notifications', + 'reminders', 'votes'); + foreach ($tables as $table) { + if ($table == 'related') { + $stmt = $db->dblink->prepare('DELETE FROM ' . $db->dbprefix . $table . ' WHERE this_task = ? OR related_task = ? '); + } else { + $stmt = $db->dblink->prepare('DELETE FROM ' . $db->dbprefix . $table . ' WHERE task_id = ?'); + } + foreach ($task_ids as $id) { + $db->dblink->execute($stmt, ($table == 'related') ? array($id, $id) : array($id)); + } + } + } + + // unset category of tasks because we don't move categories + if ($move_to) { + $db->query('UPDATE {tasks} SET product_category = 0 WHERE project_id = ?', array($pid)); + } + + $tables = array('list_category', 'list_os', 'list_resolution', 'list_tasktype', + 'list_status', 'list_version', 'admin_requests', + 'cache', 'projects', 'tasks'); + + foreach ($tables as $table) { + if ($move_to && $table !== 'projects' && $table !== 'list_category') { + // Having a unique index in most list_* tables prevents + // doing just a simple update, if the list item already + // exists in target project, so we have to update existing + // tasks to use the one in target project. Something similar + // should be done when moving a single task to another project. + // Consider making this a separate function that can be used + // for that purpose too, if possible. + if (strpos($table, 'list_') === 0) { + list($type, $name) = explode('_', $table); + $sql = $db->query('SELECT ' . $name . '_id, ' . $name . '_name + FROM {' . $table . '} + WHERE project_id = ?', + array($pid)); + $rows = $db->fetchAllArray($sql); + foreach ($rows as $row) { + $sql = $db->query('SELECT ' . $name . '_id + FROM {' . $table . '} + WHERE project_id = ? AND '. $name . '_name = ?', + array($move_to, $row[$name .'_name'])); + $new_id = $db->fetchOne($sql); + if ($new_id) { + switch ($name) { + case 'os'; + $column = 'operating_system'; + break; + case 'resolution'; + $column = 'resolution_reason'; + break; + case 'tasktype'; + $column = 'task_type'; + break; + case 'status'; + $column = 'item_status'; + break; + case 'version'; + // Questionable what to do with this one. 1.0 could + // have been still future in the old project and + // already past in the new one... + $column = 'product_version'; + break; + } + if (isset($column)) { + $db->query('UPDATE {tasks} + SET ' . $column . ' = ? + WHERE ' . $column . ' = ?', + array($new_id, $row[$name . '_id'])); + $db->query('DELETE FROM {' . $table . '} + WHERE ' . $name . '_id = ?', + array($row[$name . '_id'])); + } + } + } + } + $base_sql = 'UPDATE {' . $table . '} SET project_id = ?'; + $sql_params = array($move_to, $pid); + } else { + $base_sql = 'DELETE FROM {' . $table . '}'; + $sql_params = array($pid); + } + + if (!$db->query($base_sql . ' WHERE project_id = ?', $sql_params)) { + return false; + } + } + + // groups are only deleted, not moved (it is likely + // that the destination project already has all kinds + // of groups which are also used by the old project) + $sql = $db->query('SELECT group_id FROM {groups} WHERE project_id = ?', array($pid)); + while ($row = $db->fetchRow($sql)) { + $db->query('DELETE FROM {users_in_groups} WHERE group_id = ?', array($row['group_id'])); + } + $sql = $db->query('DELETE FROM {groups} WHERE project_id = ?', array($pid)); + + //we have enough reasons .. the process is OK. + return true; + } + + /** + * Adds a reminder to a task + * @param integer $task_id + * @param string $message + * @param integer $how_often send a reminder every ~ seconds + * @param integer $start_time time when the reminder starts + * @param $user_id the user who is reminded. by default (null) all users assigned to the task are reminded. + * @access public + * @return bool + * @version 1.0 + */ + public static function add_reminder($task_id, $message, $how_often, $start_time, $user_id = null) + { + global $user, $db; + $task = Flyspray::getTaskDetails($task_id); + + if (!$user->perms('manage_project', $task['project_id'])) { + return false; + } + + if (is_null($user_id)) { + // Get all users assigned to a task + $user_id = Flyspray::getAssignees($task_id); + } else { + $user_id = array(Flyspray::validUserId($user_id)); + if (!reset($user_id)) { + return false; + } + } + + foreach ($user_id as $id) { + $sql = $db->replace('{reminders}', + array('task_id'=> $task_id, 'to_user_id'=> $id, + 'from_user_id' => $user->id, 'start_time' => $start_time, + 'how_often' => $how_often, 'reminder_message' => $message), + array('task_id', 'to_user_id', 'how_often', 'reminder_message')); + if(!$sql) { + // query has failed :( + return false; + } + } + // 2 = no record has found and was INSERT'ed correclty + if (isset($sql) && $sql == 2) { + Flyspray::logEvent($task_id, 17, $task_id); + } + return true; + } + + /** + * Adds a new task + * @param array $args array containing all task properties. unknown properties will be ignored + * @access public + * @return integer the task ID on success + * @version 1.0 + * @notes $args is POST data, bad..bad user.. + */ + public static function create_task($args) + { + global $conf, $db, $user, $proj; + + if (!isset($args)) return 0; + + // these are the POST variables that the user MUST send, if one of + // them is missing or if one of them is empty, then we have to abort + $requiredPostArgs = array('item_summary', 'project_id');//modify: made description not required + foreach ($requiredPostArgs as $required) { + if (empty($args[$required])) return 0; + } + + $notify = new Notifications(); + if ($proj->id != $args['project_id']) { + $proj = new Project($args['project_id']); + } + + if (!$user->can_open_task($proj)) { + return 0; + } + + // first populate map with default values + $sql_args = array( + 'project_id' => $proj->id, + 'date_opened' => time(), + 'last_edited_time' => time(), + 'opened_by' => intval($user->id), + 'percent_complete' => 0, + 'mark_private' => 0, + 'supertask_id' => 0, + 'closedby_version' => 0, + 'closure_comment' => '', + 'task_priority' => 2, + 'due_date' => 0, + 'anon_email' => '', + 'item_status'=> STATUS_UNCONFIRMED + ); + + // POST variables the user is ALLOWED to provide + $allowedPostArgs = array( + 'task_type', 'product_category', 'product_version', + 'operating_system', 'task_severity', 'estimated_effort', + 'supertask_id', 'item_summary', 'detailed_desc' + ); + // these POST variables the user is only ALLOWED to provide if he got the permissions + if ($user->perms('modify_all_tasks')) { + $allowedPostArgs[] = 'closedby_version'; + $allowedPostArgs[] = 'task_priority'; + $allowedPostArgs[] = 'due_date'; + $allowedPostArgs[] = 'item_status'; + } + if ($user->perms('manage_project')) { + $allowedPostArgs[] = 'mark_private'; + } + // now copy all over all POST variables the user is ALLOWED to provide + // (but only if they are not empty) + foreach ($allowedPostArgs as $allowed) { + if (!empty($args[$allowed])) { + $sql_args[$allowed] = $args[$allowed]; + } + } + + // Process the due_date + if ( isset($args['due_date']) && ($due_date = $args['due_date']) || ($due_date = 0) ) { + $due_date = Flyspray::strtotime($due_date); + } + + $sql_params[] = 'mark_private'; + $sql_values[] = intval($user->perms('manage_project') && isset($args['mark_private']) && $args['mark_private'] == '1'); + + $sql_params[] = 'due_date'; + $sql_values[] = $due_date; + + $sql_params[] = 'closure_comment'; + $sql_values[] = ''; + + // Process estimated effort + $estimated_effort = 0; + if ($proj->prefs['use_effort_tracking'] && isset($sql_args['estimated_effort'])) { + if (($estimated_effort = effort::editStringToSeconds($sql_args['estimated_effort'], $proj->prefs['hours_per_manday'], $proj->prefs['estimated_effort_format'])) === FALSE) { + Flyspray::show_error(L('invalideffort')); + $estimated_effort = 0; + } + $sql_args['estimated_effort'] = $estimated_effort; + } + + // Token for anonymous users + $token = ''; + if ($user->isAnon()) { + if (empty($args['anon_email'])) { + return 0; + } + $token = md5(function_exists('openssl_random_pseudo_bytes') ? + openssl_random_pseudo_bytes(32) : + uniqid(mt_rand(), true)); + $sql_args['task_token'] = $token; + $sql_args['anon_email'] = $args['anon_email']; + } + + // ensure all variables are in correct format + if (!empty($sql_args['due_date'])) { + $sql_args['due_date'] = Flyspray::strtotime($sql_args['due_date']); + } + if (isset($sql_args['mark_private'])) { + $sql_args['mark_private'] = intval($sql_args['mark_private'] == '1'); + } + + # dokuwiki syntax plugin filters on output + if($conf['general']['syntax_plugin'] != 'dokuwiki' && isset($sql_args['detailed_desc']) ){ + $purifierconfig = HTMLPurifier_Config::createDefault(); + $purifier = new HTMLPurifier($purifierconfig); + $sql_args['detailed_desc'] = $purifier->purify($sql_args['detailed_desc']); + } + + // split keys and values into two separate arrays + $sql_keys = array(); + $sql_values = array(); + foreach ($sql_args as $key => $value) { + $sql_keys[] = $key; + $sql_values[] = $value; + } + + /* + * TODO: At least with PostgreSQL, this has caused the sequence to be + * out of sync with reality. Must be fixed in upgrade process. Check + * what's the situation with MySQL. (It's fine, it updates the value even + * if the column was manually adjusted. Remove this whole block later.) + $result = $db->query('SELECT MAX(task_id)+1 + FROM {tasks}'); + $task_id = $db->fetchOne($result); + $task_id = $task_id ? $task_id : 1; + */ + //now, $task_id is always the first element of $sql_values + #array_unshift($sql_keys, 'task_id'); + #array_unshift($sql_values, $task_id); + + $sql_keys_string = join(', ', $sql_keys); + $sql_placeholder = $db->fill_placeholders($sql_values); + + $result = $db->query("INSERT INTO {tasks} + ($sql_keys_string) + VALUES ($sql_placeholder)", $sql_values); + $task_id=$db->insert_ID(); + + Backend::upload_links($task_id); + + // create tags + if (isset($args['tags'])) { + $tagList = explode(';', $args['tags']); + $tagList = array_map('strip_tags', $tagList); + $tagList = array_map('trim', $tagList); + $tagList = array_unique($tagList); # avoid duplicates for inputs like: "tag1;tag1" or "tag1; tag1<p></p>" + foreach ($tagList as $tag){ + if ($tag == ''){ + continue; + } + + # old tag feature + #$result2 = $db->query("INSERT INTO {tags} (task_id, tag) VALUES (?,?)",array($task_id,$tag)); + + # new tag feature. let's do it in 2 steps, it is getting too complicated to make it cross database compatible, drawback is possible (rare) race condition (use transaction?) + $res=$db->query("SELECT tag_id FROM {list_tag} WHERE (project_id=0 OR project_id=?) AND tag_name LIKE ? ORDER BY project_id", array($proj->id,$tag) ); + if($t=$db->fetchRow($res)){ + $tag_id=$t['tag_id']; + } else{ + if( $proj->prefs['freetagging']==1){ + # add to taglist of the project + $db->query("INSERT INTO {list_tag} (project_id,tag_name) VALUES (?,?)", array($proj->id,$tag)); + $tag_id=$db->insert_ID(); + } else{ + continue; + } + }; + $db->query("INSERT INTO {task_tag}(task_id,tag_id) VALUES(?,?)", array($task_id, $tag_id) ); + } + } + + // Log the assignments and send notifications to the assignees + if (isset($args['rassigned_to']) && is_array($args['rassigned_to'])) + { + // Convert assigned_to and store them in the 'assigned' table + foreach ($args['rassigned_to'] as $val) + { + $db->replace('{assigned}', array('user_id'=> $val, 'task_id'=> $task_id), array('user_id','task_id')); + } + // Log to task history + Flyspray::logEvent($task_id, 14, implode(' ', $args['rassigned_to'])); + + // Notify the new assignees what happened. This obviously won't happen if the task is now assigned to no-one. + $notify->create(NOTIFY_NEW_ASSIGNEE, $task_id, null, $notify->specificAddresses($args['rassigned_to']), NOTIFY_BOTH, $proj->prefs['lang_code']); + } + + // Log that the task was opened + Flyspray::logEvent($task_id, 1); + + $result = $db->query('SELECT * + FROM {list_category} + WHERE category_id = ?', + array($args['product_category'])); + $cat_details = $db->fetchRow($result); + + // We need to figure out who is the category owner for this task + if (!empty($cat_details['category_owner'])) { + $owner = $cat_details['category_owner']; + } + else { + // check parent categories + $result = $db->query('SELECT * + FROM {list_category} + WHERE lft < ? AND rgt > ? AND project_id = ? + ORDER BY lft DESC', + array($cat_details['lft'], $cat_details['rgt'], $cat_details['project_id'])); + while ($row = $db->fetchRow($result)) { + // If there's a parent category owner, send to them + if (!empty($row['category_owner'])) { + $owner = $row['category_owner']; + break; + } + } + } + + if (!isset($owner)) { + $owner = $proj->prefs['default_cat_owner']; + } + + if ($owner) { + if ($proj->prefs['auto_assign'] && ($args['item_status'] == STATUS_UNCONFIRMED || $args['item_status'] == STATUS_NEW)) { + Backend::add_to_assignees($owner, $task_id, true); + } + Backend::add_notification($owner, $task_id, true); + } + + // Reminder for due_date field + if (!empty($sql_args['due_date'])) { + Backend::add_reminder($task_id, L('defaultreminder') . "\n\n" . createURL('details', $task_id), 2*24*60*60, time()); + } + + // Create the Notification + if (Backend::upload_files($task_id)) { + $notify->create(NOTIFY_TASK_OPENED, $task_id, 'files', null, NOTIFY_BOTH, $proj->prefs['lang_code']); + } else { + $notify->create(NOTIFY_TASK_OPENED, $task_id, null, null, NOTIFY_BOTH, $proj->prefs['lang_code']); + } + + // If the reporter wanted to be added to the notification list + if (isset($args['notifyme']) && $args['notifyme'] == '1' && $user->id != $owner) { + Backend::add_notification($user->id, $task_id, true); + } + + if ($user->isAnon()) { + $anonuser = array(); + $anonuser[$email] = array('recipient' => $args['anon_email'], 'lang' => $fs->prefs['lang_code']); + $recipients = array($anonuser); + $notify->create(NOTIFY_ANON_TASK, $task_id, $token, + $recipients, NOTIFY_EMAIL, $proj->prefs['lang_code']); + } + + return array($task_id, $token); + } + + /** + * Closes a task + * @param integer $task_id + * @param integer $reason + * @param string $comment + * @param bool $mark100 + * @access public + * @return bool + * @version 1.0 + */ + public static function close_task($task_id, $reason, $comment, $mark100 = true) + { + global $db, $notify, $user, $proj; + $task = Flyspray::getTaskDetails($task_id); + + if (!$user->can_close_task($task)) { + return false; + } + + if ($task['is_closed']) { + return false; + } + + $db->query('UPDATE {tasks} + SET date_closed = ?, closed_by = ?, closure_comment = ?, + is_closed = 1, resolution_reason = ?, last_edited_time = ?, + last_edited_by = ? + WHERE task_id = ?', + array(time(), $user->id, $comment, $reason, time(), $user->id, $task_id)); + + if ($mark100) { + $db->query('UPDATE {tasks} SET percent_complete = 100 WHERE task_id = ?', + array($task_id)); + + Flyspray::logEvent($task_id, 3, 100, $task['percent_complete'], 'percent_complete'); + } + + $notify->create(NOTIFY_TASK_CLOSED, $task_id, null, null, NOTIFY_BOTH, $proj->prefs['lang_code']); + Flyspray::logEvent($task_id, 2, $reason, $comment); + + // If there's an admin request related to this, close it + $db->query('UPDATE {admin_requests} + SET resolved_by = ?, time_resolved = ? + WHERE task_id = ? AND request_type = ?', + array($user->id, time(), $task_id, 1)); + + // duplicate + if ($reason == RESOLUTION_DUPLICATE) { + preg_match("/\b(?:FS#|bug )(\d+)\b/", $comment, $dupe_of); + if (count($dupe_of) >= 2) { + $existing = $db->query('SELECT * FROM {related} WHERE this_task = ? AND related_task = ? AND is_duplicate = 1', + array($task_id, $dupe_of[1])); + + if ($existing && $db->countRows($existing) == 0) { + $db->query('INSERT INTO {related} (this_task, related_task, is_duplicate) VALUES(?, ?, 1)', + array($task_id, $dupe_of[1])); + } + Backend::add_vote($task['opened_by'], $dupe_of[1]); + } + } + + return true; + } + + /** + * Returns an array of tasks (respecting pagination) and an ID list (all tasks) + * @param array $args + * @param array $visible + * @param integer $offset + * @param integer $comment + * @param bool $perpage + * @access public + * @return array + * @version 1.0 + */ + public static function get_task_list($args, $visible, $offset = 0, $perpage = 20) { + global $fs, $proj, $db, $user, $conf; + /* build SQL statement {{{ */ + // Original SQL courtesy of Lance Conry http://www.rhinosw.com/ + $where = $sql_params = array(); + + // echo '<pre>' . print_r($visible, true) . '</pre>'; + // echo '<pre>' . print_r($args, true) . '</pre>'; + // PostgreSQL LIKE searches are by default case sensitive, + // so we use ILIKE instead. For other databases, in our case + // only MySQL/MariaDB, LIKE is good for our purposes. + $LIKEOP = 'LIKE'; + if ($db->dblink->dataProvider == 'postgres') { + $LIKEOP = 'ILIKE'; + } + + $select = ''; + $groupby = 't.task_id, '; + $cgroupbyarr = array(); + + // Joins absolutely needed for user viewing rights + $from = ' {tasks} t +-- All tasks have a project! +JOIN {projects} p ON t.project_id = p.project_id'; + + // Not needed for anonymous users + if (!$user->isAnon()) { +$from .= ' -- Global group always exists +JOIN ({groups} gpg + JOIN {users_in_groups} gpuig ON gpg.group_id = gpuig.group_id AND gpuig.user_id = ? +) ON gpg.project_id = 0 +-- Project group might exist or not. +LEFT JOIN ({groups} pg + JOIN {users_in_groups} puig ON pg.group_id = puig.group_id AND puig.user_id = ? +) ON pg.project_id = t.project_id'; + $sql_params[] = $user->id; + $sql_params[] = $user->id; + } + + // Keep this always, could also used for showing assigned users for a task. + // Keeps the overall logic somewhat simpler. + $from .= ' LEFT JOIN {assigned} ass ON t.task_id = ass.task_id'; + $from .= ' LEFT JOIN {task_tag} tt ON t.task_id = tt.task_id'; + $cfrom = $from; + + // Seems resution name really is needed... + $select .= 'lr.resolution_name, '; + $from .= ' LEFT JOIN {list_resolution} lr ON t.resolution_reason = lr.resolution_id '; + $groupby .= 'lr.resolution_name, '; + + // Otherwise, only join tables which are really necessary to speed up the db-query + if (array_get($args, 'type') || in_array('tasktype', $visible)) { + $select .= ' lt.tasktype_name, '; + $from .= ' +LEFT JOIN {list_tasktype} lt ON t.task_type = lt.tasktype_id '; + $groupby .= ' lt.tasktype_id, '; + } + + if (array_get($args, 'status') || in_array('status', $visible)) { + $select .= ' lst.status_name, '; + $from .= ' +LEFT JOIN {list_status} lst ON t.item_status = lst.status_id '; + $groupby .= ' lst.status_id, '; + } + + if (array_get($args, 'cat') || in_array('category', $visible)) { + $select .= ' lc.category_name AS category_name, '; + $from .= ' +LEFT JOIN {list_category} lc ON t.product_category = lc.category_id '; + $groupby .= 'lc.category_id, '; + } + + if (in_array('votes', $visible)) { + $select .= ' (SELECT COUNT(vot.vote_id) FROM {votes} vot WHERE vot.task_id = t.task_id) AS num_votes, '; + } + + $maxdatesql = ' GREATEST(COALESCE((SELECT max(c.date_added) FROM {comments} c WHERE c.task_id = t.task_id), 0), t.date_opened, t.date_closed, t.last_edited_time) '; + $search_for_changes = in_array('lastedit', $visible) || array_get($args, 'changedto') || array_get($args, 'changedfrom'); + if ($search_for_changes) { + $select .= ' GREATEST(COALESCE((SELECT max(c.date_added) FROM {comments} c WHERE c.task_id = t.task_id), 0), t.date_opened, t.date_closed, t.last_edited_time) AS max_date, '; + $cgroupbyarr[] = 't.task_id'; + } + + if (array_get($args, 'search_in_comments')) { + $from .= ' +LEFT JOIN {comments} c ON t.task_id = c.task_id '; + $cfrom .= ' +LEFT JOIN {comments} c ON t.task_id = c.task_id '; + $cgroupbyarr[] = 't.task_id'; + } + + if (in_array('comments', $visible)) { + $select .= ' (SELECT COUNT(cc.comment_id) FROM {comments} cc WHERE cc.task_id = t.task_id) AS num_comments, '; + } + + if (in_array('reportedin', $visible)) { + $select .= ' lv.version_name AS product_version_name, '; + $from .= ' +LEFT JOIN {list_version} lv ON t.product_version = lv.version_id '; + $groupby .= 'lv.version_id, '; + } + + if (array_get($args, 'opened') || in_array('openedby', $visible)) { + $select .= ' uo.real_name AS opened_by_name, '; + $from .= ' +LEFT JOIN {users} uo ON t.opened_by = uo.user_id '; + $groupby .= 'uo.user_id, '; + if (array_get($args, 'opened')) { + $cfrom .= ' +LEFT JOIN {users} uo ON t.opened_by = uo.user_id '; + } + } + + if (array_get($args, 'closed')) { + $select .= ' uc.real_name AS closed_by_name, '; + $from .= ' +LEFT JOIN {users} uc ON t.closed_by = uc.user_id '; + $groupby .= 'uc.user_id, '; + $cfrom .= ' +LEFT JOIN {users} uc ON t.closed_by = uc.user_id '; + } + + if (array_get($args, 'due') || in_array('dueversion', $visible)) { + $select .= ' lvc.version_name AS closedby_version_name, '; + $from .= ' +LEFT JOIN {list_version} lvc ON t.closedby_version = lvc.version_id '; + $groupby .= 'lvc.version_id, lvc.list_position, '; + } + + if (in_array('os', $visible)) { + $select .= ' los.os_name AS os_name, '; + $from .= ' +LEFT JOIN {list_os} los ON t.operating_system = los.os_id '; + $groupby .= 'los.os_id, '; + } + + if (in_array('attachments', $visible)) { + $select .= ' (SELECT COUNT(attc.attachment_id) FROM {attachments} attc WHERE attc.task_id = t.task_id) AS num_attachments, '; + } + + if (array_get($args, 'has_attachment')) { + $where[] = 'EXISTS (SELECT 1 FROM {attachments} att WHERE t.task_id = att.task_id)'; + } + # 20150213 currently without recursive subtasks! + if (in_array('effort', $visible)) { + $select .= ' (SELECT SUM(ef.effort) FROM {effort} ef WHERE t.task_id = ef.task_id) AS effort, '; + } + + if (array_get($args, 'dev') || in_array('assignedto', $visible)) { + # not every db system has this feature out of box + if($conf['database']['dbtype']=='mysqli' || $conf['database']['dbtype']=='mysql'){ + $select .= ' GROUP_CONCAT(DISTINCT u.user_name ORDER BY u.user_id) AS assigned_to_name, '; + $select .= ' GROUP_CONCAT(DISTINCT u.user_id ORDER BY u.user_id) AS assignedids, '; + $select .= ' GROUP_CONCAT(DISTINCT u.profile_image ORDER BY u.user_id) AS assigned_image, '; + } elseif( $conf['database']['dbtype']=='pgsql'){ + $select .= " array_to_string(array_agg(u.user_name ORDER BY u.user_id), ',') AS assigned_to_name, "; + $select .= " array_to_string(array_agg(CAST(u.user_id as text) ORDER BY u.user_id), ',') AS assignedids, "; + $select .= " array_to_string(array_agg(u.profile_image ORDER BY u.user_id), ',') AS assigned_image, "; + } else{ + $select .= ' MIN(u.user_name) AS assigned_to_name, '; + $select .= ' (SELECT COUNT(assc.user_id) FROM {assigned} assc WHERE assc.task_id = t.task_id) AS num_assigned, '; + } + // assigned table is now always included in join + $from .= ' +LEFT JOIN {users} u ON ass.user_id = u.user_id '; + $groupby .= 'ass.task_id, '; + if (array_get($args, 'dev')) { + $cfrom .= ' +LEFT JOIN {users} u ON ass.user_id = u.user_id '; + $cgroupbyarr[] = 't.task_id'; + $cgroupbyarr[] = 'ass.task_id'; + } + } + + # not every db system has this feature out of box, it is not standard sql + if($conf['database']['dbtype']=='mysqli' || $conf['database']['dbtype']=='mysql'){ + #$select .= ' GROUP_CONCAT(DISTINCT tg.tag_name ORDER BY tg.list_position) AS tags, '; + $select .= ' GROUP_CONCAT(DISTINCT tg.tag_id ORDER BY tg.list_position) AS tagids, '; + #$select .= ' GROUP_CONCAT(DISTINCT tg.class ORDER BY tg.list_position) AS tagclass, '; + } elseif($conf['database']['dbtype']=='pgsql'){ + #$select .= " array_to_string(array_agg(tg.tag_name ORDER BY tg.list_position), ',') AS tags, "; + $select .= " array_to_string(array_agg(CAST(tg.tag_id as text) ORDER BY tg.list_position), ',') AS tagids, "; + #$select .= " array_to_string(array_agg(tg.class ORDER BY tg.list_position), ',') AS tagclass, "; + } else{ + # unsupported groupconcat or we just do not know how write it for the other databasetypes in this section + #$select .= ' MIN(tg.tag_name) AS tags, '; + #$select .= ' (SELECT COUNT(tt.tag_id) FROM {task_tag} tt WHERE tt.task_id = t.task_id) AS tagnum, '; + $select .= ' MIN(tg.tag_id) AS tagids, '; + #$select .= " '' AS tagclass, "; + } + // task_tag join table is now always included in join + $from .= ' +LEFT JOIN {list_tag} tg ON tt.tag_id = tg.tag_id '; + $groupby .= 'tt.task_id, '; + $cfrom .= ' +LEFT JOIN {list_tag} tg ON tt.tag_id = tg.tag_id '; + $cgroupbyarr[] = 't.task_id'; + $cgroupbyarr[] = 'tt.task_id'; + + + # use preparsed task description cache for dokuwiki when possible + if($conf['general']['syntax_plugin']=='dokuwiki' && FLYSPRAY_USE_CACHE==true){ + $select.=' MIN(cache.content) desccache, '; + $from.=' +LEFT JOIN {cache} cache ON t.task_id=cache.topic AND cache.type=\'task\' '; + } else { + $select .= 'NULL AS desccache, '; + } + + if (array_get($args, 'only_primary')) { + $where[] = 'NOT EXISTS (SELECT 1 FROM {dependencies} dep WHERE dep.dep_task_id = t.task_id)'; + } + + # feature FS#1600 + if (array_get($args, 'only_blocker')) { + $where[] = 'EXISTS (SELECT 1 FROM {dependencies} dep WHERE dep.dep_task_id = t.task_id)'; + } + + if (array_get($args, 'only_blocked')) { + $where[] = 'EXISTS (SELECT 1 FROM {dependencies} dep WHERE dep.task_id = t.task_id)'; + } + + # feature FS#1599 + if (array_get($args, 'only_unblocked')) { + $where[] = 'NOT EXISTS (SELECT 1 FROM {dependencies} dep WHERE dep.task_id = t.task_id)'; + } + + if (array_get($args, 'hide_subtasks')) { + $where[] = 't.supertask_id = 0'; + } + + if (array_get($args, 'only_watched')) { + $where[] = 'EXISTS (SELECT 1 FROM {notifications} fsn WHERE t.task_id = fsn.task_id AND fsn.user_id = ?)'; + $sql_params[] = $user->id; + } + + if ($proj->id) { + $where[] = 't.project_id = ?'; + $sql_params[] = $proj->id; + } else { + if (!$user->isAnon()) { // Anon-case handled later. + $allowed = array(); + foreach($fs->projects as $p) { + $allowed[] = $p['project_id']; + } + if(count($allowed)>0){ + $where[] = 't.project_id IN (' . implode(',', $allowed). ')'; + }else{ + $where[] = '0 = 1'; # always empty result + } + } + } + + // process users viewing rights, if not anonymous + if (!$user->isAnon()) { + $where[] = ' +( -- Begin block where users viewing rights are checked. + -- Case everyone can see all project tasks anyway and task not private + (t.mark_private = 0 AND p.others_view = 1) + OR + -- Case admin or project manager, can see any task, even private + (gpg.is_admin = 1 OR gpg.manage_project = 1 OR pg.is_admin = 1 OR pg.manage_project = 1) + OR + -- Case allowed to see all tasks, but not private + ((gpg.view_tasks = 1 OR pg.view_tasks = 1) AND t.mark_private = 0) + OR + -- Case allowed to see own tasks (automatically covers private tasks also for this user!) + ((gpg.view_own_tasks = 1 OR pg.view_own_tasks = 1) AND (t.opened_by = ? OR ass.user_id = ?)) + OR + -- Case task is private, but user either opened it or is an assignee + (t.mark_private = 1 AND (t.opened_by = ? OR ass.user_id = ?)) + OR + -- Leave groups tasks as the last one to check. They are the only ones that actually need doing a subquery + -- for checking viewing rights. There\'s a chance that a previous check already matched and the subquery is + -- not executed at all. All this of course depending on how the database query optimizer actually chooses + -- to fetch the results and execute this query... At least it has been given the hint. + + -- Case allowed to see groups tasks, all projects (NOTE: both global and project specific groups accepted here) + -- Strange... do not use OR here with user_id in EXISTS clause, seems to prevent using index with both mysql and + -- postgresql, query times go up a lot. So it\'ll be 2 different EXISTS OR\'ed together. + (gpg.view_groups_tasks = 1 AND t.mark_private = 0 AND ( + EXISTS (SELECT 1 FROM {users_in_groups} WHERE (group_id = pg.group_id OR group_id = gpg.group_id) AND user_id = t.opened_by) + OR + EXISTS (SELECT 1 FROM {users_in_groups} WHERE (group_id = pg.group_id OR group_id = gpg.group_id) AND user_id = ass.user_id) + )) + OR + -- Case allowed to see groups tasks, current project. Only project group allowed here. + (pg.view_groups_tasks = 1 AND t.mark_private = 0 AND ( + EXISTS (SELECT 1 FROM {users_in_groups} WHERE group_id = pg.group_id AND user_id = t.opened_by) + OR + EXISTS (SELECT 1 FROM {users_in_groups} WHERE group_id = pg.group_id AND user_id = ass.user_id) + )) +) -- Rights have been checked +'; + $sql_params[] = $user->id; + $sql_params[] = $user->id; + $sql_params[] = $user->id; + $sql_params[] = $user->id; + } + /// process search-conditions {{{ + $submits = array('type' => 'task_type', 'sev' => 'task_severity', + 'due' => 'closedby_version', 'reported' => 'product_version', + 'cat' => 'product_category', 'status' => 'item_status', + 'percent' => 'percent_complete', 'pri' => 'task_priority', + 'dev' => array('ass.user_id', 'u.user_name', 'u.real_name'), + 'opened' => array('opened_by', 'uo.user_name', 'uo.real_name'), + 'closed' => array('closed_by', 'uc.user_name', 'uc.real_name')); + foreach ($submits as $key => $db_key) { + $type = array_get($args, $key, ($key == 'status') ? 'open' : ''); + settype($type, 'array'); + + if (in_array('', $type)) { + continue; + } + + $temp = ''; + $condition = ''; + foreach ($type as $val) { + // add conditions for the status selection + if ($key == 'status' && $val == 'closed' && !in_array('open', $type)) { + $temp .= ' is_closed = 1 AND'; + } elseif ($key == 'status' && !in_array('closed', $type)) { + $temp .= ' is_closed = 0 AND'; + } + if (is_numeric($val) && !is_array($db_key) && !($key == 'status' && $val == 'closed')) { + $temp .= ' ' . $db_key . ' = ? OR'; + $sql_params[] = $val; + } elseif (is_array($db_key)) { + if ($key == 'dev' && ($val == 'notassigned' || $val == '0' || $val == '-1')) { + $temp .= ' ass.user_id is NULL OR'; + } else { + foreach ($db_key as $singleDBKey) { + if(ctype_digit($val) && strpos($singleDBKey, '_name') === false) { + $temp .= ' ' . $singleDBKey . ' = ? OR'; + $sql_params[] = $val; + } elseif (!ctype_digit($val) && strpos($singleDBKey, '_name') !== false) { + $temp .= ' ' . $singleDBKey . " $LIKEOP ? OR"; + $sql_params[] = '%' . $val . '%'; + } + } + } + } + + // Add the subcategories to the query + if ($key == 'cat') { + $result = $db->query('SELECT * + FROM {list_category} + WHERE category_id = ?', array($val)); + $cat_details = $db->fetchRow($result); + + $result = $db->query('SELECT * + FROM {list_category} + WHERE lft > ? AND rgt < ? AND project_id = ?', array($cat_details['lft'], $cat_details['rgt'], $cat_details['project_id'])); + while ($row = $db->fetchRow($result)) { + $temp .= ' product_category = ? OR'; + $sql_params[] = $row['category_id']; + } + } + } + + if ($temp) { + $where[] = '(' . substr($temp, 0, -3) . ')'; # strip last ' OR' and 'AND' + } + } +/// }}} + + $order_keys = array( + 'id' => 't.task_id', + 'project' => 'project_title', + 'tasktype' => 'tasktype_name', + 'dateopened' => 'date_opened', + 'summary' => 'item_summary', + 'severity' => 'task_severity', + 'category' => 'lc.category_name', + 'status' => 'is_closed, item_status', + 'dueversion' => 'lvc.list_position', + 'duedate' => 'due_date', + 'progress' => 'percent_complete', + 'lastedit' => 'max_date', + 'priority' => 'task_priority', + 'openedby' => 'uo.real_name', + 'reportedin' => 't.product_version', + 'assignedto' => 'u.real_name', + 'dateclosed' => 't.date_closed', + 'os' => 'los.os_name', + 'votes' => 'num_votes', + 'attachments' => 'num_attachments', + 'comments' => 'num_comments', + 'private' => 'mark_private', + 'supertask' => 't.supertask_id', + ); + + // make sure that only columns can be sorted that are visible (and task severity, since it is always loaded) + $order_keys = array_intersect_key($order_keys, array_merge(array_flip($visible), array('severity' => 'task_severity'))); + + // Implementing setting "Default order by" + if (!array_key_exists('order', $args)) { + # now also for $proj->id=0 (allprojects) + $orderBy = $proj->prefs['sorting'][0]['field']; + $sort = $proj->prefs['sorting'][0]['dir']; + if (count($proj->prefs['sorting']) >1){ + $orderBy2 =$proj->prefs['sorting'][1]['field']; + $sort2= $proj->prefs['sorting'][1]['dir']; + } else{ + $orderBy2='severity'; + $sort2='DESC'; + } + } else { + $orderBy = $args['order']; + $sort = $args['sort']; + $orderBy2='severity'; + $sort2='desc'; + } + + // TODO: Fix this! If something is already ordered by task_id, there's + // absolutely no use to even try to order by something else also. + $order_column[0] = $order_keys[Filters::enum(array_get($args, 'order', $orderBy), array_keys($order_keys))]; + $order_column[1] = $order_keys[Filters::enum(array_get($args, 'order2', $orderBy2), array_keys($order_keys))]; + $sortorder = sprintf('%s %s, %s %s, t.task_id ASC', + $order_column[0], + Filters::enum(array_get($args, 'sort', $sort), array('asc', 'desc')), + $order_column[1], + Filters::enum(array_get($args, 'sort2', $sort2), array('asc', 'desc')) + ); + + $having = array(); + $dates = array('duedate' => 'due_date', 'changed' => $maxdatesql, + 'opened' => 'date_opened', 'closed' => 'date_closed'); + foreach ($dates as $post => $db_key) { + $var = ($post == 'changed') ? 'having' : 'where'; + if ($date = array_get($args, $post . 'from')) { + ${$var}[] = '(' . $db_key . ' >= ' . Flyspray::strtotime($date) . ')'; + } + if ($date = array_get($args, $post . 'to')) { + ${$var}[] = '(' . $db_key . ' <= ' . Flyspray::strtotime($date) . ' AND ' . $db_key . ' > 0)'; + } + } + + if (array_get($args, 'string')) { + $words = explode(' ', strtr(array_get($args, 'string'), '()', ' ')); + $comments = ''; + $where_temp = array(); + + if (array_get($args, 'search_in_comments')) { + $comments .= " OR c.comment_text $LIKEOP ?"; + } + if (array_get($args, 'search_in_details')) { + $comments .= " OR t.detailed_desc $LIKEOP ?"; + } + + foreach ($words as $word) { + $word=trim($word); + if($word==''){ + continue; + } + $likeWord = '%' . str_replace('+', ' ', $word) . '%'; + $where_temp[] = "(t.item_summary $LIKEOP ? OR t.task_id = ? $comments)"; + array_push($sql_params, $likeWord, intval($word)); + if (array_get($args, 'search_in_comments')) { + array_push($sql_params, $likeWord); + } + if (array_get($args, 'search_in_details')) { + array_push($sql_params, $likeWord); + } + } + + if(count($where_temp)>0){ + $where[] = '(' . implode((array_get($args, 'search_for_all') ? ' AND ' : ' OR '), $where_temp) . ')'; + } + } + + if ($user->isAnon()) { + $where[] = 't.mark_private = 0 AND p.others_view = 1'; + if(array_key_exists('status', $args)){ + if (in_array('closed', $args['status']) && !in_array('open', $args['status'])) { + $where[] = 't.is_closed = 1'; + } elseif (in_array('open', $args['status']) && !in_array('closed', $args['status'])) { + $where[] = 't.is_closed = 0'; + } + } + } + + $where = (count($where)) ? 'WHERE ' . join(' AND ', $where) : ''; + + // Get the column names of table tasks for the group by statement + if (!strcasecmp($conf['database']['dbtype'], 'pgsql')) { + $groupby .= "p.project_title, p.project_is_active, "; + // Remove this after checking old PostgreSQL docs. + // 1 column from task table should be enough, after + // already grouping by task_id, there's no possibility + // to have anything more in that table to group by. + $groupby .= $db->getColumnNames('{tasks}', 't.task_id', 't.'); + } else { + $groupby = 't.task_id'; + } + + $having = (count($having)) ? 'HAVING ' . join(' AND ', $having) : ''; + + // echo '<pre>' . print_r($args, true) . '</pre>'; + // echo '<pre>' . print_r($cgroupbyarr, true) . '</pre>'; + $cgroupby = count($cgroupbyarr) ? 'GROUP BY ' . implode(',', array_unique($cgroupbyarr)) : ''; + + $sqlcount = "SELECT COUNT(*) FROM (SELECT 1, t.task_id, t.date_opened, t.date_closed, t.last_edited_time + FROM $cfrom + $where + $cgroupby + $having) s"; + $sqltext = "SELECT t.*, $select +p.project_title, p.project_is_active +FROM $from +$where +GROUP BY $groupby +$having +ORDER BY $sortorder"; + + // Very effective alternative with a little bit more work + // and if row_number() can be emulated in mysql. Idea: + // Move every join and other operation not needed in + // the inner clause to select rows to the outer query, + // and do the rest when we already know which rows + // are in the window to show. Got it to run constantly + // under 6000 ms. + /* Leave this for next version, don't have enough time for testing. + $sqlexperiment = "SELECT * FROM ( +SELECT row_number() OVER(ORDER BY task_id) AS rownum, +t.*, $select p.project_title, p.project_is_active FROM $from +$where +GROUP BY $groupby +$having +ORDER BY $sortorder +) +t WHERE rownum BETWEEN $offset AND " . ($offset + $perpage); +*/ + +// echo '<pre>'.print_r($sql_params, true).'</pre>'; # for debugging +// echo '<pre>'.$sqlcount.'</pre>'; # for debugging +// echo '<pre>'.$sqltext.'</pre>'; # for debugging + $sql = $db->query($sqlcount, $sql_params); + $totalcount = $db->fetchOne($sql); + +# 20150313 peterdd: Do not override task_type with tasktype_name until we changed t.task_type to t.task_type_id! We need the id too. + + $sql = $db->query($sqltext, $sql_params, $perpage, $offset); + // $sql = $db->query($sqlexperiment, $sql_params); + $tasks = $db->fetchAllArray($sql); + $id_list = array(); + $limit = array_get($args, 'limit', -1); + $forbidden_tasks_count = 0; + foreach ($tasks as $key => $task) { + $id_list[] = $task['task_id']; + if (!$user->can_view_task($task)) { + unset($tasks[$key]); + $forbidden_tasks_count++; + } + } + +// Work on this is not finished until $forbidden_tasks_count is always zero. +// echo "<pre>$offset : $perpage : $totalcount : $forbidden_tasks_count</pre>"; + return array($tasks, $id_list, $totalcount, $forbidden_tasks_count); +// # end alternative + } + +# end get_task_list +} # end class diff --git a/includes/class.csp.php b/includes/class.csp.php new file mode 100644 index 0000000..ecf4541 --- /dev/null +++ b/includes/class.csp.php @@ -0,0 +1,106 @@ +<?php +/** + * + * A simple class for dynamic construction of Content-Security-Policy HTTP header string. + * + * This is just quick write to get the job for Flyspray done. May change completely! + * + * It does not check if the added rules are valid or make sense in the context and http request/response! + * So it is currently up to the code sections who use that class that the resulting csp string is correct. + * + * @license http://opensource.org/licenses/lgpl-license.php Lesser GNU Public License + * @author peterdd + * + * @see https://www.w3.org/TR/CSP2/ + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP + * @see https://caniuse.com/#search=csp + * + * Example: $csp=new ContentSecurityPolicy(); $csp->add('default-src', "'none'"); $csp->add('style-src', "'self'"); $csp->emit(); + */ +class ContentSecurityPolicy { + + #private $csp=array(); + public $csp=array(); + + # for debugging just to track which extension/plugins wants to add csp-entries + #public $history=array(); + + function __construct(){ + $this->csp=array(); + } + + /** + * get the constructed concatenated value string part for the http Content-Security-Header + * + * TODO: maybe some syntax checks and logical verification when building the string. + * + * MAYBE: add optional parameter to get only a part like $csp->get('img-src') + * Alternatively the user can just access the currently public $csp->csp['img-src'] to get that values as array. + * + */ + public function get(){ + $out = ''; + foreach ( $this->csp as $key => $values ) { + $out .= $key.' '.implode(' ', $values).'; '; + } + $out = trim($out, '; '); + return $out; + } + + /** + * adds a value to a csp type + * + * @param type + * @param value single values for a type + * + * examples: + * $csp->add('default-src', "'self'"); # surrounding double quotes "" used to pass the single quotes + * $csp->add('img-src', 'mycdn.example.com'); # single quoted string ok + */ + public function add($type, $value){ + if( isset($this->csp[$type]) ) { + if( !in_array( $value, $this->csp[$type] ) ) { + $this->csp[$type][] = $value; + } + } else { + $this->csp[$type] = array($value); + } + #$this->history[]=debug_backtrace()[1]; + } + + /** + * sends the Content-Security-Policy http headers + */ + public function emit() { + $string=$this->get(); + header('Content-Security-Policy: '.$string ); + # some older web browsers used vendor prefixes before csp got w3c recommendation. + # maybe use useragent string to detect who should receive this outdated vendor csp strings. + # for IE 10-11 + header('X-Content-Security-Policy: '.$string ); + # for Chrome 15-24, Safari 5.1-6, .. + header('X-WebKit-CSP: '.$string ); + } + + /** + * Put the csp as meta-tags in the HTML-head section. + * + * Q: What is the benefit of adding csp as meta tags too? + * + * I don't know, maybe this way the csp persist if someone saves a page to his harddrive for instance or if bad web proxies rip off csp http headers? + * Do webbrowsers store the CSP-HTTP header to the HTML-head as metatags automatically if there is no related metatag in the original page? Mhh.. + */ + public function getMeta() { + $string=$this->get(); + $out= '<meta http-equiv="Content-Security-Policy" content="'.$string.'">'; + # enable if you think it is necessary for your customers. + # older web browsers used vendor prefixes before csp2 got a w3c recommendation standard.. + # maybe use useragent string to detect who should receive this outdated vendor csp strings. + # for IE 10-11 + $out.= '<meta http-equiv="X-Content-Security-Policy" content="'.$string.'">'; + # for Chrome 15-24, Safari 5.1-6, .. + $out.= '<meta http-equiv="X-WebKit-CSP" content="'.$string.'">'; + return $out; + } + +} diff --git a/includes/class.database.php b/includes/class.database.php new file mode 100644 index 0000000..652cc98 --- /dev/null +++ b/includes/class.database.php @@ -0,0 +1,434 @@ +<?php +/** + * Flyspray + * + * Database class class + * + * This class is a wrapper for ADOdb functions. + * + * @license http://opensource.org/licenses/lgpl-license.php Lesser GNU Public License + * @package flyspray + * @author Tony Collins + * @author Cristian Rodriguez + */ + +if (!defined('IN_FS')) { + die('Do not access this file directly.'); +} + +class Database +{ + /** + * Table prefix, usually flyspray_ + * @var string + * @access private + */ + public $dbprefix; + + /** + * Cache for queries done by cached_query() + * @var array + * @access private + * @see cached_query(); + */ + private $cache = array(); + + /** + * dblink + * adodb handler object + * @var object + * @access public + */ + public $dblink = null; + + /** + * Open a connection to the database quickly + * @param array $conf connection data + * @return void + */ + public function dbOpenFast($conf) + { + if(!is_array($conf) || extract($conf, EXTR_REFS|EXTR_SKIP) < 5) { + + die( 'Flyspray was unable to connect to the database. ' + .'Check your settings in flyspray.conf.php'); + } + + $this->dbOpen($dbhost, $dbuser, $dbpass, $dbname, $dbtype, isset($dbprefix) ? $dbprefix : ''); + } + + /** + * Open a connection to the database and set connection parameters + * @param string $dbhost hostname where the database server uses + * @param string $dbuser username to connect to the database + * @param string $dbpass password to connect to the database + * @param string $dbname + * @param string $dbtype database driver to use, currently : "mysql", "mysqli", "pgsql" + * "pdo_mysql" and "pdo_pgsql" experimental + * @param string $dbprefix database prefix. + */ + public function dbOpen($dbhost = '', $dbuser = '', $dbpass = '', $dbname = '', $dbtype = '', $dbprefix = '') + { + + $this->dbtype = $dbtype; + $this->dbprefix = $dbprefix; + $ADODB_COUNTRECS = false; + + # 20160408 peterdd: hack to enable database socket usage with adodb-5.20.3 + # For instance on german 1und1 managed linux servers, e.g. $dbhost='localhost:/tmp/mysql5.sock' + if( ($dbtype=='mysqli' || $dbtype='pdo_mysql') && 'localhost:/'==substr($dbhost,0,11) ){ + $dbsocket=substr($dbhost,10); + $dbhost='localhost'; + if($dbtype=='mysqli'){ + ini_set('mysqli.default_socket', $dbsocket ); + }else{ + ini_set('pdo_mysql.default_socket',$dbsocket); + } + } + + # adodb for pdo is a bit different then the others at the moment (adodb 5.20.4) + # see http://adodb.org/dokuwiki/doku.php?id=v5:database:pdo + if($this->dbtype=='pdo_mysql'){ + $this->dblink = ADOnewConnection('pdo'); + $dsnString= 'host='.$dbhost.';dbname='.$dbname.';charset=utf8mb4'; + $this->dblink->connect('mysql:' . $dsnString, $dbuser, $dbpass); + }else{ + $this->dblink = ADOnewConnection($this->dbtype); + $this->dblink->connect($dbhost, $dbuser, $dbpass, $dbname); + } + + if ($this->dblink === false || (!empty($this->dbprefix) && !preg_match('/^[a-z][a-z0-9_]+$/i', $this->dbprefix))) { + + die('Flyspray was unable to connect to the database. ' + .'Check your settings in flyspray.conf.php'); + } + $this->dblink->setFetchMode(ADODB_FETCH_BOTH); + + if($dbtype=='mysqli'){ + $sinfo=$this->dblink->serverInfo(); + if(version_compare($sinfo['version'], '5.5.3')>=0 ){ + $this->dblink->setCharSet('utf8mb4'); + }else{ + $this->dblink->setCharSet('utf8'); + } + }else{ + $this->dblink->setCharSet('utf8'); + } + + // enable debug if constant DEBUG_SQL is defined. + !defined('DEBUG_SQL') || $this->dblink->debug = true; + + if($dbtype === 'mysql' || $dbtype === 'mysqli') { + $dbinfo = $this->dblink->serverInfo(); + if(isset($dbinfo['version']) && version_compare($dbinfo['version'], '5.0.2', '>=')) { + $this->dblink->execute("SET SESSION SQL_MODE='TRADITIONAL'"); + } + } + } + + /** + * Closes the database connection + * @return void + */ + public function dbClose() + { + $this->dblink->close(); + } + + /** + * insert_ID + * + * @access public + */ + public function insert_ID() + { + return $this->dblink->insert_ID(); + } + + /** + * countRows + * Returns the number of rows in a result + * @param object $result + * @access public + * @return int + */ + public function countRows($result) + { + return (int) $result->recordCount(); + } + + /** + * affectedRows + * + * @access public + * @return int + */ + public function affectedRows() + { + return (int) $this->dblink->affected_Rows(); + } + + /** + * fetchRow + * + * @param $result + * @access public + * @return void + */ + + public function fetchRow($result) + { + return $result->fetchRow(); + } + + /** + * fetchCol + * + * @param $result + * @param int $col + * @access public + * @return void + */ + + public function fetchCol($result, $col=0) + { + $tab = array(); + while ($tmp = $result->fetchRow()) { + $tab[] = $tmp[$col]; + } + return $tab; + } + + /** + * query + * + * @param mixed $sql + * @param mixed $inputarr + * @param mixed $numrows + * @param mixed $offset + * @access public + * @return void + */ + + public function query($sql, $inputarr = false, $numrows = -1, $offset = -1) + { + // auto add $dbprefix where we have {table} + $sql = $this->_add_prefix($sql); + // remove conversions for MySQL + if (strcasecmp($this->dbtype, 'pgsql') != 0) { + $sql = str_replace('::int', '', $sql); + $sql = str_replace('::text', '', $sql); + } + + $ADODB_FETCH_MODE = ADODB_FETCH_ASSOC; + + if (($numrows >= 0 ) or ($offset >= 0 )) { + /* adodb drivers are inconsisent with the casting of $numrows and $offset so WE + * cast to integer here anyway */ + $result = $this->dblink->selectLimit($sql, (int) $numrows, (int) $offset, $inputarr); + } else { + $result = $this->dblink->execute($sql, $inputarr); + } + + if (!$result) { + + if(function_exists("debug_backtrace") && defined('DEBUG_SQL')) { + echo "<pre style='text-align: left;'>"; + var_dump(debug_backtrace()); + echo "</pre>"; + } + + $query_params = ''; + + if(is_array($inputarr) && count($inputarr)) { + $query_params = implode(',', array_map(array('Filters','noXSS'), $inputarr)); + } + + die(sprintf("Query {%s} with params {%s} failed! (%s)", + Filters::noXSS($sql), $query_params, Filters::noXSS($this->dblink->errorMsg()))); + + } + + return $result; + } + + /** + * cached_query + * + * @param mixed $idx + * @param mixed $sql + * @param array $sqlargs + * @access public + * @return array + */ + public function cached_query($idx, $sql, $sqlargs = array()) + { + if (isset($this->cache[$idx])) { + return $this->cache[$idx]; + } + + $sql = $this->query($sql, $sqlargs); + return ($this->cache[$idx] = $this->fetchAllArray($sql)); + } + + /** + * fetchOne + * + * @param $result + * @access public + * @return array + */ + public function fetchOne($result) + { + $row = $this->fetchRow($result); + return (isset($row[0]) ? $row[0] : ''); + } + + /** + * fetchAllArray + * + * @param $result + * @access public + * @return array + */ + public function fetchAllArray($result) + { + return $result->getArray(); + } + + /** + * groupBy + * + * This groups a result by a single column the way + * MySQL would do it. Postgre doesn't like the queries MySQL needs. + * + * @param object $result + * @param string $column + * @access public + * @return array process the returned array with foreach ($return as $row) {} + */ + public function groupBy($result, $column) + { + $rows = array(); + while ($row = $this->fetchRow($result)) { + $rows[$row[$column]] = $row; + } + return array_values($rows); + } + + /** + * getColumnNames + * + * @param mixed $table + * @param mixed $alt + * @param mixed $prefix + * @access public + * @return void + */ + + public function getColumnNames($table, $alt, $prefix) + { + global $conf; + + if (strcasecmp($conf['database']['dbtype'], 'pgsql')) { + return $alt; + } + + $table = $this->_add_prefix($table); + $fetched_columns = $this->query('SELECT column_name FROM information_schema.columns WHERE table_name = ?', + array(str_replace('"', '', $table))); + $fetched_columns = $this->fetchAllArray($fetched_columns); + + foreach ($fetched_columns as $key => $value) + { + $col_names[$key] = $prefix . $value[0]; + } + + $groupby = implode(', ', $col_names); + + return $groupby; + } + + /** + * replace + * + * Try to update a record, + * and if the record is not found, + * an insert statement is generated and executed. + * + * @param string $table + * @param array $field + * @param array $keys + * @param bool $autoquote + * @access public + * @return integer 0 on error, 1 on update. 2 on insert + */ + public function replace($table, $field, $keys, $autoquote = true) + { + $table = $this->_add_prefix($table); + return $this->dblink->replace($table, $field, $keys, $autoquote); + } + + /** + * Adds the table prefix + * @param string $sql_data table name or sql query + * @return string sql with correct,quoted table prefix + * @access private + * @since 0.9.9 + */ + private function _add_prefix($sql_data) + { + return preg_replace('/{([\w\-]*?)}/', $this->quoteIdentifier($this->dbprefix . '\1'), $sql_data); + } + + /** + * Helper method to quote an indentifier + * (table or field name) with the database specific quote + * @param string $ident table or field name to be quoted + * @return string + * @access public + * @since 0.9.9 + */ + public function quoteIdentifier($ident) + { + return (string) $this->dblink->nameQuote . $ident . $this->dblink->nameQuote ; + } + + /** + * Quote a string in a safe way to be entered to the database + * (for the very few cases we don't use prepared statements) + * + * @param string $string string to be quoted + * @return string quoted string + * @access public + * @since 0.9.9 + * @notes please use this little as possible, always prefer prepared statements + */ + public function qstr($string) + { + return $this->dblink->qstr($string, false); + } + + /** + * fill_placeholders + * a convenience function to fill sql query placeholders + * according to the number of columns to be used. + * @param array $cols + * @param integer $additional generate N additional placeholders + * @access public + * @return string comma separated "?" placeholders + * @static + */ + public function fill_placeholders($cols, $additional=0) + { + if(is_array($cols) && count($cols) && is_int($additional)) { + + return join(',', array_fill(0, (count($cols) + $additional), '?')); + + } else { + //this is not an user error, is a programmer error. + trigger_error("incorrect data passed to fill_placeholders", E_USER_ERROR); + } + } + // End of Database Class +} diff --git a/includes/class.effort.php b/includes/class.effort.php new file mode 100644 index 0000000..984feeb --- /dev/null +++ b/includes/class.effort.php @@ -0,0 +1,302 @@ +<?php + +/** + * Class effort + * + * Task level Effort Tracking functionality. + */ +class effort +{ + const FORMAT_HOURS_COLON_MINUTES = 0; // Default value in database + const FORMAT_HOURS_SPACE_MINUTES = 1; + const FORMAT_HOURS_PLAIN = 2; + const FORMAT_HOURS_ONE_DECIMAL = 3; + const FORMAT_MINUTES = 4; + const FORMAT_DAYS_PLAIN = 5; + const FORMAT_DAYS_ONE_DECIMAL = 6; + const FORMAT_DAYS_PLAIN_HOURS_PLAIN = 7; + const FORMAT_DAYS_PLAIN_HOURS_ONE_DECIMAL = 8; + const FORMAT_DAYS_PLAIN_HOURS_COLON_MINUTES = 9; + const FORMAT_DAYS_PLAIN_HOURS_SPACE_MINUTES = 10; + + private $_task_id; + private $_userId; + public $details; + + /** + * Class Constructor: Requires the user id and task id as all effort is in context of the task. + * + * @param $task_id + * @param $user_id + */ + public function __construct($task_id,$user_id) + { + $this->_task_id = $task_id; + $this->_userId = $user_id; + } + + /** + * Manually add effort to the effort table for this issue / user. + * + * @param $effort_to_add int amount of effort in hh:mm to add to effort table. + */ + public function addEffort($effort_to_add, $proj) + { + global $db; + + # note: third parameter seem useless, not used by EditStringToSeconds().., maybe drop it.. + $effort = self::editStringToSeconds($effort_to_add, $proj->prefs['hours_per_manday'], $proj->prefs['estimated_effort_format']); + if ($effort === FALSE) { + Flyspray::show_error(L('invalideffort')); + return false; + } + + # quickfix to avoid useless table entries. + if($effort==0){ + Flyspray::show_error(L('zeroeffort')); + return false; + } else{ + $db->query('INSERT INTO {effort} + (task_id, date_added, user_id,start_timestamp,end_timestamp,effort) + VALUES ( ?, ?, ?, ?,?,? )', + array($this->_task_id, time(), $this->_userId,time(),time(),$effort) + ); + return true; + } + } + + /** + * Starts tracking effort for the current user against the current issue. + * + * @return bool Returns Success or Failure of the action. + */ + public function startTracking() + { + global $db; + + //check if the user is already tracking time against this task. + $result = $db->query('SELECT * FROM {effort} WHERE task_id ='.$this->_task_id.' AND user_id='.$this->_userId.' AND end_timestamp IS NULL;'); + if($db->countRows($result)>0) + { + return false; + } + else + { + $db->query('INSERT INTO {effort} + (task_id, date_added, user_id,start_timestamp) + VALUES ( ?, ?, ?, ? )', + array ($this->_task_id, time(), $this->_userId,time())); + + return true; + } + } + + /** + * Stops tracking the current tracking request and then updates the actual hours field on the table, this + * is useful as both stops constant calculation from start/end timestamps and provides a quick aggregation + * method as we only need to deal with one field. + */ + public function stopTracking() + { + global $db; + + $time = time(); + + + $sql = $db->query('SELECT start_timestamp FROM {effort} WHERE user_id='.$this->_userId.' AND task_id='.$this->_task_id.' AND end_timestamp IS NULL;'); + $result = $db->fetchRow($sql); + $start_time = $result[0]; + $seconds = $time - $start_time; + + // Round to full minutes upwards. + $effort = ($seconds % 60 == 0 ? $seconds : floor($seconds / 60) * 60 + 60); + + $sql = $db->query("UPDATE {effort} SET end_timestamp = ".$time.",effort = ".$effort." WHERE user_id=".$this->_userId." AND task_id=".$this->_task_id." AND end_timestamp IS NULL;"); + } + + /** + * Removes any outstanding tracking requests for this task for this user. + */ + public function cancelTracking() + { + global $db; + + # 2016-07-04: also remove invalid finished 0 effort entries that were accidently possible up to Flyspray 1.0-rc + $db->query('DELETE FROM {effort} + WHERE user_id='.$this->_userId.' + AND task_id='.$this->_task_id.' + AND ( + end_timestamp IS NULL + OR (start_timestamp=end_timestamp AND effort=0) + );' + ); + } + + public function populateDetails() + { + global $db; + + $this->details = $db->query('SELECT * FROM {effort} WHERE task_id ='.$this->_task_id.';'); + } + + public static function secondsToString($seconds, $factor, $format) { + if ($seconds == 0) { + return ''; + } + + $factor = ($factor == 0 ? 86400 : $factor); + + switch ($format) { + case self::FORMAT_HOURS_COLON_MINUTES: + $seconds = ($seconds % 60 == 0 ? $seconds : floor($seconds / 60) * 60 + 60); + $hours = floor($seconds / 3600); + $minutes = floor(($seconds - ($hours * 3600)) / 60); + return sprintf('%01u:%02u', $hours, $minutes); + break; + case self::FORMAT_HOURS_SPACE_MINUTES: + $seconds = ($seconds % 60 == 0 ? $seconds : floor($seconds / 60) * 60 + 60); + $hours = floor($seconds / 3600); + $minutes = floor(($seconds - ($hours * 3600)) / 60); + if ($hours == 0) { + return sprintf('%u %s', $minutes, L('minuteabbrev')); + } else { + return sprintf('%u %s %u %s', $hours, L('hourabbrev'), $minutes, L('minuteabbrev')); + } + break; + case self::FORMAT_HOURS_PLAIN: + $hours = ceil($seconds / 3600); + return sprintf('%01u %s', $hours, ($hours == 1 ? L('hoursingular') : L('hourplural'))); + break; + case self::FORMAT_HOURS_ONE_DECIMAL: + $hours = round(ceil($seconds * 10 / 3600) / 10, 1); + return sprintf('%01.1f %s', $hours, ($hours == 1 ? L('hoursingular') : L('hourplural'))); + break; + case self::FORMAT_MINUTES: + $minutes = ceil($seconds / 60); + return sprintf('%01u %s', $minutes, L('minuteabbrev')); + break; + case self::FORMAT_DAYS_PLAIN: + $days = ceil($seconds / $factor); + return sprintf('%01u %s', $days, ($days == 1 ? L('manday') : L('mandays'))); + break; + case self::FORMAT_DAYS_ONE_DECIMAL: + $days = round(ceil($seconds * 10 / $factor) / 10, 1); + return sprintf('%01.1f %s', $days, ($days == 1 ? L('manday') : L('mandays'))); + break; + case self::FORMAT_DAYS_PLAIN_HOURS_PLAIN: + $days = floor($seconds / $factor); + $hours = ceil(($seconds - ($days * $factor)) / 3600); + if ($days == 0) { + return sprintf('%1u %s', $hours, L('hourabbrev')); + } else { + return sprintf('%u %s %1u %s', $days, L('mandayabbrev'), $hours, L('hourabbrev')); + } + break; + case self::FORMAT_DAYS_PLAIN_HOURS_ONE_DECIMAL: + $days = floor($seconds / $factor); + $hours = round(ceil(($seconds - ($days * $factor)) * 10 / 3600) / 10, 1); + if ($days == 0) { + return sprintf('%01.1f %s', $hours, L('hourabbrev')); + } else { + return sprintf('%u %s %01.1f %s', $days, L('mandayabbrev'), $hours, L('hourabbrev')); + } + break; + case self::FORMAT_DAYS_PLAIN_HOURS_COLON_MINUTES: + $seconds = ($seconds % 60 == 0 ? $seconds : floor($seconds / 60) * 60 + 60); + $days = floor($seconds / $factor); + $hours = floor(($seconds - ($days * $factor)) / 3600); + $minutes = floor(($seconds - (($days * $factor) + ($hours * 3600))) / 60); + if ($days == 0) { + return sprintf('%01u:%02u', $hours, $minutes); + } else { + return sprintf('%u %s %01u:%02u', $days, L('mandayabbrev'), $hours, $minutes); + } + break; + case self::FORMAT_DAYS_PLAIN_HOURS_SPACE_MINUTES: + $seconds = ($seconds % 60 == 0 ? $seconds : floor($seconds / 60) * 60 + 60); + $days = floor($seconds / $factor); + $hours = floor(($seconds - ($days * $factor)) / 3600); + $minutes = floor(($seconds - (($days * $factor) + ($hours * 3600))) / 60); + if ($days == 0) { + return sprintf('%u %s %u %s', $hours, L('hourabbrev'), $minutes, L('minuteabbrev')); + } else { + return sprintf('%u %s %u %s %u %s', $days, L('mandayabbrev'), $hours, L('hourabbrev'), $minutes, L('minuteabbrev')); + } + break; + default: + $seconds = ($seconds % 60 == 0 ? $seconds : floor($seconds / 60) * 60 + 60); + $hours = floor($seconds / 3600); + $minutes = floor(($seconds - ($hours * 3600)) / 60); + return sprintf('%01u:%02u', $hours, $minutes); + } + } + + public static function secondsToEditString($seconds, $factor, $format) { + $factor = ($factor == 0 ? 86400 : $factor); + + // Adjust seconds to be evenly dividable by 60, so + // 3595 -> 3600, floor can be safely used for minutes in formats + // and the result will be 1:00 instead of 0:60 (if ceil would be used). + + $seconds = ($seconds % 60 == 0 ? $seconds : floor($seconds / 60) * 60 + 60); + + switch ($format) { + case self::FORMAT_HOURS_COLON_MINUTES: + case self::FORMAT_HOURS_SPACE_MINUTES: + case self::FORMAT_HOURS_PLAIN: + case self::FORMAT_HOURS_ONE_DECIMAL: + case self::FORMAT_MINUTES: + $hours = floor($seconds / 3600); + $minutes = floor(($seconds - ($hours * 3600)) / 60); + return sprintf('%01u:%02u', $hours, $minutes); + break; + case self::FORMAT_DAYS_PLAIN: + case self::FORMAT_DAYS_ONE_DECIMAL: + case self::FORMAT_DAYS_PLAIN_HOURS_PLAIN: + case self::FORMAT_DAYS_PLAIN_HOURS_ONE_DECIMAL: + case self::FORMAT_DAYS_PLAIN_HOURS_COLON_MINUTES: + case self::FORMAT_DAYS_PLAIN_HOURS_SPACE_MINUTES: + $days = floor($seconds / $factor); + $hours = floor(($seconds - ($days * $factor)) / 3600); + $minutes = floor(($seconds - ($hours * 3600)) / 60); + if ($days == 0) { + return sprintf('%01u:%02u', $hours, $minutes); + } else { + return sprintf('%u %02u:%02u', $days, $hours, $minutes); + } + break; + default: + $hours = floor($seconds / 3600); + $minutes = floor(($seconds - (($days * $factor) + ($hours * 3600))) / 60); + return sprintf('%01u:%02u', $hours, $minutes); + } + } + + public static function editStringToSeconds($string, $factor, $format) { + if (!isset($string) || empty($string)) { + return 0; + } + + $factor = ($factor == 0 ? 86400 : $factor); + + $matches = array(); + if (preg_match('/^((\d+)\s)?(\d+)(:(\d{2}))?$/', $string, $matches) !== 1) { + return FALSE; + } + + if (!isset($matches[2])) { + $matches[2] = 0; + } + + if (!isset($matches[5])) { + $matches[5] = 0; + } else { + if ($matches[5] > 59) { + return FALSE; + } + } + + $effort = ($matches[2] * $factor) + ($matches[3] * 3600) + ($matches[5] * 60); + return $effort; + } +} diff --git a/includes/class.flyspray.php b/includes/class.flyspray.php new file mode 100644 index 0000000..1dc2352 --- /dev/null +++ b/includes/class.flyspray.php @@ -0,0 +1,1471 @@ +<?php +/** + * Flyspray + * + * Flyspray class + * + * This script contains all the functions we use often in + * Flyspray to do miscellaneous things. + * + * @license http://opensource.org/licenses/lgpl-license.php Lesser GNU Public License + * @package flyspray + * @author Tony Collins + * @author Florian Schmitz + * @author Cristian Rodriguez + */ + +class Flyspray +{ + + /** + * Current Flyspray version. Change this for each release. Don't forget! + * @access public + * @var string + * For github development use e.g. '1.0-beta dev' ; Flyspray::base_version() currently splits on the ' ' ... + * For making github release use e.g. '1.0-beta' here. + * For online version check www.flyspray.org/version.txt use e.g. '1.0-beta' + * For making releases on github use github's recommended versioning e.g. 'v1.0-beta' --> release files are then named v1.0-beta.zip and v1.0-beta.tar.gz and unzips to a flyspray-1.0-beta/ directory. + * Well, looks like a mess but hopefully consolidate this in future. Maybe use version_compare() everywhere in future instead of an own invented Flyspray::base_version() + */ + public $version = '1.0-rc9'; + + /** + * Flyspray preferences + * @access public + * @var array + */ + public $prefs = array(); + + /** + * Max. file size for file uploads. 0 = no uploads allowed + * @access public + * @var integer + */ + public $max_file_size = 0; + + /** + * List of projects the user is allowed to view + * @access public + * @var array + */ + public $projects = array(); + + /** + * List of severities. Loaded in i18n.inc.php + * @access public + * @var array + */ + public $severities = array(); + + /** + * List of priorities. Loaded in i18n.inc.php + * @access public + * @var array + */ + public $priorities = array(); + + /** + * Constructor, starts session, loads settings + * @access private + * @return void + * @version 1.0 + */ + public function __construct() + { + global $db; + + $this->startSession(); + + $res = $db->query('SELECT pref_name, pref_value FROM {prefs}'); + + while ($row = $db->fetchRow($res)) { + $this->prefs[$row['pref_name']] = $row['pref_value']; + } + + $this->setDefaultTimezone(); + + $sizes = array(); + foreach (array(ini_get('memory_limit'), ini_get('post_max_size'), ini_get('upload_max_filesize')) as $val) { + if($val === '-1'){ + // unlimited value in php configuration + $val = PHP_INT_MAX; + } + if (!$val || $val < 0) { + continue; + } + + $last = strtolower($val{strlen($val)-1}); + $val = trim($val, 'gGmMkK'); + switch ($last) { + // The 'G' modifier is available since PHP 5.1.0 + case 'g': + $val *= 1024; + case 'm': + $val *= 1024; + case 'k': + $val *= 1024; + } + + $sizes[] = $val; + } + clearstatcache(); + $this->max_file_size = ( + (bool) ini_get('file_uploads') + && is_file(BASEDIR.DIRECTORY_SEPARATOR.'attachments'.DIRECTORY_SEPARATOR.'index.html') + && is_writable(BASEDIR.DIRECTORY_SEPARATOR.'attachments') + ) ? round((min($sizes)/1024/1024), 1) : 0; + } + + protected function setDefaultTimezone() + { + $default_timezone = isset($this->prefs['default_timezone']) && !empty($this->prefs['default_timezone']) ? $this->prefs['default_timezone'] : 'UTC'; + // set the default time zone - this will be redefined as we go + define('DEFAULT_TIMEZONE',$default_timezone); + date_default_timezone_set(DEFAULT_TIMEZONE); + } + + public static function base_version($version) + { + if (strpos($version, ' ') === false) { + return $version; + } + return substr($version, 0, strpos($version, ' ')); + } + + public static function get_config_path($basedir = BASEDIR) + { + $cfile = $basedir . '/flyspray.conf.php'; + if (is_readable($hostconfig = sprintf('%s/%s.conf.php', $basedir, $_SERVER['SERVER_NAME']))) { + $cfile = $hostconfig; + } + return $cfile; + } + + /** + * Redirects the browser to the page in $url + * This function is based on PEAR HTTP class + * @param string $url + * @param bool $exit + * @param bool $rfc2616 + * @license BSD + * @access public static + * @return bool + * @version 1.0 + */ + public static function redirect($url, $exit = true, $rfc2616 = true) + { + + @ob_clean(); + + if (isset($_SESSION) && count($_SESSION)) { + session_write_close(); + } + + if (headers_sent()) { + die('Headers are already sent, this should not have happened. Please inform Flyspray developers.'); + } + + $url = Flyspray::absoluteURI($url); + + if($_SERVER['REQUEST_METHOD']=='POST' && version_compare(PHP_VERSION, '5.4.0')>=0 ) { + http_response_code(303); + } + header('Location: '. $url); + + if ($rfc2616 && isset($_SERVER['REQUEST_METHOD']) && + $_SERVER['REQUEST_METHOD'] != 'HEAD') { + $url = htmlspecialchars($url, ENT_QUOTES, 'utf-8'); + printf('%s to: <a href="%s">%s</a>.', eL('Redirect'), $url, $url); + } + if ($exit) { + exit; + } + + return true; + } + + /** + * Absolute URI (This function is part of PEAR::HTTP licensed under the BSD) {{{ + * + * This function returns the absolute URI for the partial URL passed. + * The current scheme (HTTP/HTTPS), host server, port, current script + * location are used if necessary to resolve any relative URLs. + * + * Offsets potentially created by PATH_INFO are taken care of to resolve + * relative URLs to the current script. + * + * You can choose a new protocol while resolving the URI. This is + * particularly useful when redirecting a web browser using relative URIs + * and to switch from HTTP to HTTPS, or vice-versa, at the same time. + * + * @author Philippe Jausions <Philippe.Jausions@11abacus.com> + * @static + * @access public + * @return string The absolute URI. + * @param string $url Absolute or relative URI the redirect should go to. + * @param string $protocol Protocol to use when redirecting URIs. + * @param integer $port A new port number. + */ + public static function absoluteURI($url = null, $protocol = null, $port = null) + { + // filter CR/LF + $url = str_replace(array("\r", "\n"), ' ', $url); + + // Mess around with already absolute URIs + if (preg_match('!^([a-z0-9]+)://!i', $url)) { + if (empty($protocol) && empty($port)) { + return $url; + } + if (!empty($protocol)) { + $url = $protocol .':'. end($array = explode(':', $url, 2)); + } + if (!empty($port)) { + $url = preg_replace('!^(([a-z0-9]+)://[^/:]+)(:[\d]+)?!i', + '\1:'. $port, $url); + } + return $url; + } + + $host = 'localhost'; + if (!empty($_SERVER['HTTP_HOST'])) { + list($host) = explode(':', $_SERVER['HTTP_HOST']); + + if (strpos($_SERVER['HTTP_HOST'], ':') !== false && !isset($port)) { + $port = explode(':', $_SERVER['HTTP_HOST']); + } + } elseif (!empty($_SERVER['SERVER_NAME'])) { + list($host) = explode(':', $_SERVER['SERVER_NAME']); + } + + if (empty($protocol)) { + if (isset($_SERVER['HTTPS']) && !strcasecmp($_SERVER['HTTPS'], 'on')) { + $protocol = 'https'; + } else { + $protocol = 'http'; + } + if (!isset($port) || $port != intval($port)) { + $port = isset($_SERVER['SERVER_PORT']) ? $_SERVER['SERVER_PORT'] : 80; + } + } + + if ($protocol == 'http' && $port == 80) { + unset($port); + } + if ($protocol == 'https' && $port == 443) { + unset($port); + } + + $server = $protocol .'://'. $host . (isset($port) ? ':'. $port : ''); + + + if (!strlen($url) || $url{0} == '?' || $url{0} == '#') { + $uri = isset($_SERVER['REQUEST_URI']) ? + $_SERVER['REQUEST_URI'] : $_SERVER['PHP_SELF']; + if ($url && $url{0} == '?' && false !== ($q = strpos($uri, '?'))) { + $url = substr($uri, 0, $q) . $url; + } else { + $url = $uri . $url; + } + } + + if ($url{0} == '/') { + return $server . $url; + } + + // Check for PATH_INFO + if (isset($_SERVER['PATH_INFO']) && strlen($_SERVER['PATH_INFO']) && + $_SERVER['PHP_SELF'] != $_SERVER['PATH_INFO']) { + $path = dirname(substr($_SERVER['PHP_SELF'], 0, -strlen($_SERVER['PATH_INFO']))); + } else { + $path = dirname($_SERVER['PHP_SELF']); + } + + if (substr($path = strtr($path, '\\', '/'), -1) != '/') { + $path .= '/'; + } + + return $server . $path . $url; + } + + /** + * Test to see if user resubmitted a form. + * Checks only newtask and addcomment actions. + * @return bool true if user has submitted the same action within less than 6 hours, false otherwise + * @access public static + * @version 1.0 + */ + public static function requestDuplicated() + { + // garbage collection -- clean entries older than 6 hrs + $now = isset($_SERVER['REQUEST_TIME']) ? $_SERVER['REQUEST_TIME'] : time(); + if (!empty($_SESSION['requests_hash'])) { + foreach ($_SESSION['requests_hash'] as $key => $val) { + if ($val < $now-6*60*60) { + unset($_SESSION['requests_hash'][$key]); + } + } + } + + if (count($_POST)) { + + if (preg_match('/^newtask.newtask|details.addcomment$/', Post::val('action', ''))) + { + $currentrequest = md5(serialize($_POST)); + if (!empty($_SESSION['requests_hash'][$currentrequest])) { + return true; + } + $_SESSION['requests_hash'][$currentrequest] = time(); + } + } + return false; + } + + /** + * Gets all information about a task (and caches information if wanted) + * @param integer $task_id + * @param bool $cache_enabled + * @access public static + * @return mixed an array with all taskdetails or false on failure + * @version 1.0 + */ + public static function getTaskDetails($task_id, $cache_enabled = false) + { + global $db, $fs; + + static $cache = array(); + + if (isset($cache[$task_id]) && $cache_enabled) { + return $cache[$task_id]; + } + + //for some reason, task_id is not here + // run away immediately.. + if(!is_numeric($task_id)) { + return false; + } + + $get_details = $db->query('SELECT t.*, p.*, + c.category_name, c.category_owner, c.lft, c.rgt, c.project_id as cproj, + o.os_name, + r.resolution_name, + tt.tasktype_name, + vr.version_name AS reported_version_name, + vd.version_name AS due_in_version_name, + uo.real_name AS opened_by_name, + ue.real_name AS last_edited_by_name, + uc.real_name AS closed_by_name, + lst.status_name AS status_name + FROM {tasks} t + LEFT JOIN {projects} p ON t.project_id = p.project_id + LEFT JOIN {list_category} c ON t.product_category = c.category_id + LEFT JOIN {list_os} o ON t.operating_system = o.os_id + LEFT JOIN {list_resolution} r ON t.resolution_reason = r.resolution_id + LEFT JOIN {list_tasktype} tt ON t.task_type = tt.tasktype_id + LEFT JOIN {list_version} vr ON t.product_version = vr.version_id + LEFT JOIN {list_version} vd ON t.closedby_version = vd.version_id + LEFT JOIN {list_status} lst ON t.item_status = lst.status_id + LEFT JOIN {users} uo ON t.opened_by = uo.user_id + LEFT JOIN {users} ue ON t.last_edited_by = ue.user_id + LEFT JOIN {users} uc ON t.closed_by = uc.user_id + WHERE t.task_id = ?', array($task_id)); + + if (!$db->countRows($get_details)) { + return false; + } + + if ($get_details = $db->fetchRow($get_details)) { + $get_details += array('severity_name' => $get_details['task_severity']==0 ? '' : $fs->severities[$get_details['task_severity']]); + $get_details += array('priority_name' => $get_details['task_priority']==0 ? '' : $fs->priorities[$get_details['task_priority']]); + } + + $get_details['tags'] = Flyspray::getTags($task_id); + + $get_details['assigned_to'] = $get_details['assigned_to_name'] = array(); + if ($assignees = Flyspray::getAssignees($task_id, true)) { + $get_details['assigned_to'] = $assignees[0]; + $get_details['assigned_to_name'] = $assignees[1]; + } + + /** + * prevent RAM growing array like creating 100000 tasks with Backend::create_task() in a loop (Tests) + * Costs maybe some SQL queries if getTaskDetails is called first without $cache_enabled + * and later with $cache_enabled within same request + */ + if($cache_enabled){ + $cache[$task_id] = $get_details; + } + return $get_details; + } + + /** + * Returns a list of all projects + * @param bool $active_only show only active projects + * @access public static + * @return array + * @version 1.0 + */ + // FIXME: $active_only would not work since the templates are accessing the returned array implying to be sortyed by project id, which is aparently wrong and error prone ! Same applies to the case when a project was deleted, causing a shift in the project id sequence, hence -> severe bug! + # comment by peterdd 20151012: reenabled param active_only with false as default. I do not see a problem within current Flyspray version. But consider using $fs->projects when possible, saves this extra sql request. + public static function listProjects($active_only = false) + { + global $db; + $query = 'SELECT project_id, project_title, project_is_active FROM {projects}'; + + if ($active_only) { + $query .= ' WHERE project_is_active = 1'; + } + + $query .= ' ORDER BY project_is_active DESC, project_id DESC'; # active first, latest projects first for option groups and new projects are probably the most used. + + $sql = $db->query($query); + return $db->fetchAllArray($sql); + } + + /** + * Returns a list of all themes + * @access public static + * @return array + * @version 1.0 + */ + public static function listThemes() + { + $themes = array(); + $dirname = dirname(dirname(__FILE__)); + if ($handle = opendir($dirname . '/themes/')) { + while (false !== ($file = readdir($handle))) { + if (substr($file,0,1) != '.' && is_dir("$dirname/themes/$file") + && (is_file("$dirname/themes/$file/theme.css") || is_dir("$dirname/themes/$file/templates")) + ) { + $themes[] = $file; + } + } + closedir($handle); + } + + sort($themes); + # always put the full default Flyspray theme first, [0] works as fallback in class Tpl->setTheme() + array_unshift($themes, 'CleanFS'); + $themes = array_unique($themes); + return $themes; + } + + /** + * Returns a list of global groups or a project's groups + * @param integer $proj_id + * @access public static + * @return array + * @version 1.0 + */ + public static function listGroups($proj_id = 0) + { + global $db; + $res = $db->query('SELECT g.*, COUNT(uig.user_id) AS users + FROM {groups} g + LEFT JOIN {users_in_groups} uig ON uig.group_id=g.group_id + WHERE project_id = ? + GROUP BY g.group_id + ORDER BY g.group_id ASC', array($proj_id)); + return $db->fetchAllArray($res); + } + + /** + * Returns a list of a all users + * @access public static + * @param array $opts optional filter which fields (or group of fields) are needed, more may be added later (sorting, where ..) + * @return array + * @version 1.0 + */ + public static function listUsers($opts=array()) + { + global $db; + + if( empty($opts) || !isset($opts['stats']) ){ + + $res = $db->query('SELECT account_enabled, user_id, user_name, real_name, + email_address, jabber_id, oauth_provider, oauth_uid, + notify_type, notify_own, notify_online, + tasks_perpage, lang_code, time_zone, dateformat, dateformat_extended, + register_date, login_attempts, lock_until, + profile_image, hide_my_email, last_login + FROM {users} + ORDER BY account_enabled DESC, user_name ASC'); + + } else { + # Well, this is a big and slow query, but the current solution I found. + # If you know a more elegant for calculating user stats from the different tables with one query let us know! + $res = $db->query(' +SELECT +MIN(u.account_enabled) AS account_enabled, +MIN(u.user_id) AS user_id, +MIN(u.user_name) AS user_name, +MIN(u.real_name) AS real_name, +MIN(u.email_address) AS email_address, +MIN(u.jabber_id) AS jabber_id, +MIN(u.oauth_provider) AS oauth_provider, +MIN(u.oauth_uid) AS oauth_uid, +MIN(u.notify_type) AS notify_type, +MIN(u.notify_own) AS notify_own, +MIN(u.notify_online) AS notify_online, +MIN(u.tasks_perpage) AS tasks_perpage, +MIN(u.lang_code) AS lang_code, +MIN(u.time_zone) AS time_zone, +MIN(u.dateformat) AS dateformat, +MIN(u.dateformat_extended) AS dateformat_extended, +MIN(u.register_date) AS register_date, +MIN(u.login_attempts) AS login_attempts, +MIN(u.lock_until) AS lock_until, +MIN(u.profile_image) AS profile_image, +MIN(u.hide_my_email) AS hide_my_email, +MIN(u.last_login) AS last_login, +SUM(countopen) AS countopen, +SUM(countclose) AS countclose, +SUM(countlastedit) AS countlastedit, +SUM(comments) AS countcomments, +SUM(assigned) AS countassign, +SUM(watching) AS countwatching, +SUM(votes) AS countvotes +FROM +( SELECT u.account_enabled, u.user_id, u.user_name, u.real_name, + u.email_address, u.jabber_id, u.oauth_provider, u.oauth_uid, + u.notify_type, u.notify_own, u.notify_online, + u.tasks_perpage, u.lang_code, u.time_zone, u.dateformat, u.dateformat_extended, + u.register_date, u.login_attempts, u.lock_until, + u.profile_image, u.hide_my_email, u.last_login, + COUNT(topen.opened_by) AS countopen, 0 AS countclose, 0 AS countlastedit, 0 AS comments, 0 AS assigned, 0 AS watching, 0 AS votes + FROM {users} u + LEFT JOIN {tasks} topen ON topen.opened_by=u.user_id + GROUP BY u.user_id +UNION + SELECT u.account_enabled, u.user_id, u.user_name, u.real_name, + u.email_address, u.jabber_id, u.oauth_provider, u.oauth_uid, + u.notify_type, u.notify_own, u.notify_online, + u.tasks_perpage, u.lang_code, u.time_zone, u.dateformat, u.dateformat_extended, + u.register_date, u.login_attempts, u.lock_until, + u.profile_image, u.hide_my_email, u.last_login, + 0, COUNT(tclose.closed_by) AS countclose, 0, 0, 0, 0, 0 + FROM {users} u + LEFT JOIN {tasks} tclose ON tclose.closed_by=u.user_id + GROUP BY u.user_id +UNION + SELECT u.account_enabled, u.user_id, u.user_name, u.real_name, + u.email_address, u.jabber_id, u.oauth_provider, u.oauth_uid, + u.notify_type, u.notify_own, u.notify_online, + u.tasks_perpage, u.lang_code, u.time_zone, u.dateformat, u.dateformat_extended, + u.register_date, u.login_attempts, u.lock_until, + u.profile_image, u.hide_my_email, u.last_login, + 0, 0, COUNT(tlast.last_edited_by) AS countlastedit, 0, 0, 0, 0 + FROM {users} u + LEFT JOIN {tasks} tlast ON tlast.last_edited_by=u.user_id + GROUP BY u.user_id +UNION + SELECT u.account_enabled, u.user_id, u.user_name, u.real_name, + u.email_address, u.jabber_id, u.oauth_provider, u.oauth_uid, + u.notify_type, u.notify_own, u.notify_online, + u.tasks_perpage, u.lang_code, u.time_zone, u.dateformat, u.dateformat_extended, + u.register_date, u.login_attempts, u.lock_until, + u.profile_image, u.hide_my_email, u.last_login, + 0, 0, 0, COUNT(c.user_id) AS comments, 0, 0, 0 + FROM {users} u + LEFT JOIN {comments} c ON c.user_id=u.user_id + GROUP BY u.user_id + UNION + SELECT u.account_enabled, u.user_id, u.user_name, u.real_name, + u.email_address, u.jabber_id, u.oauth_provider, u.oauth_uid, + u.notify_type, u.notify_own, u.notify_online, + u.tasks_perpage, u.lang_code, u.time_zone, u.dateformat, u.dateformat_extended, + u.register_date, u.login_attempts, u.lock_until, + u.profile_image, u.hide_my_email, u.last_login, + 0, 0, 0, 0, COUNT(a.user_id) AS assigned, 0, 0 + FROM {users} u + LEFT JOIN {assigned} a ON a.user_id=u.user_id + GROUP BY u.user_id +UNION + SELECT u.account_enabled, u.user_id, u.user_name, u.real_name, + u.email_address, u.jabber_id, u.oauth_provider, u.oauth_uid, + u.notify_type, u.notify_own, u.notify_online, + u.tasks_perpage, u.lang_code, u.time_zone, u.dateformat, u.dateformat_extended, + u.register_date, u.login_attempts, u.lock_until, + u.profile_image, u.hide_my_email, u.last_login, + 0, 0, 0, 0, 0, COUNT(n.user_id) AS watching, 0 + FROM {users} u + LEFT JOIN {notifications} n ON n.user_id=u.user_id + GROUP BY u.user_id +UNION + SELECT u.account_enabled, u.user_id, u.user_name, u.real_name, + u.email_address, u.jabber_id, u.oauth_provider, u.oauth_uid, + u.notify_type, u.notify_own, u.notify_online, + u.tasks_perpage, u.lang_code, u.time_zone, u.dateformat, u.dateformat_extended, + u.register_date, u.login_attempts, u.lock_until, + u.profile_image, u.hide_my_email, u.last_login, + 0, 0, 0, 0, 0, 0, COUNT(v.user_id) AS votes + FROM {users} u + LEFT JOIN {votes} v ON v.user_id=u.user_id + GROUP BY u.user_id +) u +GROUP BY u.user_id +ORDER BY MIN(u.account_enabled) DESC, MIN(u.user_name) ASC'); + } + + return $db->fetchAllArray($res); + } + + /** + * Returns a list of installed languages + * @access public static + * @return array + * @version 1.0 + */ + public static function listLangs() + { + return str_replace('.php', '', array_map('basename', glob_compat(BASEDIR ."/lang/[a-zA-Z]*.php"))); + + } + + /** + * Saves an event to the {history} db table + * @param integer $task_id + * @param integer $type + * @param string $newvalue + * @param string $oldvalue + * @param string $field + * @param integer $time for synchronisation with other functions + * @access public static + * @return void + * @version 1.0 + */ + public static function logEvent($task_id, $type, $newvalue = '', $oldvalue = '', $field = '', $time = null) + { + global $db, $user; + + // This function creates entries in the history table. These are the event types: + // 0: Fields changed in a task + // 1: New task created + // 2: Task closed + // 3: Task edited (for backwards compatibility with events prior to the history system) + // 4: Comment added + // 5: Comment edited + // 6: Comment deleted + // 7: Attachment added + // 8: Attachment deleted + // 9: User added to notification list + // 10: User removed from notification list + // 11: Related task added to this task + // 12: Related task removed from this task + // 13: Task re-opened + // 14: Task assigned to user / re-assigned to different user / Unassigned + // 15: This task was added to another task's related list + // 16: This task was removed from another task's related list + // 17: Reminder added + // 18: Reminder deleted + // 19: User took ownership + // 20: Closure request made + // 21: Re-opening request made + // 22: Adding a new dependency + // 23: This task added as a dependency of another task + // 24: Removing a dependency + // 25: This task removed from another task's dependency list + // 26: Task was made private + // 27: Task was made public + // 28: PM request denied + // 29: User added to the list of assignees + // 30: New user registration + // 31: User deletion + // 32: Add new subtask + // 33: Remove Subtask + // 34: Add new parent + // 35: Remove parent + + $query_params = array(intval($task_id), intval($user->id), + ((!is_numeric($time)) ? time() : $time), + $type, $field, $oldvalue, $newvalue); + + if($db->query('INSERT INTO {history} (task_id, user_id, event_date, event_type, field_changed, + old_value, new_value) VALUES (?, ?, ?, ?, ?, ?, ?)', $query_params)) { + + return true; + } + + return false; + } + + /** + * Adds an admin or project manager request to the database + * @param integer $type 1: Task close, 2: Task re-open, 3: Pending user registration + * @param integer $project_id + * @param integer $task_id + * @param integer $submitter + * @param string $reason + * @access public static + * @return void + * @version 1.0 + */ + public static function adminRequest($type, $project_id, $task_id, $submitter, $reason) + { + global $db; + $db->query('INSERT INTO {admin_requests} (project_id, task_id, submitted_by, request_type, reason_given, time_submitted, deny_reason) + VALUES (?, ?, ?, ?, ?, ?, ?)', + array($project_id, $task_id, $submitter, $type, $reason, time(), '')); + } + + /** + * Checks whether or not there is an admin request for a task + * @param integer $type 1: Task close, 2: Task re-open, 3: Pending user registration + * @param integer $task_id + * @access public static + * @return bool + * @version 1.0 + */ + public static function adminRequestCheck($type, $task_id) + { + global $db; + + $check = $db->query("SELECT * + FROM {admin_requests} + WHERE request_type = ? AND task_id = ? AND resolved_by = 0", + array($type, $task_id)); + return (bool)($db->countRows($check)); + } + + /** + * Gets all user details of a user + * @param integer $user_id + * @access public static + * @return array + * @version 1.0 + */ + public static function getUserDetails($user_id) + { + global $db; + + // Get current user details. We need this to see if their account is enabled or disabled + $result = $db->query('SELECT * FROM {users} WHERE user_id = ?', array(intval($user_id))); + return $db->fetchRow($result); + } + + /** + * Gets all information about a group + * @param integer $group_id + * @access public static + * @return array + * @version 1.0 + */ + public static function getGroupDetails($group_id) + { + global $db; + $sql = $db->query('SELECT * FROM {groups} WHERE group_id = ?', array($group_id)); + return $db->fetchRow($sql); + } + + /** + * Crypt a password with the method set in the configfile + * @param string $password + * @access public static + * @return string + * @version 1.0 + */ + public static function cryptPassword($password) + { + global $conf; + + # during install e.g. not set + if(isset($conf['general']['passwdcrypt'])){ + $pwcrypt = strtolower($conf['general']['passwdcrypt']); + }else{ + $pwcrypt=''; + } + + # sha1, md5, sha512 are unsalted, hashing methods, not suited for storing passwords anymore. + # Use password_hash(), that adds random salt, customizable rounds and customizable hashing algorithms. + if ($pwcrypt == 'sha1') { + return sha1($password); + } elseif ($pwcrypt == 'md5') { + return md5($password); + } elseif ($pwcrypt == 'sha512') { + return hash('sha512', $password); + } elseif ($pwcrypt =='argon2i' && version_compare(PHP_VERSION,'7.2.0')>=0){ + # php7.2+ + return password_hash($password, PASSWORD_ARGON2I); + } else { + $bcryptoptions=array('cost'=>14); + return password_hash($password, PASSWORD_BCRYPT, $bcryptoptions); + } + } + + /** + * Check if a user provided the right credentials + * @param string $username + * @param string $password + * @param string $method '', 'oauth', 'ldap', 'native' + * @access public static + * @return integer user_id on success, 0 if account or user is disabled, -1 if password is wrong + * @version 1.0 + */ + public static function checkLogin($username, $password, $method = 'native') + { + global $db; + + $email_address = $username; //handle multiple email addresses + $temp = $db->query("SELECT id FROM {user_emails} WHERE email_address = ?",$email_address); + $user_id = $db->fetchRow($temp); + $user_id = $user_id["id"]; + + $result = $db->query("SELECT uig.*, g.group_open, u.account_enabled, u.user_pass, + lock_until, login_attempts + FROM {users_in_groups} uig + LEFT JOIN {groups} g ON uig.group_id = g.group_id + LEFT JOIN {users} u ON uig.user_id = u.user_id + WHERE u.user_id = ? OR u.user_name = ? AND g.project_id = ? + ORDER BY g.group_id ASC", array($user_id, $username, 0)); + + $auth_details = $db->fetchRow($result); + + if($auth_details === false) { + return -2; + } + if(!$result || !count($auth_details)) { + return 0; + } + + if ($auth_details['lock_until'] > 0 && $auth_details['lock_until'] < time()) { + $db->query('UPDATE {users} SET lock_until = 0, account_enabled = 1, login_attempts = 0 + WHERE user_id = ?', array($auth_details['user_id'])); + $auth_details['account_enabled'] = 1; + $_SESSION['was_locked'] = true; + } + + // skip password check if the user is using oauth + if($method == 'oauth'){ + $pwok = true; + } elseif( $method == 'ldap'){ + $pwok = Flyspray::checkForLDAPUser($username, $password); + } else{ + // encrypt the password with the method used in the db + if(substr($auth_details['user_pass'],0,1)!='$' && ( + strlen($auth_details['user_pass'])==32 + || strlen($auth_details['user_pass'])==40 + || strlen($auth_details['user_pass'])==128 + )){ + # detecting (old) password stored with old unsalted hashing methods: md5,sha1,sha512 + switch(strlen($auth_details['user_pass'])){ + case 32: + $pwhash = md5($password); + break; + case 40: + $pwhash = sha1($password); + break; + case 128: + $pwhash = hash('sha512', $password); + break; + } + $pwok = hash_equals($auth_details['user_pass'], $pwhash); + }else{ + #$pwhash = crypt($password, $auth_details['user_pass']); // user_pass contains algorithm, rounds, salt + $pwok = password_verify($password, $auth_details['user_pass']); + } + } + + // Admin users cannot be disabled + if ($auth_details['group_id'] == 1 /* admin */ && $pwok) { + return $auth_details['user_id']; + } + if ($pwok && $auth_details['account_enabled'] == '1' && $auth_details['group_open'] == '1'){ + return $auth_details['user_id']; + } + + return ($auth_details['account_enabled'] && $auth_details['group_open']) ? 0 : -1; + } + + static public function checkForOauthUser($uid, $provider) + { + global $db; + + if(empty($uid) || empty($provider)) { + return false; + } + + $sql = $db->query("SELECT id FROM {user_emails} WHERE oauth_uid = ? AND oauth_provider = ?",array($uid, $provider)); + + if ($db->fetchOne($sql)) { + return true; + } else { + return false; + } + } + + /** + * 20150320 just added from provided patch, untested! + */ + public static function checkForLDAPUser($username, $password) + { + # TODO: add to admin settings area, maybe let user set the config at final installation step + $ldap_host = 'ldaphost'; + $ldap_port = '389'; + $ldap_version = '3'; + $base_dn = 'OU=SBSUsers,OU=Users,OU=MyBusiness,DC=MyDomain,DC=local'; + $ldap_search_user = 'ldapuser@mydomain.local'; + $ldap_search_pass = "ldapuserpass"; + $filter = "SAMAccountName=%USERNAME%"; // this is for AD - may be different with other setups + $username = $username; + + if (strlen($password) == 0){ // LDAP will succeed binding with no password on AD (defaults to anon bind) + return false; + } + + $rs = ldap_connect($ldap_host, $ldap_port); + @ldap_set_option($rs, LDAP_OPT_PROTOCOL_VERSION, $ldap_version); + @ldap_set_option($rs, LDAP_OPT_REFERRALS, 0); + $ldap_bind_dn = empty($ldap_search_user) ? NULL : $ldap_search_user; + $ldap_bind_pw = empty($ldap_search_pass) ? NULL : $ldap_search_pass; + if (!$bindok = @ldap_bind($rs, $ldap_bind_dn, $ldap_search_pass)){ + // Uncomment for LDAP debugging + $error_msg = ldap_error($rs); + die("Couldn't bind using ".$ldap_bind_dn."@".$ldap_host.":".$ldap_port." Because:".$error_msg); + return false; + } else{ + $filter_r = str_replace("%USERNAME%", $username, $filter); + $result = @ldap_search($rs, $base_dn, $filter_r); + if (!$result){ // ldap search returned nothing or error + return false; + } + $result_user = ldap_get_entries($rs, $result); + if ($result_user["count"] == 0){ // No users match the filter + return false; + } + $first_user = $result_user[0]; + $ldap_user_dn = $first_user["dn"]; + // Bind with the dn of the user that matched our filter (only one user should match sAMAccountName or uid etc..) + if (!$bind_user = @ldap_bind($rs, $ldap_user_dn, $password)){ + $error_msg = ldap_error($rs); + die("Couldn't bind using ".$ldap_user_dn."@".$ldap_host.":".$ldap_port." Because:".$error_msg); + return false; + } else{ + return true; + } + } + } + + /** + * Sets a cookie, automatically setting the URL + * Now same params as PHP's builtin setcookie() + * @param string $name + * @param string $val + * @param integer $time + * @param string $path + * @param string $domain + * @param bool $secure + * @param bool $httponly + * @access public static + * @return bool + * @version 1.1 + */ + public static function setCookie($name, $val, $time = null, $path=null, $domain=null, $secure=false, $httponly=false) + { + global $conf; + + if (null===$path){ + $url = parse_url($GLOBALS['baseurl']); + }else{ + $url['path']=$path; + } + + if (!is_int($time)) { + $time = time()+60*60*24*30; + } + if(null===$domain){ + $domain=''; + } + if(null===$secure){ + $secure = isset($conf['general']['securecookies']) ? $conf['general']['securecookies'] : false; + } + if((strlen($name) + strlen($val)) > 4096) { + //violation of the protocol + trigger_error("Flyspray sent a too big cookie, browsers will not handle it"); + return false; + } + + return setcookie($name, $val, $time, $url['path'],$domain,$secure,$httponly); + } + + /** + * Starts the session + * @access public static + * @return void + * @version 1.0 + * @notes smile intented + */ + public static function startSession() + { + global $conf; + if (defined('IN_FEED') || php_sapi_name() === 'cli') { + return; + } + + $url = parse_url($GLOBALS['baseurl']); + session_name('flyspray'); + session_set_cookie_params(0,$url['path'],'', (isset($conf['general']['securecookies'])? $conf['general']['securecookies']:false), TRUE); + session_start(); + if(!isset($_SESSION['csrftoken'])){ + $_SESSION['csrftoken']=rand(); # lets start with one anti csrf token secret for the session and see if it's simplicity is good enough (I hope together with enforced Content Security Policies) + } + } + + /** + * Compares two tasks and returns an array of differences + * @param array $old + * @param array $new + * @access public static + * @return array array('field', 'old', 'new') + * @version 1.0 + */ + public static function compare_tasks($old, $new) + { + $comp = array('priority_name', 'severity_name', 'status_name', 'assigned_to_name', 'due_in_version_name', + 'reported_version_name', 'tasktype_name', 'os_name', 'category_name', + 'due_date', 'percent_complete', 'item_summary', 'due_in_version_name', + 'detailed_desc', 'project_title', 'mark_private'); + + $changes = array(); + foreach ($old as $key => $value) + { + if (!in_array($key, $comp) || ($key === 'due_date' && intval($old[$key]) === intval($new[$key]))) { + continue; + } + + if($old[$key] != $new[$key]) { + switch ($key) + { + case 'due_date': + $new[$key] = formatDate($new[$key]); + $value = formatDate($value); + break; + + case 'percent_complete': + $new[$key] .= '%'; + $value .= '%'; + break; + + case 'mark_private': + $new[$key] = $new[$key] ? L('private') : L('public'); + $value = $value ? L('private') : L('public'); + break; + } + $changes[] = array($key, $value, $new[$key]); + } + } + + return $changes; + } + + /** + * Get all tags of a task + * @access public static + * @return array + * @version 1.0 + */ + public static function getTags($task_id) + { + global $db; + # pre FS1.0beta + #$sql = $db->query('SELECT * FROM {tags} WHERE task_id = ?', array($task_id)); + # since FS1.0beta + $sql = $db->query('SELECT tg.tag_id, tg.tag_name AS tag, tg.class FROM {task_tag} tt + JOIN {list_tag} tg ON tg.tag_id=tt.tag_id + WHERE task_id = ? + ORDER BY list_position', array($task_id)); + return $db->fetchAllArray($sql); + } + + /** + * load all task tags into array + * + * Compared to listTags() of class project, this loads all tags in Flyspray database into a global array. + * Ideally called only once per http request, then using the array index for getting tag info. + * + * Used mainly for tasklist view to simplify get_task_list() sql query. + * + * @return array + */ + public static function getAllTags() + { + global $db; + $at=array(); + $res = $db->query('SELECT tag_id, project_id, list_position, tag_name, class, show_in_list FROM {list_tag}'); + while ($t = $db->fetchRow($res)){ + $at[$t['tag_id']]=array( + 'project_id'=>$t['project_id'], + 'list_position'=>$t['list_position'], + 'tag_name'=>$t['tag_name'], + 'class'=>$t['class'], + 'show_in_list'=>$t['show_in_list'] + ); + } + return $at; + } + + /** + * Get a list of assignees for a task + * @param integer $task_id + * @param bool $name whether or not names of the assignees should be returned as well + * @access public static + * @return array + * @version 1.0 + */ + public static function getAssignees($task_id, $name = false) + { + global $db; + + $sql = $db->query('SELECT u.real_name, u.user_id + FROM {users} u, {assigned} a + WHERE task_id = ? AND u.user_id = a.user_id', + array($task_id)); + + $assignees = array(); + while ($row = $db->fetchRow($sql)) { + if ($name) { + $assignees[0][] = $row['user_id']; + $assignees[1][] = $row['real_name']; + } else { + $assignees[] = $row['user_id']; + } + } + + return $assignees; + } + + /** + * Explode string to the array of integers + * @param string $separator + * @param string $string + * @access public static + * @return array + * @version 1.0 + */ + public static function int_explode($separator, $string) + { + $ret = array(); + foreach (explode($separator, $string) as $v) + { + if (ctype_digit($v)) {// $v is always string, this func returns false if $v == '' + $ret[] = intval($v); // convert to int + } + } + return $ret; + } + + /** + * Checks if a function is disabled + * @param string $func_name + * @access public static + * @return bool + * @version 1.0 + */ + public static function function_disabled($func_name) + { + $disabled_functions = explode(',', ini_get('disable_functions')); + return in_array($func_name, $disabled_functions); + } + + /** + * Returns the key number of an array which contains an array like array($key => $value) + * For use with SQL result arrays + * returns 0 for first index, so take care if you want check when useing to check if a value exists, use === + * + * @param string $key + * @param string $value + * @param array $array + * @access public static + * @return integer + * @version 1.0 + */ + public static function array_find($key, $value, $array) + { + foreach ($array as $num => $part) { + if (isset($part[$key]) && $part[$key] == $value) { + return $num; + } + } + return false; + } + + /** + * Shows an error message + * @param string $error_message if it is an integer, an error message from the language file will be loaded + * @param bool $die enable/disable redirection (if outside the database modification script) + * @param string $advanced_info append a string to the error message + * @param string $url alternate redirection + * @access public static + * @return void + * @version 1.0 + * @notes if a success and error happens on the same page, a mixed error message will be shown + * @todo is the if ($die) meant to be inside the else clause? + */ + public static function show_error($error_message, $die = true, $advanced_info = null, $url = null) + { + global $modes, $baseurl; + + if (!is_int($error_message)) { + // in modify.inc.php + $_SESSION['ERROR'] = $error_message; + } else { + $_SESSION['ERROR'] = L('error#') . $error_message . ': ' . L('error' . $error_message); + if (!is_null($advanced_info)) { + $_SESSION['ERROR'] .= ' ' . $advanced_info; + } + if ($die) { + Flyspray::redirect( (is_null($url) ? $baseurl : $url) ); + } + } + } + + /** + * Returns the user ID if valid, 0 otherwise + * @param int $id + * @access public static + * @return integer 0 if the user does not exist + * @version 1.0 + */ + public static function validUserId($id) + { + global $db; + + $sql = $db->query('SELECT user_id FROM {users} WHERE user_id = ?', array(intval($id))); + + return intval($db->fetchOne($sql)); + } + + /** + * Returns the ID of a user with $name + * @param string $name + * @access public static + * @return integer 0 if the user does not exist + * @version 1.0 + */ + public static function usernameToId($name) + { + global $db; + + if(!is_string($name)){ + return 0; + } + + $sql = $db->query('SELECT user_id FROM {users} WHERE user_name = ?', array($name)); + + return intval($db->fetchOne($sql)); + } + + /** + * check_email + * checks if an email is valid + * @param string $email + * @access public + * @return bool + */ + public static function check_email($email) + { + return is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL); + } + + /** + * get_tmp_dir + * Based on PEAR System::tmpdir() by Tomas V.V.Cox. + * @access public + * @return void + */ + public static function get_tmp_dir() + { + $return = ''; + + if (function_exists('sys_get_temp_dir')) { + $return = sys_get_temp_dir(); + } elseif (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { + if ($var = isset($_ENV['TEMP']) ? $_ENV['TEMP'] : getenv('TEMP')) { + $return = $var; + } else + if ($var = isset($_ENV['TMP']) ? $_ENV['TMP'] : getenv('TMP')) { + $return = $var; + } else + if ($var = isset($_ENV['windir']) ? $_ENV['windir'] : getenv('windir')) { + $return = $var; + } else { + $return = getenv('SystemRoot') . '\temp'; + } + + } elseif ($var = isset($_ENV['TMPDIR']) ? $_ENV['TMPDIR'] : getenv('TMPDIR')) { + $return = $var; + } else { + $return = '/tmp'; + } + // Now, the final check + if (@is_dir($return) && is_writable($return)) { + return rtrim($return, DIRECTORY_SEPARATOR); + // we have a problem at this stage. + } elseif(is_writable(ini_get('upload_tmp_dir'))) { + $return = ini_get('upload_tmp_dir'); + } elseif(is_writable(ini_get('session.save_path'))) { + $return = ini_get('session.save_path'); + } + return rtrim($return, DIRECTORY_SEPARATOR); + } + + /** + * check_mime_type + * + * @param string $fname path to filename + * @access public + * @return string the mime type of the offended file. + * @notes DO NOT use this function for any security related + * task (i.e limiting file uploads by type) + * it wasn't designed for that purpose but to UI related tasks. + */ + public static function check_mime_type($fname) { + + $type = ''; + + if (extension_loaded('fileinfo') && class_exists('finfo')) { + + $info = new finfo(FILEINFO_MIME); + $type = $info->file($fname); + + } elseif(function_exists('mime_content_type')) { + + $type = @mime_content_type($fname); + // I hope we don't have to... + } elseif(!FlySpray::function_disabled('exec') && strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN' + && php_uname('s') !== 'SunOS') { + + $type = @exec(sprintf('file -bi %s', escapeshellarg($fname))); + + } + // if wasn't possible to determine , return empty string so + // we can use the browser reported mime-type (probably fake) + return trim($type); + } + + /** + * Works like strtotime, but it considers the user's timezone + * @access public + * @param string $time + * @return integer + */ + public static function strtotime($time) + { + global $user; + + $time = strtotime($time); + + if (!$user->isAnon()) { + $st = date('Z')/3600; // server GMT timezone + // Example: User is GMT+3, Server GMT-2. + // User enters 7:00. For the server it must be converted to 2:00 (done below) + $time += ($st - $user->infos['time_zone']) * 60 * 60; + // later it adds 5 hours to 2:00 for the user when the date is displayed. + } + //strtotime() may return false, making this method to return bool instead of int. + return $time ? $time : 0; + } + + /** + * Writes content to a file using a lock. + * @access public + * @param string $filename location to write to + * @param string $content data to write + */ + public static function write_lock($filename, $content) + { + if ($f = fopen($filename, 'wb')) { + if(flock($f, LOCK_EX)) { + fwrite($f, $content); + flock($f, LOCK_UN); + } + fclose($f); + } + } + + /** + * file_get_contents replacement for remote files + * @access public + * @param string $url + * @param bool $get_contents whether or not to return file contents, use GET_CONTENTS for true + * @param integer $port + * @param string $connect manually choose server for connection + * @return string an empty string is not necessarily a failure + */ + public static function remote_request($url, $get_contents = false, $port = 80, $connect = '', $host = null) + { + $url = parse_url($url); + if (!$connect) { + $connect = $url['host']; + } + + if ($host) { + $url['host'] = $host; + } + + $data = ''; + + if ($conn = @fsockopen($connect, $port, $errno, $errstr, 10)) { + $out = "GET {$url['path']} HTTP/1.0\r\n"; + $out .= "Host: {$url['host']}\r\n"; + $out .= "Connection: Close\r\n\r\n"; + + stream_set_timeout($conn, 5); + fwrite($conn, $out); + + if ($get_contents) { + while (!feof($conn)) { + $data .= fgets($conn, 128); + } + + $pos = strpos($data, "\r\n\r\n"); + + if ($pos !== false) { + //strip the http headers. + $data = substr($data, $pos + 2 * strlen("\r\n")); + } + } + fclose($conn); + } + + return $data; + } + + /** + * Returns an array containing all notification options the user is + * allowed to use. + * @access public + * @return array + */ + public function getNotificationOptions($noneAllowed = true) + { + switch ($this->prefs['user_notify']) + { + case 0: + return array(0 => L('none')); + case 2: + return array(NOTIFY_EMAIL => L('email')); + case 3: + return array(NOTIFY_JABBER => L('jabber')); + + } + + $return = array(0 => L('none'), + NOTIFY_EMAIL => L('email'), + NOTIFY_JABBER => L('jabber'), + NOTIFY_BOTH => L('both')); + if (!$noneAllowed) { + unset($return[0]); + } + + return $return; + } + + public static function weedOutTasks($user, $tasks) { + $allowedtasks = array(); + foreach ($tasks as $task) { + if ($user->can_view_task($task)) { + $allowedtasks[] = $task; + } + } + return $allowedtasks; + } +} diff --git a/includes/class.gpc.php b/includes/class.gpc.php new file mode 100644 index 0000000..88235ef --- /dev/null +++ b/includes/class.gpc.php @@ -0,0 +1,257 @@ +<?php +// {{{ class Req +/** + * Flyspray + * + * GPC classes + * + * This script contains classes for $_GET, $_REQUEST, $_POST and $_COOKIE + * to safely retrieve values. Example: Get::val('foo', 'bar') to get $_GET['foo'] or 'bar' if + * the key does not exist. + * + * @license http://opensource.org/licenses/lgpl-license.php Lesser GNU Public License + * @package flyspray + * @author Pierre Habouzit + */ + +abstract class Req +{ + public static function has($key) + { + return isset($_REQUEST[$key]); + } + + public static function val($key, $default = null) + { + return Req::has($key) ? $_REQUEST[$key] : $default; + } + + //it will always return a number no matter what(null is 0) + public static function num($key, $default = null) + { + return Filters::num(Req::val($key, $default)); + } + + public static function enum($key, $options, $default = null) + { + return Filters::enum(Req::val($key, $default), $options); + } + + //always a string (null is typed to an empty string) + public static function safe($key) + { + return Filters::noXSS(Req::val($key)); + } + + public static function isAlnum($key) + { + return Filters::isAlnum(Req::val($key)); + } + + /** + * Overwrites or sets a request value + */ + public static function set($key, $value = null) { + $_REQUEST[$key] = $value; + } +} + + // }}} +// {{{ class Post + +abstract class Post +{ + public static function has($key) + { + // XXX semantics is different for POST, as POST of '' values is never + // unintentionnal, whereas GET/COOKIE may have '' values for empty + // ones. + return isset($_POST[$key]); + } + + public static function val($key, $default = null) + { + return Post::has($key) ? $_POST[$key] : $default; + } + + //it will always return a number no matter what(null is 0) + public static function num($key, $default = null) + { + return Filters::num(Post::val($key, $default)); + } + + //always a string (null is typed to an empty string) + public static function safe($key) + { + return Filters::noXSS(Post::val($key)); + } + + public static function isAlnum($key) + { + return Filters::isAlnum(Post::val($key)); + } +} + +// }}} +// {{{ class Get + +abstract class Get +{ + public static function has($key) + { + return isset($_GET[$key]) && $_GET[$key] !== ''; + } + + public static function val($key, $default = null) + { + return Get::has($key) ? $_GET[$key] : $default; + } + + //it will always return a number no matter what(null is 0) + public static function num($key, $default = null) + { + return Filters::num(Get::val($key, $default)); + } + + //always a string (null is typed to an empty string) + public static function safe($key) + { + return Filters::noXSS(Get::val($key)); + } + + public static function enum($key, $options, $default = null) + { + return Filters::enum(Get::val($key, $default), $options); + } + +} + +// }}} +//{{{ class Cookie + +abstract class Cookie +{ + public static function has($key) + { + return isset($_COOKIE[$key]) && $_COOKIE[$key] !== ''; + } + + public static function val($key, $default = null) + { + return Cookie::has($key) ? $_COOKIE[$key] : $default; + } +} +//}}} +/** + * Class Filters + * + * This is a simple class for safe input validation + * no mixed stuff here, functions returns always the same type. + * @author Cristian Rodriguez R <judas.iscariote@flyspray.org> + * @license BSD + * @notes this intented to be used by Flyspray internals functions/methods + * please DO NOT use this in templates , if the code processing the input there + * is not safe, please fix the underlying problem. + */ +abstract class Filters { + /** + * give me a number only please? + * @param mixed $data + * @return int + * @access public static + * @notes changed before 0.9.9 to avoid strange results + * with arrays and objects + */ + public static function num($data) + { + return intval($data); // no further checks here please + } + + /** + * Give user input free from potentially mailicious html + * @param mixed $data + * @return string htmlspecialchar'ed + * @access public static + */ + public static function noXSS($data) + { + if(empty($data) || is_numeric($data)) { + return $data; + } elseif(is_string($data)) { + return htmlspecialchars($data, ENT_QUOTES, 'utf-8'); + } + return ''; + } + + /** + * Give user input free from potentially mailicious html and JS insertions + * @param mixed $data + * @return string + * @access public static + */ + public static function noJsXSS($data) + { + if(empty($data) || is_numeric($data)) { + return $data; + } elseif(is_string($data)) { + return Filters::noXSS(preg_replace("/[\x01-\x1F\x7F]|\xC2[\x80-\x9F]/", "", addcslashes($data, "\t\"'\\"))); + } + return ''; + } + + /** + * is $data alphanumeric eh ? + * @param string $data string value to check + * @return bool + * @access public static + * @notes unfortunately due to a bug in PHP < 5.1 + * http://bugs.php.net/bug.php?id=30945 ctype_alnum + * returned true on empty string, that's the reason why + * we have to use strlen too. + * + * Be aware: $data MUST be an string, integers or any other + * type is evaluated to FALSE + */ + public static function isAlnum($data) + { + return ctype_alnum($data) && strlen($data); + } + + /** + * Checks if $data is a value of $options and returns the first element of + * $options if it is not (for input validation if all possible values are known) + * @param mixed $data + * @param array $options + * @return mixed + * @access public static + */ + public static function enum($data, $options) + { + if (!in_array($data, $options) && isset($options[0])) { + return $options[0]; + } + + return $data; + } + + public static function escapeqs($qs) + { + parse_str($qs, $clean_qs); + return http_build_query($clean_qs); + } +} + +/** + * A basic function which works like the GPC classes above for any array + * @param array $array + * @param mixed $key + * @param mixed $default + * @return mixed + * @version 1.0 + * @since 0.9.9 + * @see Backend::get_task_list() + */ +function array_get(&$array, $key, $default = null) +{ + return (isset($array[$key])) ? $array[$key] : $default; +} diff --git a/includes/class.jabber2.php b/includes/class.jabber2.php new file mode 100644 index 0000000..4617395 --- /dev/null +++ b/includes/class.jabber2.php @@ -0,0 +1,943 @@ +<?php +/** + * Jabber class + * + * @version $Id$ + * @copyright 2006 Flyspray.org + * @notes: This lib has been created due to the lack of any good and modern jabber class out there + * @author: Florian Schmitz (floele) + */ + +define('SECURITY_NONE', 0); +define('SECURITY_SSL', 1); +define('SECURITY_TLS', 2); + +class Jabber +{ + public $connection = null; + public $session = array(); + public $resource = 'class.jabber2.php'; + public $log = array(); + public $log_enabled = true; + public $timeout = 10; + public $user = ''; + public $password = ''; + public $server = ''; + public $features = array(); + + public function __construct($login, $password, $security = SECURITY_NONE, $port = 5222, $host = '') + { + // Can we use Jabber at all? + // Note: Maybe replace with SimpleXML in the future + if (!extension_loaded('xml')) { + $this->log('Error: No XML functions available, Jabber functions can not operate.'); + return false; + } + + //bug in php 5.2.1 renders this stuff more or less useless. + if ((version_compare(phpversion(), '5.2.1', '>=') && version_compare(phpversion(), '5.2.3RC2', '<')) && $security != SECURITY_NONE) { + $this->log('Error: PHP ' . phpversion() . ' + SSL is incompatible with jabber, see http://bugs.php.net/41236'); + return false; + } + + if (!Jabber::check_jid($login)) { + $this->log('Error: Jabber ID is not valid: ' . $login); + return false; + } + + // Extract data from user@server.org + list($username, $server) = explode('@', $login); + + // Decide whether or not to use encryption + if ($security == SECURITY_SSL && !Jabber::can_use_ssl()) { + $this->log('Warning: SSL encryption is not supported (openssl required). Falling back to no encryption.'); + $security = SECURITY_NONE; + } + if ($security == SECURITY_TLS && !Jabber::can_use_tls()) { + $this->log('Warning: TLS encryption is not supported (openssl and stream_socket_enable_crypto() required). Falling back to no encryption.'); + $security = SECURITY_NONE; + } + + $this->session['security'] = $security; + $this->server = $server; + $this->user = $username; + $this->password = $password; + + if ($this->open_socket( ($host != '') ? $host : $server, $port, $security == SECURITY_SSL)) { + $this->send("<?xml version='1.0' encoding='UTF-8' ?" . ">\n"); + $this->send("<stream:stream to='{$server}' xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' version='1.0'>\n"); + } else { + return false; + } + // Now we listen what the server has to say...and give appropriate responses + $this->response($this->listen()); + } + + /** + * Sets the resource which is used. No validation is done here, only escaping. + * @param string $$name + * @access public + */ + public function setResource($name) + { + $this->resource = $name; + } + + /** + * Send data to the Jabber server + * @param string $xml + * @access public + * @return bool + */ + public function send($xml) + { + if ($this->connected()) { + $xml = trim($xml); + $this->log('SEND: '. $xml); + return fwrite($this->connection, $xml); + } else { + $this->log('Error: Could not send, connection lost (flood?).'); + return false; + } + } + + /** + * OpenSocket + * @param string $server host to connect to + * @param int $port port number + * @param bool $ssl use ssl or not + * @access public + * @return bool + */ + public function open_socket($server, $port, $ssl = false) + { + if (function_exists("dns_get_record")) { + $record = dns_get_record("_xmpp-client._tcp.$server", DNS_SRV); + if (!empty($record)) { + $server = $record[0]['target']; + } + } else { + $this->log('Warning: dns_get_record function not found. gtalk will not work.'); + } + + $server = $ssl ? 'ssl://' . $server : $server; + + if ($ssl) { + $this->session['ssl'] = true; + } + + if ($this->connection = @fsockopen($server, $port, $errorno, $errorstr, $this->timeout)) { + socket_set_blocking($this->connection, 0); + socket_set_timeout($this->connection, 60); + + return true; + } + // Apparently an error occured... + $this->log('Error: ' . $errorstr); + return false; + } + + public function log($msg) + { + if ($this->log_enabled) { + $this->log[] = $msg; + return true; + } + + return false; + } + + /** + * Listens to the connection until it gets data or the timeout is reached. + * Thus, it should only be called if data is expected to be received. + * @access public + * @return mixed either false for timeout or an array with the received data + */ + public function listen($timeout = 10, $wait = false) + { + if (!$this->connected()) { + return false; + } + + // Wait for a response until timeout is reached + $start = time(); + $data = ''; + + do { + $read = trim(fread($this->connection, 4096)); + $data .= $read; + } while (time() <= $start + $timeout && !feof($this->connection) && ($wait || $data == '' || $read != '' + || (substr(rtrim($data), -1) != '>'))); + + if ($data != '') { + $this->log('RECV: '. $data); + return Jabber::xmlize($data); + } else { + $this->log('Timeout, no response from server.'); + return false; + } + } + + /** + * Initiates login (using data from contructor) + * @access public + * @return bool + */ + public function login() + { + if (!count($this->features)) { + $this->log('Error: No feature information from server available.'); + return false; + } + + return $this->response($this->features); + } + + /** + * Initiates account registration (based on data used for contructor) + * @access public + * @return bool + */ + public function register() + { + if (!isset($this->session['id']) || isset($this->session['jid'])) { + $this->log('Error: Cannot initiate registration.'); + return false; + } + + $this->send("<iq type='get' id='reg_1'> + <query xmlns='jabber:iq:register'/> + </iq>"); + return $this->response($this->listen()); + } + + /** + * Initiates account un-registration (based on data used for contructor) + * @access public + * @return bool + */ + public function unregister() + { + if (!isset($this->session['id']) || !isset($this->session['jid'])) { + $this->log('Error: Cannot initiate un-registration.'); + return false; + } + + $this->send("<iq type='set' from='" . Jabber::jspecialchars($this->session['jid']) . "' id='unreg_1'> + <query xmlns='jabber:iq:register'> + <remove/> + </query> + </iq>"); + return $this->response($this->listen(2)); // maybe we don't even get a response + } + + /** + * Sets account presence. No additional info required (default is "online" status) + * @param $type dnd, away, chat, xa or nothing + * @param $message + * @param $unavailable set this to true if you want to become unavailable + * @access public + * @return bool + */ + public function presence($type = '', $message = '', $unavailable = false) + { + if (!isset($this->session['jid'])) { + $this->log('Error: Cannot set presence at this point.'); + return false; + } + + if (in_array($type, array('dnd', 'away', 'chat', 'xa'))) { + $type = '<show>'. $type .'</show>'; + } else { + $type = ''; + } + + $unavailable = ($unavailable) ? " type='unavailable'" : ''; + $message = ($message) ? '<status>' . Jabber::jspecialchars($message) .'</status>' : ''; + + $this->session['sent_presence'] = !$unavailable; + + return $this->send("<presence$unavailable>" . + $type . + $message . + '</presence>'); + } + + /** + * This handles all the different XML elements + * @param array $xml + * @access public + * @return bool + */ + public function response($xml) + { + if (!is_array($xml) || !count($xml)) { + return false; + } + + // did we get multiple elements? do one after another + // array('message' => ..., 'presence' => ...) + if (count($xml) > 1) { + foreach ($xml as $key => $value) { + $this->response(array($key => $value)); + } + return; + } else + // or even multiple elements of the same type? + // array('message' => array(0 => ..., 1 => ...)) + if (count(reset($xml)) > 1) { + foreach (reset($xml) as $value) { + $this->response(array(key($xml) => array(0 => $value))); + } + return; + } + + switch (key($xml)) { + case 'stream:stream': + // Connection initialised (or after authentication). Not much to do here... + if (isset($xml['stream:stream'][0]['#']['stream:features'])) { + // we already got all info we need + $this->features = $xml['stream:stream'][0]['#']; + } else { + $this->features = $this->listen(); + } + $second_time = isset($this->session['id']); + $this->session['id'] = $xml['stream:stream'][0]['@']['id']; + if ($second_time) { + // If we are here for the second time after TLS, we need to continue logging in + $this->login(); + return; + } + + // go on with authentication? + if (isset($this->features['stream:features'][0]['#']['bind'])) { + return $this->response($this->features); + } + break; + + case 'stream:features': + // Resource binding after successful authentication + if (isset($this->session['authenticated'])) { + // session required? + $this->session['sess_required'] = isset($xml['stream:features'][0]['#']['session']); + + $this->send("<iq type='set' id='bind_1'> + <bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'> + <resource>" . Jabber::jspecialchars($this->resource) . "</resource> + </bind> + </iq>"); + return $this->response($this->listen()); + } + // Let's use TLS if SSL is not enabled and we can actually use it + if ($this->session['security'] == SECURITY_TLS && isset($xml['stream:features'][0]['#']['starttls'])) { + $this->log('Switching to TLS.'); + $this->send("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>\n"); + return $this->response($this->listen()); + } + // Does the server support SASL authentication? + + // I hope so, because we do (and no other method). + if (isset($xml['stream:features'][0]['#']['mechanisms'][0]['@']['xmlns']) && + $xml['stream:features'][0]['#']['mechanisms'][0]['@']['xmlns'] == 'urn:ietf:params:xml:ns:xmpp-sasl') { + // Now decide on method + $methods = array(); + foreach ($xml['stream:features'][0]['#']['mechanisms'][0]['#']['mechanism'] as $value) { + $methods[] = $value['#']; + } + + // we prefer this one + if (in_array('DIGEST-MD5', $methods)) { + $this->send("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='DIGEST-MD5'/>"); + // we don't want to use this (neither does the server usually) if no encryption is in place + # http://www.xmpp.org/extensions/attic/jep-0078-1.7.html + # The plaintext mechanism SHOULD NOT be used unless the underlying stream is encrypted (using SSL or TLS) + # and the client has verified that the server certificate is signed by a trusted certificate authority. + } else if (in_array('PLAIN', $methods) && (isset($this->session['ssl']) || isset($this->session['tls']))) { + $this->send("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>" + . base64_encode(chr(0) . $this->user . '@' . $this->server . chr(0) . $this->password) . + "</auth>"); + } else if (in_array('ANONYMOUS', $methods)) { + $this->send("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='ANONYMOUS'/>"); + // not good... + } else { + $this->log('Error: No authentication method supported.'); + $this->disconnect(); + return false; + } + return $this->response($this->listen()); + + } else { + // ok, this is it. bye. + $this->log('Error: Server does not offer SASL authentication.'); + $this->disconnect(); + return false; + } + break; + + case 'challenge': + // continue with authentication...a challenge literally -_- + $decoded = base64_decode($xml['challenge'][0]['#']); + $decoded = Jabber::parse_data($decoded); + if (!isset($decoded['digest-uri'])) { + $decoded['digest-uri'] = 'xmpp/'. $this->server; + } + + // better generate a cnonce, maybe it's needed + + $decoded['cnonce'] = base64_encode(md5(uniqid(mt_rand(), true))); + + // second challenge? + if (isset($decoded['rspauth'])) { + $this->send("<response xmlns='urn:ietf:params:xml:ns:xmpp-sasl'/>"); + } else { + $response = array('username' => $this->user, + 'response' => $this->encrypt_password(array_merge($decoded, array('nc' => '00000001'))), + 'charset' => 'utf-8', + 'nc' => '00000001', + 'qop' => 'auth'); // the only option we support anyway + + foreach (array('nonce', 'digest-uri', 'realm', 'cnonce') as $key) { + if (isset($decoded[$key])) { + $response[$key] = $decoded[$key]; + } + } + + $this->send("<response xmlns='urn:ietf:params:xml:ns:xmpp-sasl'>" . + base64_encode(Jabber::implode_data($response)) + . "</response>"); + } + + return $this->response($this->listen()); + + case 'failure': + $this->log('Error: Server sent "failure".'); + $this->disconnect(); + return false; + + case 'proceed': + // continue switching to TLS + $meta = stream_get_meta_data($this->connection); + socket_set_blocking($this->connection, 1); + if (!stream_socket_enable_crypto($this->connection, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + $this->log('Error: TLS mode change failed.'); + return false; + } + socket_set_blocking($this->connection, $meta['blocked']); + $this->session['tls'] = true; + // new stream + $this->send("<?xml version='1.0' encoding='UTF-8' ?" . ">\n"); + $this->send("<stream:stream to='{$this->server}' xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' version='1.0'>\n"); + + return $this->response($this->listen()); + + case 'success': + // Yay, authentication successful. + $this->send("<stream:stream to='{$this->server}' xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' version='1.0'>\n"); + $this->session['authenticated'] = true; + return $this->response($this->listen()); // we have to wait for another response + + case 'iq': + // we are not interested in IQs we did not expect + if (!isset($xml['iq'][0]['@']['id'])) { + return false; + } + // multiple possibilities here + switch ($xml['iq'][0]['@']['id']) + { + case 'bind_1': + $this->session['jid'] = $xml['iq'][0]['#']['bind'][0]['#']['jid'][0]['#']; + // and (maybe) yet another request to be able to send messages *finally* + if ($this->session['sess_required']) { + $this->send("<iq to='{$this->server}' + type='set' + id='sess_1'> + <session xmlns='urn:ietf:params:xml:ns:xmpp-session'/> + </iq>"); + return $this->response($this->listen()); + } + return true; + + case 'sess_1': + return true; + + case 'reg_1': + $this->send("<iq type='set' id='reg_2'> + <query xmlns='jabber:iq:register'> + <username>" . Jabber::jspecialchars($this->user) . "</username> + <password>" . Jabber::jspecialchars($this->password) . "</password> + </query> + </iq>"); + return $this->response($this->listen()); + + case 'reg_2': + // registration end + if (isset($xml['iq'][0]['#']['error'])) { + $this->log('Warning: Registration failed.'); + return false; + } + return true; + + case 'unreg_1': + return true; + + default: + $this->log('Notice: Received unexpected IQ.'); + return false; + } + break; + + case 'message': + // we are only interested in content... + if (!isset($xml['message'][0]['#']['body'])) { + return false; + } + + $message['body'] = $xml['message'][0]['#']['body'][0]['#']; + $message['from'] = $xml['message'][0]['@']['from']; + if (isset($xml['message'][0]['#']['subject'])) { + $message['subject'] = $xml['message'][0]['#']['subject'][0]['#']; + } + $this->session['messages'][] = $message; + break; + + default: + // hm...don't know this response + $this->log('Notice: Unknown server response (' . key($xml) . ')'); + return false; + } + } + + public function send_message($to, $text, $subject = '', $type = 'normal') + { + if (!isset($this->session['jid'])) { + return false; + } + + if (!in_array($type, array('chat', 'normal', 'error', 'groupchat', 'headline'))) { + $type = 'normal'; + } + + return $this->send("<message from='" . Jabber::jspecialchars($this->session['jid']) . "' + to='" . Jabber::jspecialchars($to) . "' + type='$type' + id='" . uniqid('msg') . "'> + <subject>" . Jabber::jspecialchars($subject) . "</subject> + <body>" . Jabber::jspecialchars($text) . "</body> + </message>"); + } + + public function get_messages($waitfor = 3) + { + if (!isset($this->session['sent_presence']) || !$this->session['sent_presence']) { + $this->presence(); + } + + if ($waitfor > 0) { + $this->response($this->listen($waitfor, $wait = true)); // let's see if any messages fly in + } + + return isset($this->session['messages']) ? $this->session['messages'] : array(); + } + + public function connected() + { + return is_resource($this->connection) && !feof($this->connection); + } + + public function disconnect() + { + if ($this->connected()) { + // disconnect gracefully + if (isset($this->session['sent_presence'])) { + $this->presence('', 'offline', $unavailable = true); + } + $this->send('</stream:stream>'); + $this->session = array(); + return fclose($this->connection); + } + return false; + } + + public static function can_use_ssl() + { + return extension_loaded('openssl'); + } + + public static function can_use_tls() + { + return Jabber::can_use_ssl() && function_exists('stream_socket_enable_crypto'); + } + + /** + * Encrypts a password as in RFC 2831 + * @param array $data Needs data from the client-server connection + * @access public + * @return string + */ + public function encrypt_password($data) + { + // let's me think about <challenge> again... + foreach (array('realm', 'cnonce', 'digest-uri') as $key) { + if (!isset($data[$key])) { + $data[$key] = ''; + } + } + + $pack = md5($this->user . ':' . $data['realm'] . ':' . $this->password); + if (isset($data['authzid'])) { + $a1 = pack('H32', $pack) . sprintf(':%s:%s:%s', $data['nonce'], $data['cnonce'], $data['authzid']); + } else { + $a1 = pack('H32', $pack) . sprintf(':%s:%s', $data['nonce'], $data['cnonce']); + } + + // should be: qop = auth + $a2 = 'AUTHENTICATE:'. $data['digest-uri']; + + return md5(sprintf('%s:%s:%s:%s:%s:%s', md5($a1), $data['nonce'], $data['nc'], $data['cnonce'], $data['qop'], md5($a2))); + } + + /** + * parse_data like a="b",c="d",... + * @param string $data + * @access public + * @return array a => b ... + */ + public function parse_data($data) + { + // super basic, but should suffice + $data = explode(',', $data); + $pairs = array(); + foreach ($data as $pair) { + $dd = strpos($pair, '='); + if ($dd) { + $pairs[substr($pair, 0, $dd)] = trim(substr($pair, $dd + 1), '"'); + } + } + return $pairs; + } + + /** + * opposite of Jabber::parse_data() + * @param array $data + * @access public + * @return string + */ + public function implode_data($data) + { + $return = array(); + foreach ($data as $key => $value) { + $return[] = $key . '="' . $value . '"'; + } + return implode(',', $return); + } + + /** + * Checks whether or not a Jabber ID is valid (FS#1131) + * @param string $jid + * @access public + * @return string + */ + public function check_jid($jid) + { + $i = strpos($jid, '@'); + if ($i === false) { + return false; + } + + $username = substr($jid, 0, $i); + $realm = substr($jid, $i + 1); + + if (strlen($username) == 0 || strlen($realm) < 3) { + return false; + } + + $arr = explode('.', $realm); + + if (count($arr) == 0) { + return false; + } + + foreach ($arr as $part) + { + if (substr($part, 0, 1) == '-' || substr($part, -1, 1) == '-') { + return false; + } + + if (preg_match("@^[a-zA-Z0-9-.]+$@", $part) == false) { + return false; + } + } + + $b = array(array(0, 127), array(192, 223), array(224, 239), + array(240, 247), array(248, 251), array(252, 253)); + + // Prohibited Characters RFC3454 + RFC3920 + $p = array( + // Table C.1.1 + array(0x0020, 0x0020), // SPACE + // Table C.1.2 + array(0x00A0, 0x00A0), // NO-BREAK SPACE + array(0x1680, 0x1680), // OGHAM SPACE MARK + array(0x2000, 0x2001), // EN QUAD + array(0x2001, 0x2001), // EM QUAD + array(0x2002, 0x2002), // EN SPACE + array(0x2003, 0x2003), // EM SPACE + array(0x2004, 0x2004), // THREE-PER-EM SPACE + array(0x2005, 0x2005), // FOUR-PER-EM SPACE + array(0x2006, 0x2006), // SIX-PER-EM SPACE + array(0x2007, 0x2007), // FIGURE SPACE + array(0x2008, 0x2008), // PUNCTUATION SPACE + array(0x2009, 0x2009), // THIN SPACE + array(0x200A, 0x200A), // HAIR SPACE + array(0x200B, 0x200B), // ZERO WIDTH SPACE + array(0x202F, 0x202F), // NARROW NO-BREAK SPACE + array(0x205F, 0x205F), // MEDIUM MATHEMATICAL SPACE + array(0x3000, 0x3000), // IDEOGRAPHIC SPACE + // Table C.2.1 + array(0x0000, 0x001F), // [CONTROL CHARACTERS] + array(0x007F, 0x007F), // DELETE + // Table C.2.2 + array(0x0080, 0x009F), // [CONTROL CHARACTERS] + array(0x06DD, 0x06DD), // ARABIC END OF AYAH + array(0x070F, 0x070F), // SYRIAC ABBREVIATION MARK + array(0x180E, 0x180E), // MONGOLIAN VOWEL SEPARATOR + array(0x200C, 0x200C), // ZERO WIDTH NON-JOINER + array(0x200D, 0x200D), // ZERO WIDTH JOINER + array(0x2028, 0x2028), // LINE SEPARATOR + array(0x2029, 0x2029), // PARAGRAPH SEPARATOR + array(0x2060, 0x2060), // WORD JOINER + array(0x2061, 0x2061), // FUNCTION APPLICATION + array(0x2062, 0x2062), // INVISIBLE TIMES + array(0x2063, 0x2063), // INVISIBLE SEPARATOR + array(0x206A, 0x206F), // [CONTROL CHARACTERS] + array(0xFEFF, 0xFEFF), // ZERO WIDTH NO-BREAK SPACE + array(0xFFF9, 0xFFFC), // [CONTROL CHARACTERS] + array(0x1D173, 0x1D17A), // [MUSICAL CONTROL CHARACTERS] + // Table C.3 + array(0xE000, 0xF8FF), // [PRIVATE USE, PLANE 0] + array(0xF0000, 0xFFFFD), // [PRIVATE USE, PLANE 15] + array(0x100000, 0x10FFFD), // [PRIVATE USE, PLANE 16] + // Table C.4 + array(0xFDD0, 0xFDEF), // [NONCHARACTER CODE POINTS] + array(0xFFFE, 0xFFFF), // [NONCHARACTER CODE POINTS] + array(0x1FFFE, 0x1FFFF), // [NONCHARACTER CODE POINTS] + array(0x2FFFE, 0x2FFFF), // [NONCHARACTER CODE POINTS] + array(0x3FFFE, 0x3FFFF), // [NONCHARACTER CODE POINTS] + array(0x4FFFE, 0x4FFFF), // [NONCHARACTER CODE POINTS] + array(0x5FFFE, 0x5FFFF), // [NONCHARACTER CODE POINTS] + array(0x6FFFE, 0x6FFFF), // [NONCHARACTER CODE POINTS] + array(0x7FFFE, 0x7FFFF), // [NONCHARACTER CODE POINTS] + array(0x8FFFE, 0x8FFFF), // [NONCHARACTER CODE POINTS] + array(0x9FFFE, 0x9FFFF), // [NONCHARACTER CODE POINTS] + array(0xAFFFE, 0xAFFFF), // [NONCHARACTER CODE POINTS] + array(0xBFFFE, 0xBFFFF), // [NONCHARACTER CODE POINTS] + array(0xCFFFE, 0xCFFFF), // [NONCHARACTER CODE POINTS] + array(0xDFFFE, 0xDFFFF), // [NONCHARACTER CODE POINTS] + array(0xEFFFE, 0xEFFFF), // [NONCHARACTER CODE POINTS] + array(0xFFFFE, 0xFFFFF), // [NONCHARACTER CODE POINTS] + array(0x10FFFE, 0x10FFFF), // [NONCHARACTER CODE POINTS] + // Table C.5 + array(0xD800, 0xDFFF), // [SURROGATE CODES] + // Table C.6 + array(0xFFF9, 0xFFF9), // INTERLINEAR ANNOTATION ANCHOR + array(0xFFFA, 0xFFFA), // INTERLINEAR ANNOTATION SEPARATOR + array(0xFFFB, 0xFFFB), // INTERLINEAR ANNOTATION TERMINATOR + array(0xFFFC, 0xFFFC), // OBJECT REPLACEMENT CHARACTER + array(0xFFFD, 0xFFFD), // REPLACEMENT CHARACTER + // Table C.7 + array(0x2FF0, 0x2FFB), // [IDEOGRAPHIC DESCRIPTION CHARACTERS] + // Table C.8 + array(0x0340, 0x0340), // COMBINING GRAVE TONE MARK + array(0x0341, 0x0341), // COMBINING ACUTE TONE MARK + array(0x200E, 0x200E), // LEFT-TO-RIGHT MARK + array(0x200F, 0x200F), // RIGHT-TO-LEFT MARK + array(0x202A, 0x202A), // LEFT-TO-RIGHT EMBEDDING + array(0x202B, 0x202B), // RIGHT-TO-LEFT EMBEDDING + array(0x202C, 0x202C), // POP DIRECTIONAL FORMATTING + array(0x202D, 0x202D), // LEFT-TO-RIGHT OVERRIDE + array(0x202E, 0x202E), // RIGHT-TO-LEFT OVERRIDE + array(0x206A, 0x206A), // INHIBIT SYMMETRIC SWAPPING + array(0x206B, 0x206B), // ACTIVATE SYMMETRIC SWAPPING + array(0x206C, 0x206C), // INHIBIT ARABIC FORM SHAPING + array(0x206D, 0x206D), // ACTIVATE ARABIC FORM SHAPING + array(0x206E, 0x206E), // NATIONAL DIGIT SHAPES + array(0x206F, 0x206F), // NOMINAL DIGIT SHAPES + // Table C.9 + array(0xE0001, 0xE0001), // LANGUAGE TAG + array(0xE0020, 0xE007F), // [TAGGING CHARACTERS] + // RFC3920 + array(0x22, 0x22), // " + array(0x26, 0x26), // & + array(0x27, 0x27), // ' + array(0x2F, 0x2F), // / + array(0x3A, 0x3A), // : + array(0x3C, 0x3C), // < + array(0x3E, 0x3E), // > + array(0x40, 0x40) // @ + ); + + $pos = 0; + $result = true; + + while ($pos < strlen($username)) + { + $len = 0; + $uni = 0; + for ($i = 0; $i <= 5; $i++) + { + if (ord($username[$pos]) >= $b[$i][0] && ord($username[$pos]) <= $b[$i][1]) + { + $len = $i + 1; + + $uni = (ord($username[$pos]) - $b[$i][0]) * pow(2, $i * 6); + + for ($k = 1; $k < $len; $k++) { + $uni += (ord($username[$pos + $k]) - 128) * pow(2, ($i - $k) * 6); + } + + break; + } + } + + if ($len == 0) { + return false; + } + + foreach ($p as $pval) + { + if ($uni >= $pval[0] && $uni <= $pval[1]) { + $result = false; + break 2; + } + } + + $pos = $pos + $len; + } + + return $result; + } + + public static function jspecialchars($data) + { + return htmlspecialchars($data, ENT_QUOTES, 'utf-8'); + } + + // ====================================================================== + // Third party code, taken from old jabber lib (the only usable code left) + // ====================================================================== + + // xmlize() + // (c) Hans Anderson / http://www.hansanderson.com/php/xml/ + + public static function xmlize($data, $WHITE=1, $encoding='UTF-8') { + + $data = trim($data); + if (substr($data, 0, 5) != '<?xml') { + $data = '<root>'. $data . '</root>'; // mod + } + $vals = $array = array(); + $parser = xml_parser_create($encoding); + xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0); + xml_parser_set_option($parser, XML_OPTION_SKIP_WHITE, $WHITE); + xml_parse_into_struct($parser, $data, $vals); + xml_parser_free($parser); + + $i = 0; + + $tagname = $vals[$i]['tag']; + if ( isset ($vals[$i]['attributes'] ) ) + { + $array[$tagname][0]['@'] = $vals[$i]['attributes']; // mod + } else { + $array[$tagname][0]['@'] = array(); // mod + } + + $array[$tagname][0]["#"] = Jabber::_xml_depth($vals, $i); // mod + if (substr($data, 0, 5) != '<?xml') { + $array = $array['root'][0]['#']; // mod + } + + return $array; + } + + + + // _xml_depth() + // (c) Hans Anderson / http://www.hansanderson.com/php/xml/ + + public static function _xml_depth($vals, &$i) { + $children = array(); + + if ( isset($vals[$i]['value']) ) + { + array_push($children, $vals[$i]['value']); + } + + while (++$i < count($vals)) { + + switch ($vals[$i]['type']) { + + case 'open': + + if ( isset ( $vals[$i]['tag'] ) ) + { + $tagname = $vals[$i]['tag']; + } else { + $tagname = ''; + } + + if ( isset ( $children[$tagname] ) ) + { + $size = sizeof($children[$tagname]); + } else { + $size = 0; + } + + if ( isset ( $vals[$i]['attributes'] ) ) { + $children[$tagname][$size]['@'] = $vals[$i]["attributes"]; + + } + + $children[$tagname][$size]['#'] = Jabber::_xml_depth($vals, $i); + + break; + + + case 'cdata': + array_push($children, $vals[$i]['value']); + break; + + case 'complete': + $tagname = $vals[$i]['tag']; + + if( isset ($children[$tagname]) ) + { + $size = sizeof($children[$tagname]); + } else { + $size = 0; + } + + if( isset ( $vals[$i]['value'] ) ) + { + $children[$tagname][$size]["#"] = $vals[$i]['value']; + } else { + $children[$tagname][$size]["#"] = array(); + } + + if ( isset ($vals[$i]['attributes']) ) { + $children[$tagname][$size]['@'] + = $vals[$i]['attributes']; + } + + break; + + case 'close': + return $children; + break; + } + } + + return $children; + } +} + diff --git a/includes/class.notify.php b/includes/class.notify.php new file mode 100644 index 0000000..386158a --- /dev/null +++ b/includes/class.notify.php @@ -0,0 +1,1114 @@ +<?php + +/* + --------------------------------------------------- + | This script contains the notification functions | + --------------------------------------------------- +*/ + +/** + * Notifications + * + * @package + * @version $Id$ + * @copyright 2006 Flyspray.org + * @notes: This is a mess and should be replaced for 1.0 + */ + +class Notifications { + + // {{{ Wrapper function for all others + function create($type, $task_id, $info = null, $to = null, $ntype = NOTIFY_BOTH, $proj_lang = null) { + global $fs; + + if (is_null($to)) { + $to = $this->address($task_id, $type); + } + + if (!is_array($to)) { + settype($to, 'array'); + } + + if (!count($to)) { + return false; + } + + $languages = array(); + $emails = array(); + $jabbers = array(); + $onlines = array(); + + if (isset($to[0])) { + foreach ($to[0] as $recipient) { + if (!empty($recipient['lang'])) { + $lang = $recipient['lang']; + } else if (!empty($proj_lang)) { + $lang = $proj_lang; + } else { + $lang = $fs->prefs['lang_code']; + } + $emails[$lang][] = $recipient['recipient']; + if (!in_array($lang, $languages)) { + $languages[] = $lang; + } + } + } + + if (isset($to[1])) { + foreach ($to[1] as $recipient) { + if (!empty($recipient['lang'])) { + $lang = $recipient['lang']; + } else if (!empty($proj_lang)) { + $lang = $proj_lang; + } else { + $lang = $fs->prefs['lang_code']; + } + $jabbers[$lang][] = $recipient['recipient']; + if (!in_array($lang, $languages)) { + $languages[] = $lang; + } + } + } + /* + if (isset($to[2])) { + foreach ($to[2] as $recipient) { + $lang = $recipient['lang']; + if ($lang == 'j') + echo "<pre>Error 3!</pre>"; + $onlines[$lang][] = $recipient['recipient']; + if (!in_array($lang, $languages)) { + $languages[] = $lang; + } + } + } + */ + + $result = true; + foreach ($languages as $lang) { + $msg = $this->generateMsg($type, $task_id, $info, $lang); + if (isset($emails[$lang]) && ($ntype == NOTIFY_EMAIL || $ntype == NOTIFY_BOTH)) { + if (!$this->sendEmail($emails[$lang], $msg[0], $msg[1], $task_id)) { + $result = false; + } + } + + if (isset($jabbers[$lang]) && ($ntype == NOTIFY_JABBER || $ntype == NOTIFY_BOTH)) { + if (!$this->storeJabber($jabbers[$lang], $msg[0], $msg[1])) { + $result = false; + } + } + + // Get rid of undefined offset 2 when notify type is explicitly set, + // in these cases caller really has not set offset 2. Track down the + // callers later. + /* + if (isset($onlines[$lang]) && ($ntype != NOTIFY_EMAIL && $ntype != NOTIFY_JABBER)) { + if (!$this->StoreOnline($onlines[$lang], $msg[2], $msg[3], $task_id)) { + $result = false; + } + } + */ + } + return $result; + + // End of Create() function + } + + function storeOnline($to, $subject, $body, $online, $task_id = null) { + global $db, $fs; + + if (!count($to)) { + return false; + } + + $date = time(); + + // store notification in table + $db->query("INSERT INTO {notification_messages} + (message_subject, message_body, time_created) + VALUES (?, ?, ?)", array($online, '', $date) + ); + + // grab notification id + /* + $result = $db->query("SELECT message_id FROM {notification_messages} + WHERE time_created = ? ORDER BY message_id DESC", array($date), 1); + + $row = $db->fetchRow($result); + $message_id = $row['message_id']; + */ + $message_id = $db->insert_ID(); + // If message could not be inserted for whatever reason... + if (!$message_id) { + return false; + } + + // make sure every user is only added once + settype($to, 'array'); + $to = array_unique($to); + + foreach ($to as $jid) { + // store each recipient in table + $db->query("INSERT INTO {notification_recipients} + (notify_method, message_id, notify_address) + VALUES (?, ?, ?)", array('o', $message_id, $jid) + ); + } + + return true; + } + + static function getUnreadNotifications() { + global $db, $fs, $user; + + $notifications = $db->query('SELECT r.recipient_id, m.message_subject + FROM {notification_recipients} r + JOIN {notification_messages} m ON r.message_id = m.message_id + WHERE r.notify_method = ? AND notify_address = ?', + array('o', $user['user_id'])); + return $db->fetchAllArray($notifications); + } + + static function NotificationsHaveBeenRead($ids) { + global $db, $fs, $user; + + $readones = join(",", array_map('intval', $ids)); + if($readones !=''){ + $db->query(" + DELETE FROM {notification_recipients} + WHERE message_id IN ($readones) + AND notify_method = ? + AND notify_address = ?", + array('o', $user['user_id'] + ) + ); + } + } + + // {{{ Store Jabber messages for sending later + function storeJabber( $to, $subject, $body ) + { + global $db, $fs; + + if (empty($fs->prefs['jabber_server']) + || empty($fs->prefs['jabber_port']) + || empty($fs->prefs['jabber_username']) + || empty($fs->prefs['jabber_password'])) { + return false; + } + + if (empty($to)) { + return false; + } + + $date = time(); + + // store notification in table + $db->query("INSERT INTO {notification_messages} + (message_subject, message_body, time_created) + VALUES (?, ?, ?)", + array($subject, $body, $date) + ); + + // grab notification id + /* + $result = $db->query("SELECT message_id FROM {notification_messages} + WHERE time_created = ? ORDER BY message_id DESC", + array($date), 1); + + $row = $db->fetchRow($result); + $message_id = $row['message_id']; + */ + $message_id = $db->insert_ID(); + // If message could not be inserted for whatever reason... + if (!$message_id) { + return false; + } + + settype($to, 'array'); + + $duplicates = array(); + foreach ($to as $jid) { + // make sure every recipient is only added once + if (in_array($jid, $duplicates)) { + continue; + } + $duplicates[] = $jid; + // store each recipient in table + $db->query("INSERT INTO {notification_recipients} + (notify_method, message_id, notify_address) + VALUES (?, ?, ?)", + array('j', $message_id, $jid) + ); + + } + + return true; + } // }}} + + static function jabberRequestAuth($email) + { + global $fs; + + include_once BASEDIR . '/includes/class.jabber2.php'; + + if (empty($fs->prefs['jabber_server']) + || empty($fs->prefs['jabber_port']) + || empty($fs->prefs['jabber_username']) + || empty($fs->prefs['jabber_password'])) { + return false; + } + + $JABBER = new Jabber($fs->prefs['jabber_username'] . '@' . $fs->prefs['jabber_server'], + $fs->prefs['jabber_password'], + $fs->prefs['jabber_ssl'], + $fs->prefs['jabber_port']); + $JABBER->login(); + $JABBER->send("<presence to='" . Jabber::jspecialchars($email) . "' type='subscribe'/>"); + $JABBER->disconnect(); + } + + // {{{ send Jabber messages that were stored earlier + function sendJabber() + { + global $db, $fs; + + include_once BASEDIR . '/includes/class.jabber2.php'; + + if ( empty($fs->prefs['jabber_server']) + || empty($fs->prefs['jabber_port']) + || empty($fs->prefs['jabber_username']) + || empty($fs->prefs['jabber_password'])) { + return false; + } + + // get listing of all pending jabber notifications + $result = $db->query("SELECT DISTINCT message_id + FROM {notification_recipients} + WHERE notify_method='j'"); + + if (!$db->countRows($result)) { + return false; + } + + $JABBER = new Jabber($fs->prefs['jabber_username'] . '@' . $fs->prefs['jabber_server'], + $fs->prefs['jabber_password'], + $fs->prefs['jabber_ssl'], + $fs->prefs['jabber_port']); + $JABBER->login(); + + // we have notifications to process - connect + $JABBER->log("We have notifications to process..."); + $JABBER->log("Starting Jabber session:"); + + $ids = array(); + + while ( $row = $db->fetchRow($result) ) { + $ids[] = $row['message_id']; + } + + $desired = join(",", array_map('intval', $ids)); + $JABBER->log("message ids to send = {" . $desired . "}"); + + // removed array usage as it's messing up the select + // I suspect this is due to the variable being comma separated + // Jamin W. Collins 20050328 + $notifications = $db->query(" + SELECT * FROM {notification_messages} + WHERE message_id IN ($desired) + ORDER BY time_created ASC" + ); + $JABBER->log("number of notifications {" . $db->countRows($notifications) . "}"); + + // loop through notifications + while ( $notification = $db->fetchRow($notifications) ) { + $subject = $notification['message_subject']; + $body = $notification['message_body']; + + $JABBER->log("Processing notification {" . $notification['message_id'] . "}"); + $recipients = $db->query(" + SELECT * FROM {notification_recipients} + WHERE message_id = ? + AND notify_method = 'j'", + array($notification['message_id']) + ); + + // loop through recipients + while ($recipient = $db->fetchRow($recipients) ) { + $jid = $recipient['notify_address']; + $JABBER->log("- attempting send to {" . $jid . "}"); + + // send notification + if ($JABBER->send_message($jid, $body, $subject, 'normal')) { + // delete entry from notification_recipients + $result = $db->query("DELETE FROM {notification_recipients} + WHERE message_id = ? + AND notify_method = 'j' + AND notify_address = ?", + array($notification['message_id'], $jid) + ); + $JABBER->log("- notification sent"); + } else { + $JABBER->log("- notification not sent"); + } + } + // check to see if there are still recipients for this notification + $result = $db->query("SELECT * FROM {notification_recipients} + WHERE message_id = ?", + array($notification['message_id']) + ); + + if ( $db->countRows($result) == 0 ) { + $JABBER->log("No further recipients for message id {" . $notification['message_id'] . "}"); + // remove notification no more recipients + $result = $db->query("DELETE FROM {notification_messages} + WHERE message_id = ?", + array($notification['message_id']) + ); + $JABBER->log("- Notification deleted"); + } + } + + // disconnect from server + $JABBER->disconnect(); + $JABBER->log("Disconnected from Jabber server"); + + return true; + } // }}} + // {{{ send email + function sendEmail($to, $subject, $body, $task_id = null) + { + global $fs, $proj, $user; + + if (empty($to) || empty($to[0])) { + return; + } + + // Do we want to use a remote mail server? + if (!empty($fs->prefs['smtp_server'])) { + + // connection... SSL, TLS or none + if ($fs->prefs['email_tls']) { + $swiftconn = Swift_SmtpTransport::newInstance($fs->prefs['smtp_server'], 587, 'tls'); + } else if ($fs->prefs['email_ssl']) { + $swiftconn = Swift_SmtpTransport::newInstance($fs->prefs['smtp_server'], 465, 'ssl'); + } else { + $swiftconn = Swift_SmtpTransport::newInstance($fs->prefs['smtp_server']); + } + + if ($fs->prefs['smtp_user']) { + $swiftconn->setUsername($fs->prefs['smtp_user']); + } + + if ($fs->prefs['smtp_pass']){ + $swiftconn->setPassword($fs->prefs['smtp_pass']); + } + + if(defined('FS_SMTP_TIMEOUT')) { + $swiftconn->setTimeout(FS_SMTP_TIMEOUT); + } + // Use php's built-in mail() function + } else { + $swiftconn = Swift_MailTransport::newInstance(); + } + + // Make plaintext URLs into hyperlinks, but don't disturb existing ones! + $htmlbody = preg_replace("/(?<!\")(https?:\/\/)([a-zA-Z0-9\-.]+\.[a-zA-Z0-9\-]+([\/]([a-zA-Z0-9_\/\-.?&%=+#])*)*)/", '<a href="$1$2">$2</a>', $body); + $htmlbody = str_replace("\n","<br>", $htmlbody); + + // Those constants used were introduced in 5.4. + if (version_compare(phpversion(), '5.4.0', '<')) { + $plainbody= html_entity_decode(strip_tags($body)); + } else { + $plainbody= html_entity_decode(strip_tags($body), ENT_COMPAT | ENT_HTML401, 'utf-8'); + } + + $swift = Swift_Mailer::newInstance($swiftconn); + + if(defined('FS_MAIL_LOGFILE')) { + $logger = new Swift_Plugins_Loggers_ArrayLogger(); + $swift->registerPlugin(new Swift_Plugins_LoggerPlugin($logger)); + } + + $message = new Swift_Message($subject); + if (isset($fs->prefs['emailNoHTML']) && $fs->prefs['emailNoHTML'] == '1'){ + $message->setBody($plainbody, 'text/plain'); + }else{ + $message->setBody($htmlbody, 'text/html'); + $message->addPart($plainbody, 'text/plain'); + } + + $type = $message->getHeaders()->get('Content-Type'); + $type->setParameter('charset', 'utf-8'); + + $message->getHeaders()->addTextHeader('Precedence', 'list'); + $message->getHeaders()->addTextHeader('X-Mailer', 'Flyspray'); + + if ($proj->prefs['notify_reply']) { + $message->setReplyTo($proj->prefs['notify_reply']); + } + + if (isset($task_id)) { + $hostdata = parse_url($GLOBALS['baseurl']); + $inreplyto = sprintf('<FS%d@%s>', $task_id, $hostdata['host']); + // see http://cr.yp.to/immhf/thread.html this does not seems to work though :( + $message->getHeaders()->addTextHeader('In-Reply-To', $inreplyto); + $message->getHeaders()->addTextHeader('References', $inreplyto); + } + + // accepts string, array, or Swift_Address + if( is_array($to) && count($to)>1 ){ + $message->setTo($fs->prefs['admin_email']); + $message->setBcc($to); + } else{ + $message->setTo($to); + } + $message->setFrom(array($fs->prefs['admin_email'] => $proj->prefs['project_title'])); + $swift->send($message); + + if(defined('FS_MAIL_LOGFILE')) { + if(is_writable(dirname(FS_MAIL_LOGFILE))) { + if($fh = fopen(FS_MAIL_LOGFILE, 'ab')) { + fwrite($fh, $logger->dump()); + fwrite($fh, php_uname()); + fclose($fh); + } + } + } + + return true; + } //}}} + // {{{ create a message for any occasion + function generateMsg($type, $task_id, $arg1 = '0', $lang) { + global $db, $fs, $user, $proj; + + // Get the task details + $task_details = Flyspray::getTaskDetails($task_id); + if ($task_id) { + $proj = new Project($task_details['project_id']); + } + + // Set the due date correctly + if ($task_details['due_date'] == '0') { + $due_date = tL('undecided', $lang); + } else { + $due_date = formatDate($task_details['due_date']); + } + + // Set the due version correctly + if ($task_details['closedby_version'] == '0') { + $task_details['due_in_version_name'] = tL('undecided', $lang); + } + + // Get the string of modification + $notify_type_msg = array( + 0 => tL('none'), + NOTIFY_TASK_OPENED => tL('taskopened', $lang), + NOTIFY_TASK_CHANGED => tL('pm.taskchanged', $lang), + NOTIFY_TASK_CLOSED => tL('taskclosed', $lang), + NOTIFY_TASK_REOPENED => tL('pm.taskreopened', $lang), + NOTIFY_DEP_ADDED => tL('pm.depadded', $lang), + NOTIFY_DEP_REMOVED => tL('pm.depremoved', $lang), + NOTIFY_COMMENT_ADDED => tL('commentadded', $lang), + NOTIFY_ATT_ADDED => tL('attachmentadded', $lang), + NOTIFY_REL_ADDED => tL('relatedadded', $lang), + NOTIFY_OWNERSHIP => tL('ownershiptaken', $lang), + NOTIFY_PM_REQUEST => tL('pmrequest', $lang), + NOTIFY_PM_DENY_REQUEST => tL('pmrequestdenied', $lang), + NOTIFY_NEW_ASSIGNEE => tL('newassignee', $lang), + NOTIFY_REV_DEP => tL('revdepadded', $lang), + NOTIFY_REV_DEP_REMOVED => tL('revdepaddedremoved', $lang), + NOTIFY_ADDED_ASSIGNEES => tL('assigneeadded', $lang), + ); + + // Generate the nofication message + if (isset($proj->prefs['notify_subject']) && !$proj->prefs['notify_subject']) { + $proj->prefs['notify_subject'] = '[%p][#%t] %s'; + } + if (!isset($proj->prefs['notify_subject']) || + $type == NOTIFY_CONFIRMATION || + $type == NOTIFY_ANON_TASK || + $type == NOTIFY_PW_CHANGE || + $type == NOTIFY_NEW_USER || + $type == NOTIFY_OWN_REGISTRATION) { + $subject = tL('notifyfromfs', $lang); + } else { + $subject = strtr($proj->prefs['notify_subject'], array('%p' => $proj->prefs['project_title'], + '%s' => $task_details['item_summary'], + '%t' => $task_id, + '%a' => $notify_type_msg[$type], + '%u' => $user->infos['user_name'])); + } + + $subject = strtr($subject, "\n", ''); + + + /* ------------------------------- + | List of notification types: | + | 1. Task opened | + | 2. Task details changed | + | 3. Task closed | + | 4. Task re-opened | + | 5. Dependency added | + | 6. Dependency removed | + | 7. Comment added | + | 8. Attachment added | + | 9. Related task added | + |10. Taken ownership | + |11. Confirmation code | + |12. PM request | + |13. PM denied request | + |14. New assignee | + |15. Reversed dep | + |16. Reversed dep removed | + |17. Added to assignees list | + |18. Anon-task opened | + |19. Password change | + |20. New user | + |21. User registration | + ------------------------------- + */ + + $body = tL('donotreply', $lang) . "\n\n"; + $online = ''; + + // {{{ New task opened + if ($type == NOTIFY_TASK_OPENED) { + $body .= tL('newtaskopened', $lang) . " \n\n"; + $body .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ") \n\n"; + $body .= tL('attachedtoproject', $lang) . ' - ' . $task_details['project_title'] . "\n"; + $body .= tL('summary', $lang) . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('tasktype', $lang) . ' - ' . $task_details['tasktype_name'] . "\n"; + $body .= tL('category', $lang) . ' - ' . $task_details['category_name'] . "\n"; + $body .= tL('status', $lang) . ' - ' . $task_details['status_name'] . "\n"; + $body .= tL('assignedto', $lang) . ' - ' . implode(', ', $task_details['assigned_to_name']) . "\n"; + $body .= tL('operatingsystem', $lang) . ' - ' . $task_details['os_name'] . "\n"; + $body .= tL('severity', $lang) . ' - ' . $task_details['severity_name'] . "\n"; + $body .= tL('priority', $lang) . ' - ' . $task_details['priority_name'] . "\n"; + $body .= tL('reportedversion', $lang) . ' - ' . $task_details['reported_version_name'] . "\n"; + $body .= tL('dueinversion', $lang) . ' - ' . $task_details['due_in_version_name'] . "\n"; + $body .= tL('duedate', $lang) . ' - ' . $due_date . "\n"; + $body .= tL('details', $lang) . ' - ' . $task_details['detailed_desc'] . "\n\n"; + + if ($arg1 == 'files') { + $body .= tL('fileaddedtoo', $lang) . "\n\n"; + $subject .= ' (' . tL('attachmentadded', $lang) . ')'; + } + + $body .= tL('moreinfo', $lang) . "\n"; + $body .= createURL('details', $task_id); + + $online .= tL('newtaskopened', $lang) . ". "; + $online .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . "). "; + $online .= tL('attachedtoproject', $lang) . ' - ' . $task_details['project_title'] . ". "; + $online .= tL('summary', $lang) . ' - ' . $task_details['item_summary']; + } // }}} + // {{{ Task details changed + if ($type == NOTIFY_TASK_CHANGED) { + $translation = array('priority_name' => tL('priority', $lang), + 'severity_name' => tL('severity', $lang), + 'status_name' => tL('status', $lang), + 'assigned_to_name' => tL('assignedto', $lang), + 'due_in_version_name' => tL('dueinversion', $lang), + 'reported_version_name' => tL('reportedversion', $lang), + 'tasktype_name' => tL('tasktype', $lang), + 'os_name' => tL('operatingsystem', $lang), + 'category_name' => tL('category', $lang), + 'due_date' => tL('duedate', $lang), + 'percent_complete' => tL('percentcomplete', $lang), + 'mark_private' => tL('visibility', $lang), + 'item_summary' => tL('summary', $lang), + 'detailed_desc' => tL('taskedited', $lang), + 'project_title' => tL('attachedtoproject', $lang), + 'estimated_effort' => tL('estimatedeffort', $lang)); + + $body .= tL('taskchanged', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('userwho', $lang) . ': ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ")\n"; + + $online .= tL('taskchanged', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary']; + + foreach ($arg1 as $change) { + if ($change[0] == 'assigned_to_name') { + $change[1] = implode(', ', $change[1]); + $change[2] = implode(', ', $change[2]); + } + + if ($change[0] == 'detailed_desc') { + $body .= $translation[$change[0]] . ":\n-------\n" . $change[2] . "\n-------\n"; + } else { + $body .= $translation[$change[0]] . ': ' . ( ($change[1]) ? $change[1] : '[-]' ) . ' -> ' . ( ($change[2]) ? $change[2] : '[-]' ) . "\n"; + } + } + $body .= "\n" . tL('moreinfo', $lang) . "\n"; + $body .= createURL('details', $task_id); + } // }}} + // {{{ Task closed + if ($type == NOTIFY_TASK_CLOSED) { + $body .= tL('notify.taskclosed', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ")\n\n"; + $body .= tL('reasonforclosing', $lang) . ' ' . $task_details['resolution_name'] . "\n"; + + if (!empty($task_details['closure_comment'])) { + $body .= tL('closurecomment', $lang) . ' ' . $task_details['closure_comment'] . "\n\n"; + } + + $body .= tL('moreinfo', $lang) . "\n"; + $body .= createURL('details', $task_id); + + $online .= tL('notify.taskclosed', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . ". "; + $online .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . "). "; + } // }}} + // {{{ Task re-opened + if ($type == NOTIFY_TASK_REOPENED) { + $body .= tL('notify.taskreopened', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ")\n\n"; + $body .= tL('moreinfo', $lang) . "\n"; + $body .= createURL('details', $task_id); + + $online .= tL('notify.taskreopened', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . ". "; + $online .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . "). "; + } // }}} + // {{{ Dependency added + if ($type == NOTIFY_DEP_ADDED) { + $depend_task = Flyspray::getTaskDetails($arg1); + + $body .= tL('newdep', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ")\n"; + $body .= createURL('details', $task_id) . "\n\n\n"; + $body .= tL('newdepis', $lang) . ':' . "\n\n"; + $body .= 'FS#' . $depend_task['task_id'] . ' - ' . $depend_task['item_summary'] . "\n"; + $body .= createURL('details', $depend_task['task_id']); + + $online .= tL('newdep', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . ". "; + $online .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . "). "; + } // }}} + // {{{ Dependency removed + if ($type == NOTIFY_DEP_REMOVED) { + $depend_task = Flyspray::getTaskDetails($arg1); + + $body .= tL('notify.depremoved', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ")\n"; + $body .= createURL('details', $task_id) . "\n\n\n"; + $body .= tL('removeddepis', $lang) . ':' . "\n\n"; + $body .= 'FS#' . $depend_task['task_id'] . ' - ' . $depend_task['item_summary'] . "\n"; + $body .= createURL('details', $depend_task['task_id']); + + $online .= tL('notify.depremoved', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . ". "; + $online .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . "). "; + } // }}} + // {{{ Comment added + if ($type == NOTIFY_COMMENT_ADDED) { + // Get the comment information + $result = $db->query("SELECT comment_id, comment_text + FROM {comments} + WHERE user_id = ? + AND task_id = ? + ORDER BY comment_id DESC", array($user->id, $task_id), '1'); + $comment = $db->fetchRow($result); + + $body .= tL('notify.commentadded', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ")\n\n"; + $body .= "----------\n"; + $body .= $comment['comment_text'] . "\n"; + $body .= "----------\n\n"; + + if ($arg1 == 'files') { + $body .= tL('fileaddedtoo', $lang) . "\n\n"; + $subject .= ' (' . tL('attachmentadded', $lang) . ')'; + } + + $body .= tL('moreinfo', $lang) . "\n"; + $body .= createURL('details', $task_id) . '#comment' . $comment['comment_id']; + + $online .= tL('notify.commentadded', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . ". "; + $online .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . "). "; + } // }}} + // {{{ Attachment added + if ($type == NOTIFY_ATT_ADDED) { + $body .= tL('newattachment', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ")\n\n"; + $body .= tL('moreinfo', $lang) . "\n"; + $body .= createURL('details', $task_id); + + $online .= tL('newattachment', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . ". "; + $online .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . "). "; + } // }}} + // {{{ Related task added + if ($type == NOTIFY_REL_ADDED) { + $related_task = Flyspray::getTaskDetails($arg1); + + $body .= tL('notify.relatedadded', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ")\n"; + $body .= createURL('details', $task_id) . "\n\n\n"; + $body .= tL('relatedis', $lang) . ':' . "\n\n"; + $body .= 'FS#' . $related_task['task_id'] . ' - ' . $related_task['item_summary'] . "\n"; + $body .= createURL('details', $related_task['task_id']); + + $online .= tL('notify.relatedadded', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . ". "; + $online .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . "). "; + } // }}} + // {{{ Ownership taken + if ($type == NOTIFY_OWNERSHIP) { + $body .= implode(', ', $task_details['assigned_to_name']) . ' ' . tL('takenownership', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n\n"; + $body .= tL('moreinfo', $lang) . "\n"; + $body .= createURL('details', $task_id); + + $online .= implode(', ', $task_details['assigned_to_name']) . ' ' . tL('takenownership', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "."; + } // }}} + // {{{ Confirmation code + if ($type == NOTIFY_CONFIRMATION) { + $body .= tL('noticefrom', $lang) . " {$proj->prefs['project_title']}\n\n" + . tL('addressused', $lang) . "\n\n" + . " {$arg1[0]}index.php?do=register&magic_url={$arg1[1]} \n\n" + // In case that spaces in the username have been removed + . tL('username', $lang) . ': ' . $arg1[2] . "\n" + . tL('confirmcodeis', $lang) . " $arg1[3] \n\n"; + + $online = $body; + } // }}} + // {{{ Pending PM request + if ($type == NOTIFY_PM_REQUEST) { + $body .= tL('requiresaction', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('userwho') . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ")\n\n"; + $body .= tL('moreinfo', $lang) . "\n"; + $body .= createURL('details', $task_id); + + $online .= tL('requiresaction', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . ". "; + $online .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . "). "; + } // }}} + // {{{ PM request denied + if ($type == NOTIFY_PM_DENY_REQUEST) { + $body .= tL('pmdeny', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ")\n\n"; + $body .= tL('denialreason', $lang) . ':' . "\n"; + $body .= $arg1 . "\n\n"; + $body .= tL('moreinfo', $lang) . "\n"; + $body .= createURL('details', $task_id); + + $online .= tL('pmdeny', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . ". "; + $online .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . "). "; + } // }}} + // {{{ New assignee + if ($type == NOTIFY_NEW_ASSIGNEE) { + $body .= tL('assignedtoyou', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ")\n\n"; + $body .= tL('moreinfo', $lang) . "\n"; + $body .= createURL('details', $task_id); + + $online .= tL('assignedtoyou', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . ". "; + $online .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . "). "; + } // }}} + // {{{ Reversed dep + if ($type == NOTIFY_REV_DEP) { + $depend_task = Flyspray::getTaskDetails($arg1); + + $body .= tL('taskwatching', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ")\n"; + $body .= createURL('details', $task_id) . "\n\n\n"; + $body .= tL('isdepfor', $lang) . ':' . "\n\n"; + $body .= 'FS#' . $depend_task['task_id'] . ' - ' . $depend_task['item_summary'] . "\n"; + $body .= createURL('details', $depend_task['task_id']); + + $online .= tL('taskwatching', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . ". "; + $online .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . "). "; + } // }}} + // {{{ Reversed dep - removed + if ($type == NOTIFY_REV_DEP_REMOVED) { + $depend_task = Flyspray::getTaskDetails($arg1); + + $body .= tL('taskwatching', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ")\n"; + $body .= createURL('details', $task_id) . "\n\n\n"; + $body .= tL('isnodepfor', $lang) . ':' . "\n\n"; + $body .= 'FS#' . $depend_task['task_id'] . ' - ' . $depend_task['item_summary'] . "\n"; + $body .= createURL('details', $depend_task['task_id']); + + $online .= tL('taskwatching', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . ". "; + $online .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . "). "; + } // }}} + // {{{ User added to assignees list + if ($type == NOTIFY_ADDED_ASSIGNEES) { + $body .= tL('useraddedtoassignees', $lang) . "\n\n"; + $body .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . "\n"; + $body .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . ")\n"; + $body .= createURL('details', $task_id); + + $online .= tL('useraddedtoassignees', $lang) . ". "; + $online .= 'FS#' . $task_id . ' - ' . $task_details['item_summary'] . ". "; + $online .= tL('userwho', $lang) . ' - ' . $user->infos['real_name'] . ' (' . $user->infos['user_name'] . "). "; + } // }}} + // {{{ Anon-task has been opened + if ($type == NOTIFY_ANON_TASK) { + $body .= tL('thankyouforbug', $lang) . "\n\n"; + $body .= createURL('details', $task_id, null, array('task_token' => $arg1)) . "\n\n"; + + $online .= tL('thankyouforbug') . ""; + } // }}} + // {{{ Password change + if ($type == NOTIFY_PW_CHANGE) { + $body = tL('magicurlmessage', $lang) . " \n" + . "{$arg1[0]}index.php?do=lostpw&magic_url=$arg1[1]\n\n" + . tL('messagefrom', $lang) . $arg1[0]; + $online = $body; + } // } }} + // {{{ New user + if ($type == NOTIFY_NEW_USER) { + $body = tL('newuserregistered', $lang) . " \n\n" + . tL('username', $lang) . ': ' . $arg1[1] . "\n" . + tL('realname', $lang) . ': ' . $arg1[2] . "\n"; + $online = $body; + + if ($arg1[6]) { + $body .= tL('password', $lang) . ': ' . $arg1[5] . "\n"; + } + + $body .= tL('emailaddress', $lang) . ': ' . $arg1[3] . "\n"; + $body .= tL('jabberid', $lang) . ':' . $arg1[4] . "\n\n"; + $body .= tL('messagefrom', $lang) . $arg1[0]; + } // }}} + // {{{ New user him/herself + if ($type == NOTIFY_OWN_REGISTRATION) { + $body = tL('youhaveregistered', $lang) . " \n\n" + . tL('username', $lang) . ': ' . $arg1[1] . "\n" . + tL('realname', $lang) . ': ' . $arg1[2] . "\n"; + $online = $body; + + if ($arg1[6]) { + $body .= tL('password', $lang) . ': ' . $arg1[5] . "\n"; + } + + $body .= tL('emailaddress', $lang) . ': ' . $arg1[3] . "\n"; + $body .= tL('jabberid', $lang) . ':' . $arg1[4] . "\n\n"; + + // Add something here to tell the user whether the registration must + // first be accepted by Administrators or not. And if it had and was + // rejected, the reason. Check first what happening when requests are + // either denied or accepted. + + $body .= tL('messagefrom', $lang) . $arg1[0]; + } // }}} + + $body .= "\n\n" . tL('disclaimer', $lang); + return array(Notifications::fixMsgData($subject), Notifications::fixMsgData($body), $online); + } + +// }}} + + public static function assignRecipients($recipients, &$emails, &$jabbers, &$onlines, $ignoretype = false) { + global $db, $fs, $user; + + if (!is_array($recipients)) { + return false; + } + + foreach ($recipients as $recipient) { + if ($recipient['user_id'] == $user->id && !$user->infos['notify_own']) { + continue; + } + + if (($fs->prefs['user_notify'] == '1' && ($recipient['notify_type'] == NOTIFY_EMAIL || $recipient['notify_type'] == NOTIFY_BOTH) ) || $fs->prefs['user_notify'] == '2' || $ignoretype) { + if (isset($recipient['email_address']) && !empty($recipient['email_address'])) { + $emails[$recipient['email_address']] = array('recipient' => $recipient['email_address'], 'lang' => $recipient['lang_code']); + } + } + + if (($fs->prefs['user_notify'] == '1' && ($recipient['notify_type'] == NOTIFY_JABBER || $recipient['notify_type'] == NOTIFY_BOTH) ) || $fs->prefs['user_notify'] == '3' || $ignoretype) { + if (isset($recipient['jabber_id']) && !empty($recipient['jabber_id']) && $recipient['jabber_id']) { + $jabbers[$recipient['jabber_id']] = array('recipient' => $recipient['jabber_id'], 'lang' => $recipient['lang_code']); + } + } + /* + if ($fs->prefs['user_notify'] == '1' && $recipient['notify_online']) { + $onlines[$recipient['user_id']] = array('recipient' => $recipient['user_id'], 'lang' => $recipient['lang_code']); + } + */ + } + } + + // {{{ Create an address list for specific users + function specificAddresses($users, $ignoretype = false) { + global $db, $fs, $user; + + $emails = array(); + $jabbers = array(); + $onlines = array(); + + if (!is_array($users)) { + settype($users, 'array'); + } + + if (count($users) < 1) { + return array(); + } + + $sql = $db->query('SELECT u.user_id, u.email_address, u.jabber_id, + u.notify_online, u.notify_type, u.notify_own, u.lang_code + FROM {users} u + WHERE' . substr(str_repeat(' user_id = ? OR ', count($users)), 0, -3), array_values($users)); + + self::assignRecipients($db->fetchAllArray($sql), $emails, $jabbers, $onlines, $ignoretype); + + return array($emails, $jabbers, $onlines); + } + +// }}} + + // {{{ Create a standard address list of users (assignees, notif tab and proj addresses) + function address($task_id, $type) { + global $db, $fs, $proj, $user; + + $users = array(); + $emails = array(); + $jabbers = array(); + $onlines = array(); + + $task_details = Flyspray::getTaskDetails($task_id); + + // Get list of users from the notification tab + $get_users = $db->query(' + SELECT * FROM {notifications} n + LEFT JOIN {users} u ON n.user_id = u.user_id + WHERE n.task_id = ?', + array($task_id) + ); + self::assignRecipients($db->fetchAllArray($get_users), $emails, $jabbers, $onlines); + + // Get list of assignees + $get_users = $db->query(' + SELECT * FROM {assigned} a + LEFT JOIN {users} u ON a.user_id = u.user_id + WHERE a.task_id = ?', + array($task_id) + ); + self::assignRecipients($db->fetchAllArray($get_users), $emails, $jabbers, $onlines); + + // Now, we add the project contact addresses... + // ...but only if the task is public + if ($task_details['mark_private'] != '1' + && in_array($type, Flyspray::int_explode(' ', $proj->prefs['notify_types']))) { + + // FIXME! Have to find users preferred language here too, + // must fetch from database. But the address could also be a mailing + // list address and user not exist in database, use fs->prefs in that case, + + $proj_emails = preg_split('/[\s,;]+/', $proj->prefs['notify_email'], -1, PREG_SPLIT_NO_EMPTY); + $desired = implode("','", $proj_emails); + if($desired !=''){ + $get_users = $db->query(" + SELECT DISTINCT u.user_id, u.email_address, u.jabber_id, + u.notify_online, u.notify_type, u.notify_own, u.lang_code + FROM {users} u + WHERE u.email_address IN ('$desired')" + ); + + self::assignRecipients($db->fetchAllArray($get_users), $emails, $jabbers, $onlines); + } + + $proj_jids = explode(',', $proj->prefs['notify_jabber']); + $desired = implode("','", $proj_jids); + if($desired!='') { + $get_users = $db->query(" + SELECT DISTINCT u.user_id, u.email_address, u.jabber_id, + u.notify_online, u.notify_type, u.notify_own, u.lang_code + FROM {users} u + WHERE u.jabber_id IN ('$desired')" + ); + self::assignRecipients($db->fetchAllArray($get_users), $emails, $jabbers, $onlines); + } + + // Now, handle notification addresses that are not assigned to any user... + foreach ($proj_emails as $email) { + if (!array_key_exists($email, $emails)) { + $emails[$email] = array('recipient' => $email, 'lang' => $fs->prefs['lang_code']); + } + } + + foreach ($proj_jids as $jabber) { + if (!array_key_exists($jabber, $jabbers)) { + $jabbers[$jabber] = array('recipient' => $jabber, 'lang' => $fs->prefs['lang_code']); + } + } + /* + echo "<pre>"; + echo var_dump($proj_emails); + echo var_dump($proj_jids); + echo "</pre>"; + */ + // End of checking if a task is private + } + // Send back three arrays containing the notification addresses + return array($emails, $jabbers, $onlines); + } + +// }}} + + // {{{ Fix the message data + /** + * fixMsgData + * a 0.9.9.x ONLY workaround for the "truncated email problem" + * based on code Henri Sivonen (http://hsivonen.iki.fi) + * @param mixed $data + * @access public + * @return void + */ + function fixMsgData($data) + { + // at the first step, remove all NUL bytes + //users with broken databases encoding can give us this :( + $data = str_replace(chr(0), '', $data); + + //then remove all invalid utf8 secuences + $UTF8_BAD = + '([\x00-\x7F]'. # ASCII (including control chars) + '|[\xC2-\xDF][\x80-\xBF]'. # non-overlong 2-byte + '|\xE0[\xA0-\xBF][\x80-\xBF]'. # excluding overlongs + '|[\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}'. # straight 3-byte + '|\xED[\x80-\x9F][\x80-\xBF]'. # excluding surrogates + '|\xF0[\x90-\xBF][\x80-\xBF]{2}'. # planes 1-3 + '|[\xF1-\xF3][\x80-\xBF]{3}'. # planes 4-15 + '|\xF4[\x80-\x8F][\x80-\xBF]{2}'. # plane 16 + '|(.{1}))'; # invalid byte + + $valid_data = ''; + + while (preg_match('/'.$UTF8_BAD.'/S', $data, $matches)) { + if ( !isset($matches[2])) { + $valid_data .= $matches[0]; + } else { + $valid_data .= '?'; + } + $data = substr($data, strlen($matches[0])); + } + return $valid_data; + } //}}} + +// End of Notify class +} diff --git a/includes/class.project.php b/includes/class.project.php new file mode 100644 index 0000000..55a4231 --- /dev/null +++ b/includes/class.project.php @@ -0,0 +1,474 @@ +<?php + +class Project +{ + var $id = 0; + var $prefs = array(); + + function __construct($id) + { + global $db, $fs; + + if (is_numeric($id)) { + $sql = $db->query("SELECT p.*, c.content AS pm_instructions, c.last_updated AS cache_update + FROM {projects} p + LEFT JOIN {cache} c ON c.topic = p.project_id AND c.type = 'msg' + WHERE p.project_id = ?", array($id)); + if ($db->countRows($sql)) { + $this->prefs = $db->fetchRow($sql); + $this->id = (int) $this->prefs['project_id']; + $sortrules=explode(',', $this->prefs['default_order_by']); + foreach($sortrules as $rule){ + $last_space=strrpos($rule, ' '); + if ($last_space === false){ + # temporarly + $sorting[]=array('field'=>$rule, 'dir'=> $this->prefs['default_order_by_dir']); + # future - when column default_order_by_dir removed from project table: + #$sorting[]=array('field'=>$rule, 'dir'=>'desc'); + }else{ + $sorting[]=array( + 'field'=>trim(substr($rule, 0, $last_space)), + 'dir'=>trim(substr($rule, $last_space)) + ); + } + } + # using an extra name until default_order_by_dir completely removed + $this->prefs['sorting']=$sorting; # we can use this also for highlighting in template which columns are sorted by default in task list! + + # For users with only the 'modify_own_tasks' permission within the project + # Currently hardcoded here to have it available for Flyspray1.0. May move to dbfield in future. + $this->prefs['basic_fields']=array( + 'item_summary', + 'detailed_desc', + 'task_type', + 'product_category', + 'operating_system', + 'task_severity', + 'percent_complete', + 'product_version', + 'estimated_effort' + ); + + return; + } + } + + $this->id = 0; + $this->prefs['project_title'] = L('allprojects'); + $this->prefs['feed_description'] = L('feedforall'); + $this->prefs['theme_style'] = $fs->prefs['global_theme']; + $this->prefs['default_entry'] = $fs->prefs['default_entry']; + $this->prefs['lang_code'] = $fs->prefs['lang_code']; + $this->prefs['project_is_active'] = 1; + $this->prefs['others_view'] = 1; + $this->prefs['others_viewroadmap'] = 0; + $this->prefs['intro_message'] = ''; + $this->prefs['anon_open'] = 0; + $this->prefs['feed_img_url'] = ''; + $this->prefs['notify_reply'] = ''; + $this->prefs['default_due_version'] = 'Undecided'; + $this->prefs['disable_lostpw'] = 0; + $this->prefs['disable_changepw'] = 0; + $this->prefs['hours_per_manday'] = 0; + $this->prefs['estimated_effort_format'] = 0; + $this->prefs['current_effort_done_format'] = 0; + $this->prefs['custom_style']= $fs->prefs['custom_style']; + + $sortrules=explode(',', $fs->prefs['default_order_by']); + foreach($sortrules as $rule){ + $last_space=strrpos($rule, ' '); + if ($last_space === false){ + # temporarly + $sorting[]=array('field'=>$rule, 'dir'=> $fs->prefs['default_order_by_dir']); + # future - when column default_order_by_dir removed from project table: + #$sorting[]=array('field'=>$rule, 'dir'=>'desc'); + }else{ + $sorting[]=array( + 'field'=>trim(substr($rule, 0, $last_space)), + 'dir'=>trim(substr($rule, $last_space)) + ); + } + } + # using an extra name until default_order_by_dir completely removed + $this->prefs['sorting']=$sorting; + } + + # 20150219 peterdd: deprecated + function setCookie() + { + # 20150219 peterdd: unnecessary, setting and using a projectid-cookie makes parallel handling of 2 or more projects in different browser tabs impossible. + # instead, use form variables or variables from the url! + #Flyspray::setCookie('flyspray_project', $this->id); + } + + /** + * private method + */ + function _pm_list_sql($type, $join) + { + global $db; + + // deny the possibility of shooting ourselves in the foot. + // although there is no risky usage atm, the api should never do unexpected things. + if(preg_match('![^A-Za-z0-9_]!', $type)) { + return ''; + } + // Get the column names of list tables for the group by statement + $groupby = $db->getColumnNames('{list_' . $type . '}', 'l.' . $type . '_id', 'l.'); + + $join = 't.'.join(" = l.{$type}_id OR t.", $join)." = l.{$type}_id"; + + return "SELECT l.*, COUNT(t.task_id) AS used_in_tasks, COUNT(CASE t.is_closed WHEN 0 THEN 1 ELSE NULL END) AS opentasks, COUNT(CASE t.is_closed WHEN 1 THEN 1 ELSE NULL END) AS closedtasks + FROM {list_{$type}} l + LEFT JOIN {tasks} t ON ($join) AND (l.project_id=0 OR t.project_id = l.project_id) + WHERE l.project_id = ? + GROUP BY $groupby + ORDER BY list_position"; + } + + /** + * private method + * + * @param mixed $type + * @param mixed $where + * @access protected + * @return string + * @notes The $where parameter is dangerous, think twice what you pass there.. + */ + function _list_sql($type, $where = null) + { + // sanity check. + if(preg_match('![^A-Za-z0-9_]!', $type)) { + return ''; + } + + return "SELECT {$type}_id, {$type}_name + FROM {list_{$type}} + WHERE show_in_list = 1 AND ( project_id = ? OR project_id = 0 ) + $where + ORDER BY list_position"; + } + + function listTaskTypes($pm = false) + { + global $db; + if ($pm) { + return $db->cached_query( + 'pm_task_types'.$this->id, + $this->_pm_list_sql('tasktype', array('task_type')), + array($this->id)); + } else { + return $db->cached_query( + 'task_types'.$this->id, $this->_list_sql('tasktype'), array($this->id)); + } + } + + function listOs($pm = false) + { + global $db; + if ($pm) { + return $db->cached_query( + 'pm_os'.$this->id, + $this->_pm_list_sql('os', array('operating_system')), + array($this->id)); + } else { + return $db->cached_query('os'.$this->id, $this->_list_sql('os'), + array($this->id)); + } + } + + function listVersions($pm = false, $tense = null, $reported_version = null) + { + global $db; + + $params = array($this->id); + + if (is_null($tense)) { + $where = ''; + } else { + $where = 'AND version_tense = ?'; + $params[] = $tense; + } + + if ($pm) { + return $db->cached_query( + 'pm_version'.$this->id, + $this->_pm_list_sql('version', array('product_version', 'closedby_version')), + array($params[0])); + } elseif (is_null($reported_version)) { + return $db->cached_query( + 'version_'.$tense, + $this->_list_sql('version', $where), + $params); + } else { + $params[] = $reported_version; + return $db->cached_query( + 'version_'.$tense, + $this->_list_sql('version', $where . ' OR version_id = ?'), + $params); + } + } + + + function listCategories($project_id = null, $hide_hidden = true, $remove_root = true, $depth = true) + { + global $db, $conf; + + // start with a empty arrays + $right = array(); + $cats = array(); + $g_cats = array(); + + // null = categories of current project + global project, int = categories of specific project + if (is_null($project_id)) { + $project_id = $this->id; + if ($this->id != 0) { + $g_cats = $this->listCategories(0); + } + } + + // retrieve the left and right value of the root node + $result = $db->query("SELECT lft, rgt + FROM {list_category} + WHERE category_name = 'root' AND lft = 1 AND project_id = ?", + array($project_id)); + $row = $db->fetchRow($result); + + $groupby = $db->getColumnNames('{list_category}', 'c.category_id', 'c.'); + + // now, retrieve all descendants of the root node + $result = $db->query('SELECT c.category_id, c.category_name, c.*, count(t.task_id) AS used_in_tasks + FROM {list_category} c + LEFT JOIN {tasks} t ON (t.product_category = c.category_id) + WHERE c.project_id = ? AND lft BETWEEN ? AND ? + GROUP BY ' . $groupby . ' + ORDER BY lft ASC', + array($project_id, intval($row['lft']), intval($row['rgt']))); + + while ($row = $db->fetchRow($result)) { + if ($hide_hidden && !$row['show_in_list'] && $row['lft'] != 1) { + continue; + } + + // check if we should remove a node from the stack + while (count($right) > 0 && $right[count($right)-1] < $row['rgt']) { + array_pop($right); + } + $cats[] = $row + array('depth' => count($right)-1); + + // add this node to the stack + $right[] = $row['rgt']; + } + + // Adjust output for select boxes + if ($depth) { + foreach ($cats as $key => $cat) { + if ($cat['depth'] > 0) { + $cats[$key]['category_name'] = str_repeat('...', $cat['depth']) . $cat['category_name']; + $cats[$key]['1'] = str_repeat('...', $cat['depth']) . $cat['1']; + } + } + } + + if ($remove_root) { + unset($cats[0]); + } + + return array_merge($cats, $g_cats); + } + + function listResolutions($pm = false) + { + global $db; + if ($pm) { + return $db->cached_query( + 'pm_resolutions'.$this->id, + $this->_pm_list_sql('resolution', array('resolution_reason')), + array($this->id)); + } else { + return $db->cached_query('resolution'.$this->id, + $this->_list_sql('resolution'), array($this->id)); + } + } + + function listTaskStatuses($pm = false) + { + global $db; + if ($pm) { + return $db->cached_query( + 'pm_statuses'.$this->id, + $this->_pm_list_sql('status', array('item_status')), + array($this->id)); + } else { + return $db->cached_query('status'.$this->id, + $this->_list_sql('status'), array($this->id)); + } + } + + /* between FS0.9.9.7 to FS1.0alpha2 */ + /* + function listTags($pm = false) + { + global $db; + if ($pm) { + $result= $db->query('SELECT tag AS tag_name, 1 AS list_position, 1 AS show_in_list, COUNT(*) AS used_in_tasks + FROM {tags} tg + JOIN {tasks} t ON t.task_id=tg.task_id + WHERE t.project_id=? + GROUP BY tag + ORDER BY tag', array($this->id)); + } else { + $result= $db->query('SELECT tag AS tag_name, 1 AS list_position, 1 AS show_in_list, COUNT(*) AS used_in_tasks + FROM {tags} + GROUP BY tag + ORDER BY tag'); + } + + $tags=array(); + while ($row = $db->fetchRow($result)) { + $tags[]=$row; + } + return $tags; + } + */ + /* rewrite of tags feature, FS1.0beta1 */ + function listTags($pm = false) + { + global $db; + if ($pm) { + $result= $db->query('SELECT tg.*, COUNT(tt.task_id) AS used_in_tasks + FROM {list_tag} tg + LEFT JOIN {task_tag} tt ON tt.tag_id=tg.tag_id + LEFT JOIN {tasks} t ON t.task_id=tt.task_id + WHERE tg.project_id=? + GROUP BY tg.tag_id + ORDER BY tg.list_position', array($this->id)); + $tags=array(); + while ($row = $db->fetchRow($result)) { + $tags[]=$row; + } + return $tags; + } else { + return $db->cached_query('tag'.$this->id, $this->_list_sql('tag'), array($this->id)); + } + } + + // This should really be moved to class Flyspray like some other ones too. + // Something todo for 1.1. + static function listUsersIn($group_id = null) + { + global $db; + return $db->cached_query( + 'users_in'.(is_null($group_id) ? $group_id : intval($group_id)), + "SELECT u.* + FROM {users} u + INNER JOIN {users_in_groups} uig ON u.user_id = uig.user_id + INNER JOIN {groups} g ON uig.group_id = g.group_id + WHERE g.group_id = ? + ORDER BY u.user_name ASC", + array($group_id)); + } + + function listAttachments($cid, $tid) + { + global $db; + return $db->cached_query( + 'attach_'.intval($cid), + "SELECT * + FROM {attachments} + WHERE comment_id = ? AND task_id = ? + ORDER BY attachment_id ASC", + array($cid, $tid)); + } + + function listLinks($cid, $tid) + { + global $db; + return $db->cached_query( + 'link_'.intval($cid), + "SELECT * + FROM {links} + WHERE comment_id = ? AND task_id = ? + ORDER BY link_id ASC", + array($cid, $tid)); + } + + function listTaskAttachments($tid) + { + global $db; + return $db->cached_query( + 'attach_'.intval($tid), + "SELECT * FROM {attachments} + WHERE task_id = ? AND comment_id = 0 + ORDER BY attachment_id ASC", + array($tid) + ); + } + + function listTaskLinks($tid) + { + global $db; + return $db->cached_query( + 'link_'.intval($tid), + "SELECT * FROM {links} + WHERE task_id = ? AND comment_id = 0 + ORDER BY link_id ASC", + array($tid)); + } + + /** + * Returns the activity by between dates for a project. + * @param date $startdate + * @param date $enddate + * @param integer $project_id + * @return array used to get the count + * @access public + */ + static function getActivityProjectCount($startdate, $enddate, $project_id) { + global $db; + $result = $db->query('SELECT count(event_date) as val + FROM {history} h left join {tasks} t on t.task_id = h.task_id + WHERE t.project_id = ? AND event_date BETWEEN ? and ?', + array($project_id, $startdate, $enddate)); + + $result = $db->fetchCol($result); + return $result[0]; + } + + /** + * Returns the day activity by the date for a project. + * @param date $date + * @param integer $project_id + * @return array used to get the count + * @access public + */ + static function getDayActivityByProject($date_start, $date_end, $project_id) { + global $db; + //NOTE: from_unixtime() on mysql, to_timestamp() on PostreSQL + $func = ('mysql' == $db->dblink->dataProvider) ? 'from_unixtime' : 'to_timestamp'; + + $result = $db->query("SELECT count(date({$func}(event_date))) as val, MIN(event_date) as event_date + FROM {history} h left join {tasks} t on t.task_id = h.task_id + WHERE t.project_id = ? AND event_date BETWEEN ? and ? + GROUP BY date({$func}(event_date)) ORDER BY event_date DESC", + array($project_id, $date_start, $date_end)); + + $date1 = new \DateTime("@$date_start"); + $date2 = new \DateTime("@$date_end"); + $days = $date1->diff($date2); + $days = $days->format('%a'); + $results = array(); + + for ($i = $days; $i >0; $i--) { + $event_date = (string) strtotime("-{$i} day", $date_end); + $results[date('Y-m-d', $event_date)] = 0; + } + + while ($row = $result->fetchRow()) { + $event_date = date('Y-m-d', $row['event_date']); + $results[$event_date] = (integer) $row['val']; + } + + return array_values($results); + } +} diff --git a/includes/class.recaptcha.php b/includes/class.recaptcha.php new file mode 100644 index 0000000..d998d43 --- /dev/null +++ b/includes/class.recaptcha.php @@ -0,0 +1,33 @@ +<?php +/* quick solution +* https://developers.google.com/recaptcha/docs/verify +*/ +class recaptcha +{ + + function verify(){ + global $fs; + + $url = 'https://www.google.com/recaptcha/api/siteverify'; + $data = array( + 'secret' => $fs->prefs['captcha_recaptcha_secret'], + 'response' => $_POST['g-recaptcha-response'] + ); + + $options = array( + 'http' => array ( + 'method' => 'POST', + /* for php5.3, default enctype for http_build_query() was added with php5.4, http://php.net/manual/en/function.http-build-query.php */ + 'header' => 'Content-type: application/x-www-form-urlencoded', + 'content' => http_build_query($data, '', '&') + ) + ); + + $context = stream_context_create($options); + $verify = file_get_contents($url, false, $context); + $captcha_success=json_decode($verify); + + return $captcha_success->success; + } + +} # end class diff --git a/includes/class.tpl.php b/includes/class.tpl.php new file mode 100644 index 0000000..24b1105 --- /dev/null +++ b/includes/class.tpl.php @@ -0,0 +1,1525 @@ +<?php + +if (!defined('IN_FS')) { + die('Do not access this file directly.'); +} + +class Tpl +{ + public $_uses = array(); + public $_vars = array(); + public $_theme = ''; + public $_tpls = array(); + public $_title = ""; + + public function uses() + { + $args = func_get_args(); + $this->_uses = array_merge($this->_uses, $args); + } + + public function assign($arg0 = null, $arg1 = null) + { + if (is_string($arg0)) { + $this->_vars[$arg0] = $arg1; + }elseif (is_array($arg0)) { + $this->_vars += $arg0; + }elseif (is_object($arg0)) { + $this->_vars += get_object_vars($arg0); + } + } + + public function getTheme() + { + return $this->_theme; + } + + public function setTheme($theme) + { + // Check available themes + $theme = trim($theme, '/'); + $themes = Flyspray::listThemes(); + if (in_array($theme, $themes)) { + $this->_theme = $theme.'/'; + } else { + $this->_theme = $themes[0].'/'; + } + } + + public function setTitle($title) + { + $this->_title = $title; + } + + public function themeUrl() + { + return sprintf('%sthemes/%s', $GLOBALS['baseurl'], $this->_theme); + } + + public function pushTpl($_tpl) + { + $this->_tpls[] = $_tpl; + } + + public function catch_start() + { + ob_start(); + } + + public function catch_end() + { + $this->_tpls[] = array(ob_get_contents()); + ob_end_clean(); + } + + public function display($_tpl, $_arg0 = null, $_arg1 = null) + { + // if only plain text + if (is_array($_tpl) && count($tpl)) { + echo $_tpl[0]; + return; + } + + // variables part + if (!is_null($_arg0)) { + $this->assign($_arg0, $_arg1); + } + + foreach ($this->_uses as $_var) { + global $$_var; + } + + extract($this->_vars, EXTR_REFS|EXTR_SKIP); + + if (is_readable(BASEDIR . '/themes/' . $this->_theme.'templates/'.$_tpl)) { + require BASEDIR . '/themes/' . $this->_theme.'templates/'.$_tpl; + } elseif (is_readable(BASEDIR . '/themes/CleanFS/templates/'.$_tpl)) { + # if a custom theme folder only contains a fraction of the .tpl files, use the template of the default full theme as fallback. + require BASEDIR . '/themes/CleanFS/templates/'.$_tpl; + } else { + # This is needed to catch times when there is no theme (for example setup pages, where BASEDIR is ../setup/ not ../) + require BASEDIR . "/templates/".$_tpl; + } + } + + public function render() + { + while (count($this->_tpls)) { + $this->display(array_shift($this->_tpls)); + } + } + + public function fetch($tpl, $arg0 = null, $arg1 = null) + { + ob_start(); + $this->display($tpl, $arg0, $arg1); + return ob_get_clean(); + } +} + +class FSTpl extends Tpl +{ + public $_uses = array('fs', 'conf', 'baseurl', 'language', 'proj', 'user'); + + public function get_image($name, $base = true) + { + global $proj, $baseurl; + $pathinfo = pathinfo($name); + $link = sprintf('themes/%s/', $proj->prefs['theme_style']); + if ($pathinfo['dirname'] != '.') { + $link .= $pathinfo['dirname'] . '/'; + $name = $pathinfo['basename']; + } + + $extensions = array('.png', '.gif', '.jpg', '.ico'); + + foreach ($extensions as $ext) { + if (is_file(BASEDIR . '/' . $link . $name . $ext)) { + return ($base) ? ($baseurl . $link . $name . $ext) : ($link . $name . $ext); + } + } + return ''; + } + +} + +/** + * Draws the form start tag and the important anticsrftoken on 'post'-forms + * + * @param string action + * @param string name optional attribute of form tag + * @param string method optional request method, default 'post' + * @param string enctype optional enctype, default 'multipart/form-data' + * @param string attr optional attributes for the form tag, example: 'id="myformid" class="myextracssclass"' + * + * @return string + */ +function tpl_form($action, $name=null, $method=null, $enctype=null, $attr='') +{ + global $baseurl; + + if (null === $method) { + $method='post'; + } + if (null === $enctype) { + $enctype='multipart/form-data'; + } + + if(substr($action,0,4)!='http'){$action=$baseurl.$action;} + return '<form action="'.$action.'"'.($method=='get'?' method="get"':' method="post"'). + ( $name!='' ? ' name="'.$name.'"':''). + ( ' enctype="'.$enctype.'"'). + ( ' '.$attr).'>'. + ( $method=='post' ? '<input type="hidden" name="csrftoken" value="'.$_SESSION['csrftoken'].'" />':''); +} + +/** + * Creates a link to a task + * + * @param array task with properties of a task. It also accepts a task_id, but that requires extra queries executed by this function. + * @param string text optional, by default the FS# + summary of task is used. + * @param bool strict check task permissions by the function too. Extra SQL queries if set true. default false. + * @param array attr extra attributes + * @param array title informations shown when hover over the link (title attribute of the HTML a-tag) + * + * @return string ready for html output + */ +function tpl_tasklink($task, $text = null, $strict = false, $attrs = array(), $title = array('status','summary','percent_complete')) +{ + global $user; + + $params = array(); + + if (!is_array($task) || !isset($task['status_name'])) { + $td_id = (is_array($task) && isset($task['task_id'])) ? $task['task_id'] : $task; + $task = Flyspray::getTaskDetails($td_id, true); + } + + if ($strict === true && (!is_object($user) || !$user->can_view_task($task))) { + return ''; + } + + if (is_object($user) && $user->can_view_task($task)) { + $summary = utf8_substr($task['item_summary'], 0, 64); + } else { + $summary = L('taskmadeprivate'); + } + + if (is_null($text)) { + $text = sprintf('FS#%d - %s', $task['task_id'], Filters::noXSS($summary)); + } elseif(is_string($text)) { + $text = htmlspecialchars(utf8_substr($text, 0, 64), ENT_QUOTES, 'utf-8'); + } else { + //we can't handle non-string stuff here. + return ''; + } + + if (!$task['task_id']) { + return $text; + } + + $title_text = array(); + + foreach($title as $info) + { + switch($info) + { + case 'status': + if ($task['is_closed']) { + $title_text[] = $task['resolution_name']; + $attrs['class'] = 'closedtasklink'; + } else { + $title_text[] = $task['status_name']; + } + break; + + case 'summary': + $title_text[] = $summary; + break; + + case 'assignedto': + if (isset($task['assigned_to_name']) ) { + if (is_array($task['assigned_to_name'])) { + $title_text[] = implode(', ', $task['assigned_to_name']); + } else { + $title_text[] = $task['assigned_to_name']; + } + } + break; + + case 'percent_complete': + $title_text[] = $task['percent_complete'].'%'; + break; + + case 'category': + if ($task['product_category']) { + if (!isset($task['category_name'])) { + $task = Flyspray::getTaskDetails($task['task_id'], true); + } + $title_text[] = $task['category_name']; + } + break; + + // ... more options if necessary + } + } + + $title_text = implode(' | ', $title_text); + + // to store search options + $params = $_GET; + unset($params['do'], $params['action'], $params['task_id'], $params['switch']); + if(isset($params['event_number'])){ + # shorter links to tasks from report page + unset($params['events'], $params['event_number'], $params['fromdate'], $params['todate'], $params['submit']); + } + + # We can unset the project param for shorter urls because flyspray knows project_id from current task data. + # Except we made a search from an 'all projects' view before, so the prev/next navigation on details page knows + # if it must search only in the project of current task or all projects the user is allowed to see tasks. + if(!isset($params['advancedsearch']) || (isset($params['project']) && $params['project']!=0) ){ + unset($params['project']); + } + + $url = htmlspecialchars(createURL('details', $task['task_id'], null, $params), ENT_QUOTES, 'utf-8'); + $title_text = htmlspecialchars($title_text, ENT_QUOTES, 'utf-8'); + $link = sprintf('<a href="%s" title="%s" %s>%s</a>',$url, $title_text, join_attrs($attrs), $text); + + if ($task['is_closed']) { + $link = '<del> ' . $link . ' </del>'; + } + return $link; +} + +/* + * Creates a textlink to a user profile. + * + * For a link with user icon use tpl_userlinkavatar(). + * + * @param int uid user_id from {users} db table + */ +function tpl_userlink($uid) +{ + global $db, $user; + + static $cache = array(); + + if (is_array($uid)) { + list($uid, $uname, $rname) = $uid; + } elseif (empty($cache[$uid])) { + $sql = $db->query('SELECT user_name, real_name FROM {users} WHERE user_id = ?', + array(intval($uid))); + if ($sql && $db->countRows($sql)) { + list($uname, $rname) = $db->fetchRow($sql); + } + } + + if (isset($uname)) { + #$url = createURL(($user->perms('is_admin')) ? 'edituser' : 'user', $uid); + # peterdd: I think it is better just to link to the user's page instead direct to the 'edit user' page also for admins. + # With more personalisation coming (personal todo list, charts, ..) in future to flyspray + # the user page itself is of increasing value. Instead show the 'edit user'-button on user's page. + $url = createURL('user', $uid); + $cache[$uid] = vsprintf('<a href="%s">%s</a>', array_map(array('Filters', 'noXSS'), array($url, $rname))); + } elseif (empty($cache[$uid])) { + $cache[$uid] = eL('anonymous'); + } + + return $cache[$uid]; +} + +/** +* Builds the HTML string for displaying a gravatar image or an uploaded user image. +* The string for a user and a size is cached per request. +* +* Class and style parameter should be avoided to make this function more effective for caching (less SQL queries) +* +* @param int uid the id of the user +* @param int size in pixel for displaying. Should use global max_avatar_size pref setting by default. +* @param string class optional, avoid calling with class parameter for better 'cacheability' +* @param string style optional, avoid calling with style parameter for better 'cacheability' +*/ +function tpl_userlinkavatar($uid, $size, $class='', $style='') +{ + global $db, $user, $baseurl, $fs; + + static $avacache=array(); + + if( !($uid>0) ){ + return '<i class="fa fa-user"></i>'; + } + + if($uid>0 && (empty($avacache[$uid]) || !isset($avacache[$uid][$size]))){ + if (!isset($avacache[$uid]['uname'])) { + $sql = $db->query('SELECT user_name, real_name, email_address, profile_image FROM {users} WHERE user_id = ?', array(intval($uid))); + if ($sql && $db->countRows($sql)) { + list($uname, $rname, $email, $profile_image) = $db->fetchRow($sql); + } else { + return; + } + $avacache[$uid]['profile_image'] = $profile_image; + $avacache[$uid]['uname'] = $uname; + $avacache[$uid]['rname'] = $rname; + $avacache[$uid]['email'] = $email; + } + + if (is_file(BASEDIR.'/avatars/'.$avacache[$uid]['profile_image'])) { + $image = '<img src="'.$baseurl.'avatars/'.$avacache[$uid]['profile_image'].'"/>'; + } else { + if (isset($fs->prefs['gravatars']) && $fs->prefs['gravatars'] == 1) { + $email = md5(strtolower(trim($avacache[$uid]['email']))); + $default = 'mm'; + $imgurl = '//www.gravatar.com/avatar/'.$email.'?d='.urlencode($default).'&s='.$size; + $image = '<img src="'.$imgurl.'"/>'; + } else { + $image = '<i class="fa fa-user" style="font-size:'.$size.'px"></i>'; + } + } + if (isset($avacache[$uid]['uname'])) { + #$url = createURL(($user->perms('is_admin')) ? 'edituser' : 'user', $uid); + # peterdd: I think it is better just to link to the user's page instead direct to the 'edit user' page also for admins. + # With more personalisation coming (personal todo list, charts, ..) in future to flyspray + # the user page itself is of increasing value. Instead show the 'edit user'-button on user's page. + $url = createURL('user', $uid); + $avacache[$uid][$size] = '<a'.($class!='' ? ' class="'.$class.'"':'').($style!='' ? ' style="'.$style.'"':'').' href="'.$url.'" title="'.Filters::noXSS($avacache[$uid]['rname']).'">'.$image.'</a>'; + } + } + return $avacache[$uid][$size]; +} + +function tpl_fast_tasklink($arr) +{ + return tpl_tasklink($arr[1], $arr[0]); +} + +/** + * Formats a task tag for HTML output based on a global $alltags array + * + * @param int id tag_id of {list_tag} db table + * @param bool showid set true if the tag_id is shown instead of the tag_name + * + * @return string ready for output + */ +function tpl_tag($id, $showid=false) { + global $alltags; + + if(!is_array($alltags)) { + $alltags=Flyspray::getAllTags(); + } + + if(isset($alltags[$id])){ + $out='<i class="tag t'.$id; + if( isset($alltags[$id]['class']) && preg_match('/^#([0-9a-f]{3}){1,2}$/i', $alltags[$id]['class']) ) { + $out.= '" style="background-color:'.$alltags[$id]['class']; + # max only calc once per tag per request + # assumes theme css of default tag font color is #000 + if(!isset($alltags[$id]['fcolor'])){ + $bg = hex2RGB($alltags[$id]['class']); + # from https://www.w3.org/TR/AERT/#color-contrast + $brightness=(299*$bg['r'] + 587*$bg['g'] + 114*$bg['b']) / 1000; + if($brightness<126){ + $out.=';color:#fff'; + $alltags[$id]['fcolor']='#fff'; + }else{ + $alltags[$id]['fcolor']=''; + } + } else if( $alltags[$id]['fcolor']==='#fff'){ + $out.=';color:#fff'; + } + $out.='"'; + } else { + $out.= (isset($alltags[$id]['class']) ? ' '.htmlspecialchars($alltags[$id]['class'], ENT_QUOTES, 'utf-8') : '').'"'; + } + if($showid){ + $out.='>'.$id; + } else{ + $out.=' title="'.htmlspecialchars($alltags[$id]['tag_name'], ENT_QUOTES, 'utf-8').'">'; + } + + $out.='</i>'; + return $out; + } +} + +/** +* Convert a hexa decimal color code to its RGB equivalent +* +* used by tpl_tag() +* +* @param string $hexstr (hexadecimal color value) +* @param boolean $returnasstring (if set true, returns the value separated by the separator character. Otherwise returns associative array) +* @param string $seperator (to separate RGB values. Applicable only if second parameter is true.) +* @return array or string (depending on second parameter. Returns False if invalid hex color value) +* +* function is adapted from an exmaple on http://php.net/manual/de/function.hexdec.php +*/ +function hex2RGB($hexstr, $returnasstring = false, $seperator = ',') { + $hexstr = preg_replace("/[^0-9A-Fa-f]/", '', $hexstr); // Gets a proper hex string + $rgb = array(); + if (strlen($hexstr) == 6) { // if a proper hex code, convert using bitwise operation. No overhead... faster + $colorval = hexdec($hexstr); + $rgb['r'] = 0xFF & ($colorval >> 0x10); + $rgb['g'] = 0xFF & ($colorval >> 0x8); + $rgb['b'] = 0xFF & $colorval; + } elseif (strlen($hexstr) == 3) { // if shorthand notation, need some string manipulations + $rgb['r'] = hexdec(str_repeat(substr($hexstr, 0, 1), 2)); + $rgb['g'] = hexdec(str_repeat(substr($hexstr, 1, 1), 2)); + $rgb['b'] = hexdec(str_repeat(substr($hexstr, 2, 1), 2)); + } else { + return false; // invalid hex color code + } + return $returnasstring ? implode($seperator, $rgb) : $rgb; // returns the rgb string or the associative array +} + +/** + * joins an array of tag attributes together for output in a HTML tag. + * + * @param array attr + * + * @return string + */ +function join_attrs($attr = null) { + if (is_array($attr) && count($attr)) { + $arr = array(); + foreach ($attr as $key=>$val) { + $arr[] = vsprintf('%s = "%s"', array_map(array('Filters', 'noXSS'), array($key, $val))); + } + return ' '.join(' ', $arr); + } + return ''; +} + +/** + * Datepicker + */ +function tpl_datepicker($name, $label = '', $value = 0) { + global $user, $page; + + $date = ''; + + if ($value) { + if (!is_numeric($value)) { + $value = strtotime($value); + } + + if (!$user->isAnon()) { + $st = date('Z')/3600; // server GMT timezone + $value += ($user->infos['time_zone'] - $st) * 60 * 60; + } + + $date = date('Y-m-d', intval($value)); + + /* It must "look" as a date.. + * XXX : do not blindly copy this code to validate other dates + * this is mostly a tongue-in-cheek validation + * 1. it will fail on 32 bit systems on dates < 1970 + * 2. it will produce different results bewteen 32 and 64 bit systems for years < 1970 + * 3. it will not work when year > 2038 on 32 bit systems (see http://en.wikipedia.org/wiki/Year_2038_problem) + * + * Fortunately tasks are never opened to be dated on 1970 and maybe our sons or the future flyspray + * coders may be willing to fix the 2038 issue ( in the strange case 32 bit systems are still used by that year) :-) + */ + + } elseif (Req::has($name) && strlen(Req::val($name))) { + + //strtotime sadly returns -1 on faliure in php < 5.1 instead of false + $ts = strtotime(Req::val($name)); + + foreach (array('m','d','Y') as $period) { + //checkdate only accepts arguments of type integer + $$period = intval(date($period, $ts)); + } + // $ts has to be > 0 to get around php behavior change + // false is casted to 0 by the ZE + $date = ($ts > 0 && checkdate($m, $d, $Y)) ? Req::val($name) : ''; + } + + + $subPage = new FSTpl; + $subPage->setTheme($page->getTheme()); + $subPage->assign('name', $name); + $subPage->assign('date', $date); + $subPage->assign('label', $label); + $subPage->assign('dateformat', '%Y-%m-%d'); + $subPage->display('common.datepicker.tpl'); +} + +/** + * user selector + */ +function tpl_userselect($name, $value = null, $id = '', $attrs = array()) { + global $db, $user, $proj; + + if (!$id) { + $id = $name; + } + + if ($value && ctype_digit($value)) { + $sql = $db->query('SELECT user_name FROM {users} WHERE user_id = ?', array($value)); + $value = $db->fetchOne($sql); + } + + if (!$value) { + $value = ''; + } + + + $page = new FSTpl; + $page->setTheme($proj->prefs['theme_style']); + $page->assign('name', $name); + $page->assign('id', $id); + $page->assign('value', $value); + $page->assign('attrs', $attrs); + $page->display('common.userselect.tpl'); +} + +/** + * Creates the options for a date format select + * + * @selected The format that should by selected by default + * @return html formatted options for a select tag +**/ +function tpl_date_formats($selected, $detailed = false) +{ + $time = time(); + + # TODO: rewrite using 'return tpl_select(...)' + if (!$detailed) { + $dateFormats = array( + '%d.%m.%Y' => strftime('%d.%m.%Y', $time).' (DD.MM.YYYY)', # popular in many european countries + '%d/%m/%Y' => strftime('%d/%m/%Y', $time).' (DD/MM/YYYY)', # popular in Greek + '%m/%d/%Y' => strftime('%m/%d/%Y', $time).' (MM/DD/YYYY)', # popular in USA + + '%d.%m.%y' => strftime('%d.%m.%y', $time), + + '%Y.%m.%d' => strftime('%Y.%m.%d', $time), + '%y.%m.%d' => strftime('%y.%m.%d', $time), + + '%d-%m-%Y' => strftime('%d-%m-%Y', $time), + '%d-%m-%y' => strftime('%d-%m-%y', $time), + + '%Y-%m-%d' => strftime('%Y-%m-%d', $time).' (YYYY-MM-DD, ISO 8601)', + '%y-%m-%d' => strftime('%y-%m-%d', $time), + + '%d %b %Y' => strftime('%d %b %Y', $time), + '%d %B %Y' => strftime('%d %B %Y', $time), + + '%b %d %Y' => strftime('%b %d %Y', $time), + '%B %d %Y' => strftime('%B %d %Y', $time), + ); + } + else { + # TODO: maybe use optgroups for tpl_select() to separate 24h and 12h (am/pm) formats + $dateFormats = array( + '%d.%m.%Y %H:%M' => strftime('%d.%m.%Y %H:%M', $time), + '%d.%m.%y %H:%M' => strftime('%d.%m.%y %H:%M', $time), + + '%d.%m.%Y %I:%M %p' => strftime('%d.%m.%Y %I:%M %p', $time), + '%d.%m.%y %I:%M %p' => strftime('%d.%m.%y %I:%M %p', $time), + + '%Y.%m.%d %H:%M' => strftime('%Y.%m.%d %H:%M', $time), + '%y.%m.%d %H:%M' => strftime('%y.%m.%d %H:%M', $time), + + '%Y.%m.%d %I:%M %p' => strftime('%Y.%m.%d %I:%M %p', $time), + '%y.%m.%d %I:%M %p' => strftime('%y.%m.%d %I:%M %p', $time), + + '%d-%m-%Y %H:%M' => strftime('%d-%m-%Y %H:%M', $time), + '%d-%m-%y %H:%M' => strftime('%d-%m-%y %H:%M', $time), + + '%d-%m-%Y %I:%M %p' => strftime('%d-%m-%Y %I:%M %p', $time), + '%d-%m-%y %I:%M %p' => strftime('%d-%m-%y %I:%M %p', $time), + + '%Y-%m-%d %H:%M' => strftime('%Y-%m-%d %H:%M', $time), + '%y-%m-%d %H:%M' => strftime('%y-%m-%d %H:%M', $time), + + '%Y-%m-%d %I:%M %p' => strftime('%Y-%m-%d %I:%M %p', $time), + '%y-%m-%d %I:%M %p' => strftime('%y-%m-%d %I:%M %p', $time), + + '%d %b %Y %H:%M' => strftime('%d %b %Y %H:%M', $time), + '%d %B %Y %H:%M' => strftime('%d %B %Y %H:%M', $time), + + '%d %b %Y %I:%M %p' => strftime('%d %b %Y %I:%M %p', $time), + '%d %B %Y %I:%M %p' => strftime('%d %B %Y %I:%M %p', $time), + + '%b %d %Y %H:%M' => strftime('%b %d %Y %H:%M', $time), + '%B %d %Y %H:%M' => strftime('%B %d %Y %H:%M', $time), + + '%b %d %Y %I:%M %p' => strftime('%b %d %Y %I:%M %p', $time), + '%B %d %Y %I:%M %p' => strftime('%B %d %Y %I:%M %p', $time), + ); + } + + return tpl_options($dateFormats, $selected); +} + + +/** + * Options for a <select> + * + * FIXME peterdd: This function is currently often called by templates with just + * results from sqltablequeries like select * from tablex, + * so data[0] and data[1] of each row works only by table structure convention as wished. + * not by names, lack of a generic optgroup feature, css-id, css-classes, disabled option. + * Maybe rewrite a as tpl_select() .. + * + * @options array of values + * For optgroups, the values should be presorted by the optgroups + * example: + * $options=array( + * array(3,'project3',1), # active project group + * array(2,'project2',1), + * array(5,'project5',0) # inactive project optgroup + * ); tpl_options($options, 2) +*/ +function tpl_options($options, $selected = null, $labelIsValue = false, $attr = null, $remove = null) +{ + $html = ''; + + // force $selected to be an array. + // this allows multi-selects to have multiple selected options. + $selected = is_array($selected) ? $selected : (array) $selected; + $options = is_array($options) ? $options : (array) $options; + + $lastoptgroup=0; + $optgroup=0; + $ingroup=false; + foreach ($options as $idx=>$data) { + if (is_array($data)) { + $value = $data[0]; + $label = $data[1]; + # just a temp hack, we currently use optgroups only for project dropdown... + $optgroup=array_key_exists('project_is_active',$data) ? $data['project_is_active'] : 0; + if (array_key_exists('project_title', $data) && $optgroup!=$lastoptgroup) { + if ($ingroup) { + $html.='</optgroup>'; + } + $html.='<optgroup'.($optgroup==0 ? ' label="'.L('inactive').'"' : '' ).'>'; + $ingroup=true; + } + } else{ + $value=$idx; + $label=$data; + } + $label = htmlspecialchars($label, ENT_QUOTES, 'utf-8'); + $value = $labelIsValue ? $label : htmlspecialchars($value, ENT_QUOTES, 'utf-8'); + + if ($value === $remove) { + continue; + } + + $html .= '<option value="'.$value.'"'; + if (in_array($value, $selected)) { + $html .= ' selected="selected"'; + } + $html .= ($attr ? join_attrs($attr): '') . '>' . $label . '</option>'; + $lastoptgroup=$optgroup; + } + + if ($ingroup) { + $html.='</optgroup>'; + } + + if (!$html) { + $html .= '<option value="0">---</option>'; + } + + return $html; +} + + +/** + * Builds a complete HTML-select with select options. + * + * Supports free choosable attributes for select, options and optgroup tags. optgroups can also be nested. + * + * @author peterdd + * + * @param array key-values pairs and can be nested + * + * @return string the complete html-select + * + * @since 1.0.0-beta3 + * + * @example + * example output of print_r($array) to see the structure of the param $array + * Array + ( + [name] => varname // required if you want submit it with a form to the server + [attr] => Array // optional + ( + [id] => selid // optional + [class] => selclass1 // optional + ) + [options] => Array // optional, but without doesn't make much sense ;-) + ( + [0] => Array // at least one would be useful + ( + [value] => opt1val // recommended + [label] => opt1label // recommended + [disabled] => 1 // optional + [selected] => 1 // optional + [attr] => Array // optional + ( + [id] => opt1id // optional + [class] => optclass1 // optional + ) + ) + [1] => Array + ( + [optgroup] => 1 // this tells the function that now comes an optgroup + [label] => optgrouplabel // optional + [attr] => Array // optional + ( + [id] => optgroupid1 // optional + [class] => optgroupclass1 // optional + ) + [options] => Array + // ... nested options and optgroups can follow here.... + ) + // ... and so on + ) + ) + */ +function tpl_select($select=array()){ + + if(isset($select['name'])){ + $name=' name="'.$select['name'].'"'; + }else{ + $name=''; + } + + $attrjoin=''; + if(isset($select['attr'])){ + foreach($select['attr'] as $key=>$val){ + $attrjoin.=' '.$key.'="'.htmlspecialchars($val, ENT_QUOTES, 'utf-8').'"'; + } + } + $html='<select'.$name.$attrjoin.'>'; + if(isset($select['options'])){ + $html.=tpl_selectoptions($select['options']); + } + $html.="\n".'</select>'; + return $html; +} + +/** + * called by tpl_select() + * + * @author peterdd + * + * @param array key-values pairs and can be nested + * + * @return string option- and optgroup-tags as one string + * + * @since 1.0.0-beta3 + * + * called recursively by itself + * Can also be called alone from template if the templates writes the wrapping select-tags. + * + * @example see [options]-array of example of tpl_select() + */ +function tpl_selectoptions($options=array(), $level=0){ + $html=''; + # such deep nesting is too weired - probably an endless loop lets + # return before something bad happens + if( $level>10){ + return; + } + #print_r($options); + #print_r($level); + foreach($options as $o){ + if(isset($o['optgroup'])){ + # we have an optgroup + $html.="\n".str_repeat("\t",$level).'<optgroup label="'.$o['label'].'"'; + if(isset($o['attr'])){ + foreach($o['attr'] as $key=>$val){ + $html.=' '.$key.'="'.htmlspecialchars($val, ENT_QUOTES, 'utf-8').'"'; + } + } + $html.='>'; + # may contain options and suboptgroups.. + $html.=tpl_selectoptions($o['options'], $level+1); + $html.="\n".str_repeat("\t",$level).'</optgroup>'; + } else{ + # we have a simple option + $html.="\n".str_repeat("\t",$level).'<option value="'.htmlspecialchars($o['value'], ENT_QUOTES, 'utf-8').'"'; + if(isset($o['disabled'])){ + $html.=' disabled="disabled"'; # xhtml compatible + } + if(isset($o['selected'])){ + $html.=' selected="selected"'; # xhtml compatible + } + if(isset($o['attr'])){ + foreach($o['attr'] as $key=>$val){ + $html.=' '.$key.'="'.htmlspecialchars($val, ENT_QUOTES, 'utf-8').'"'; + } + } + $html.='>'.htmlspecialchars($o['label'], ENT_QUOTES, 'utf-8').'</option>'; + } + } + + return $html; +} + + +/** + * Creates a double select. + * + * Elements of arrays $options and $selected can be moved between eachother. The $selected list can also be sorted. + * + * @param string name + * @param array options + * @param array selected + * @param bool labelisvalue + * @param bool updown + */ +function tpl_double_select($name, $options, $selected = null, $labelisvalue = false, $updown = true) +{ + static $_id = 0; + static $tpl = null; + + if (!$tpl) { + global $proj; + + // poor man's cache + $tpl = new FSTpl(); + $tpl->setTheme($proj->prefs['theme_style']); + } + + settype($selected, 'array'); + settype($options, 'array'); + + $tpl->assign('id', '_task_id_'.($_id++)); + $tpl->assign('name', $name); + $tpl->assign('selected', $selected); + $tpl->assign('updown', $updown); + + $html = $tpl->fetch('common.dualselect.tpl'); + + $selectedones = array(); + + $opt1 = ''; + foreach ($options as $value => $label) { + if (is_array($label) && count($label) >= 2) { + $value = $label[0]; + $label = $label[1]; + } + if ($labelisvalue) { + $value = $label; + } + if (in_array($value, $selected)) { + $selectedones[$value] = $label; + continue; + } + $label = htmlspecialchars($label, ENT_QUOTES, 'utf-8'); + $value = htmlspecialchars($value, ENT_QUOTES, 'utf-8'); + + $opt1 .= sprintf('<option title="%2$s" value="%1$s">%2$s</option>', $value, $label); + } + + $opt2 = ''; + foreach ($selected as $value) { + if (!isset($selectedones[$value])) { + continue; + } + $label = htmlspecialchars($selectedones[$value], ENT_QUOTES, 'utf-8'); + $value = htmlspecialchars($value, ENT_QUOTES, 'utf-8'); + + $opt2 .= sprintf('<option title="%2$s" value="%1$s">%2$s</option>', $value, $label); + } + + return sprintf($html, $opt1, $opt2); +} + +/** + * Creates a HTML checkbox + * + * @param string name + * @param bool checked + * @param string id id attribute of the checkbox HTML element + * @param string value + * @param array attr tag attributes + * + * @return string for ready for HTML output + */ +function tpl_checkbox($name, $checked = false, $id = null, $value = 1, $attr = null) +{ + $name = htmlspecialchars($name, ENT_QUOTES, 'utf-8'); + $value = htmlspecialchars($value, ENT_QUOTES, 'utf-8'); + $html = sprintf('<input type="checkbox" name="%s" value="%s" ', $name, $value); + if (is_string($id)) { + $html .= sprintf('id="%s" ', Filters::noXSS($id)); + } + if ($checked == true) { + $html .= 'checked="checked" '; + } + // do not call join_attrs if $attr is null or nothing.. + return ($attr ? $html. join_attrs($attr) : $html) . '/>'; +} + +/** + * Image display + */ +function tpl_img($src, $alt = '') +{ + global $baseurl; + if (is_file(BASEDIR .'/'.$src)) { + return sprintf('<img src="%s%s" alt="%s" />', $baseurl, Filters::noXSS($src), Filters::noXSS($alt)); + } + return Filters::noXSS($alt); +} + +// Text formatting +//format has been already checked in constants.inc.php +if(isset($conf['general']['syntax_plugin'])) { + + $path_to_plugin = BASEDIR . '/plugins/' . $conf['general']['syntax_plugin'] . '/' . $conf['general']['syntax_plugin'] . '_formattext.inc.php'; + + if (is_readable($path_to_plugin)) { + include($path_to_plugin); + } +} + +class TextFormatter +{ + public static function get_javascript() + { + global $conf; + + $path_to_plugin = sprintf('%s/plugins/%s', BASEDIR, $conf['general']['syntax_plugin']); + $return = array(); + + if (!is_readable($path_to_plugin)) { + return $return; + } + + $d = dir($path_to_plugin); + while (false !== ($entry = $d->read())) { + if (substr($entry, -3) == '.js') { + $return[] = $conf['general']['syntax_plugin'] . '/' . $entry; + } + } + + return $return; + } + + public static function render($text, $type = null, $id = null, $instructions = null) + { + global $conf; + + $methods = get_class_methods($conf['general']['syntax_plugin'] . '_TextFormatter'); + $methods = is_array($methods) ? $methods : array(); + + if (in_array('render', $methods)) { + return call_user_func(array($conf['general']['syntax_plugin'] . '_TextFormatter', 'render'), + $text, $type, $id, $instructions); + } else { + $text=strip_tags($text, '<br><br/><p><h2><h3><h4><h5><h5><h6><blockquote><a><img><u><b><strong><s><ins><del><ul><ol><li><table><caption><tr><col><colgroup><td><th><thead><tfoot><tbody><code>'); + if ( $conf['general']['syntax_plugin'] + && $conf['general']['syntax_plugin'] != 'none' + && $conf['general']['syntax_plugin'] != 'html') { + $text='Unsupported output plugin '.$conf['general']['syntax_plugin'].'!' + .'<br/>Couldn\'t call '.$conf['general']['syntax_plugin'].'_TextFormatter::render()' + .'<br/>Temporarily handled like it is HTML until fixed.<br/>' + .$text; + } + + //TODO: Remove Redundant Code once tested completely + //Author: Steve Tredinnick + //Have removed this as creating additional </br> lines even though <p> is already dealing with it + //possibly an conversion from Dokuwiki syntax to html issue, left in in case anyone has issues and needs to comment out + //$text = ' ' . nl2br($text) . ' '; + + // Change FS#123 into hyperlinks to tasks + return preg_replace_callback("/\b(?:FS#|bug )(\d+)\b/", 'tpl_fast_tasklink', trim($text)); + } + } + + public static function textarea($name, $rows, $cols, $attrs = null, $content = null) + { + global $conf; + + if (@in_array('textarea', get_class_methods($conf['general']['syntax_plugin'] . '_TextFormatter'))) { + return call_user_func(array($conf['general']['syntax_plugin'] . '_TextFormatter', 'textarea'), + $name, $rows, $cols, $attrs, $content); + } + + $name = htmlspecialchars($name, ENT_QUOTES, 'utf-8'); + $return = sprintf('<textarea name="%s" cols="%d" rows="%d"', $name, $cols, $rows); + if (is_array($attrs) && count($attrs)) { + $return .= join_attrs($attrs); + } + $return .= '>'; + if (is_string($content) && strlen($content)) { + $return .= htmlspecialchars($content, ENT_QUOTES, 'utf-8'); + } + $return .= '</textarea>'; + + # Activate CkEditor on textareas + if($conf['general']['syntax_plugin']=='html'){ + $return .= " +<script> + CKEDITOR.replace( '".$name."', { entities: true, entities_latin: false, entities_processNumerical: false } ); +</script>"; + } + + return $return; + } +} + +/** + * Format Date + * + * Questionable if this function belongs in this class. Usages also elsewhere and not UI-related. + */ +function formatDate($timestamp, $extended = false, $default = '') +{ + global $db, $conf, $user, $fs; + + setlocale(LC_ALL, str_replace('-', '_', L('locale')) . '.utf8'); + + if (!$timestamp) { + return $default; + } + + $dateformat = ''; + $format_id = $extended ? 'dateformat_extended' : 'dateformat'; + $st = date('Z')/3600; // server GMT timezone + + if (!$user->isAnon()) { + $dateformat = $user->infos[$format_id]; + $timestamp += ($user->infos['time_zone'] - $st) * 60 * 60; + $st = $user->infos['time_zone']; + } + + if (!$dateformat) { + $dateformat = $fs->prefs[$format_id]; + } + + if (!$dateformat) { + $dateformat = $extended ? '%A, %d %B %Y, %H:%M %GMT' : '%Y-%m-%d'; + } + + $zone = L('GMT') . (($st == 0) ? ' ' : (($st > 0) ? '+' . $st : $st)); + $dateformat = str_replace('%GMT', $zone, $dateformat); + //it returned utf-8 encoded by the system + return strftime(Filters::noXSS($dateformat), (int) $timestamp); +} + +/** + * Draw permissions table + */ +function tpl_draw_perms($perms) +{ + global $proj; + + $perm_fields = array( + 'is_admin', + 'manage_project', + 'view_tasks', + 'view_groups_tasks', + 'view_own_tasks', + 'open_new_tasks', + 'add_multiple_tasks', + 'modify_own_tasks', + 'modify_all_tasks', + 'create_attachments', + 'delete_attachments', + 'assign_to_self', + 'assign_others_to_self', + 'edit_assignments', + 'close_own_tasks', + 'close_other_tasks', + 'view_roadmap', + 'view_history', + 'view_reports', + 'add_votes', + 'view_comments', + 'add_comments', + 'edit_comments', + 'edit_own_comments', + 'delete_comments', + 'view_estimated_effort', + 'view_current_effort_done', + 'track_effort' + ); + + $yesno = array( + '<td class="bad fa fa-ban" title="'.eL('no').'"></td>', + '<td class="good fa fa-check" title="'.eL('yes').'"></td>' + ); + + # 20150307 peterdd: This a temporary hack + $i=0; + $html=''; + $projpermnames=''; + + foreach ($perms as $projperm){ + $html .= '<table class="perms"><thead><tr><th>'.($i==0? 'global' : L('project').' '.$i).'</th>'.($i==0? '<th>'.L('permissions').'</th>' : '').'</tr></thead><tbody>'; + foreach ($projperm as $key => $val) { + if (!is_numeric($key) && in_array($key, $perm_fields)) { + $html .= '<tr>'; + $html .= $yesno[ ($val || $perms[0]['is_admin']) ]; + $html .= $i==0 ? '<th>'.eL(str_replace('_','',$key)).'</th>' : ''; + $html .= '</tr>'; + + # all projects have same permnames + $projpermnames .= $i==1 ? '<tr><td>'.eL(str_replace('_','',$key)).'</td></tr>' : ''; + } + } + $html.= '</tbody></table>'; + $i++; + } + $html.='<table class="perms"><thead><th>'.L('permissions').'</th></thead><tbody>'.$projpermnames.'</tbody></table>'; + $html.='<style>.perms tr{height:30px;}</style>'; + # end 20150307 + return $html; +} + +/** + * Highlights searchqueries in HTML code + * + * @author Andreas Gohr <andi@splitbrain.org> + * @author Harry Fuecks <hfuecks@gmail.com> + */ +function html_hilight($html,$query){ + //split at common delimiters + $queries = preg_split ('/[\s\'"\\\\`()\]\[?:!\.{};,#+*<>]+/',$query,-1,PREG_SPLIT_NO_EMPTY); + foreach ($queries as $q){ + $q = preg_quote($q,'/'); + $html = preg_replace_callback("/((<[^>]*)|$q)/i",'html_hilight_callback',$html); + } + return $html; +} + +/** + * Callback used by html_hilight() + * + * @author Harry Fuecks <hfuecks@gmail.com> + */ +function html_hilight_callback($m) { + $hlight = unslash($m[0]); + if ( !isset($m[2])) { + $hlight = '<span class="search_hit">'.$hlight.'</span>'; + } + return $hlight; +} + +/** + * XHTML compatible output of disabled attribute + * + * @param bool if something that PHP sees as true or false + */ +function tpl_disableif ($if) +{ + if ($if) { + return 'disabled="disabled"'; + } +} + +/** + * Generates links for Flyspray + * + * Create an URL based upon address-rewriting preferences + * + */ +function createURL($type, $arg1 = null, $arg2 = null, $arg3 = array()) +{ + global $baseurl, $conf, $fs; + + $url = $baseurl; + + // If we do want address rewriting + if ($fs->prefs['url_rewriting']) { + switch ($type) { + case 'depends': + $return = $url . 'task/' . $arg1 . '/' . $type; + break; + case 'details': + $return = $url . 'task/' . $arg1; + break; + case 'edittask': + $return = $url . 'task/' . $arg1 . '/edit'; + break; + case 'pm': + $return = $url . 'pm/proj' . $arg2 . '/' . $arg1; + break; + + case 'admin': + case 'edituser': + case 'user': + $return = $url . $type . '/' . $arg1; + break; + + case 'project': + $return = $url . 'proj' . $arg1; + break; + + case 'reports': + case 'roadmap': + case 'toplevel': + case 'gantt': + case 'index': + $return = $url.$type.'/proj'.$arg1; + break; + + case 'newtask': + case 'newmultitasks': + $return = $url . $type . '/proj' . $arg1 . ($arg2 ? '/supertask' . $arg2 : ''); + break; + + case 'editgroup': + $return = $url . $arg2 . '/' . $type . '/' . $arg1; + break; + + case 'logout': + case 'lostpw': + case 'myprofile': + case 'register': + $return = $url . $type; + break; + + case 'mytasks': + $return = $url.'proj'.$arg1.'/dev'.$arg2; + break; + case 'tasklist': + # see also .htaccess for the mapping + if($arg1>0 && $fs->projects[$arg1]['default_entry']=='index'){ + $return = $url.'proj'.$arg1; + }else{ + $return = $url.$type.'/proj'.$arg1; + } + + break; + default: + $return = $baseurl . 'index.php'; + break; + } + } else { + if ($type == 'edittask') { + $url .= 'index.php?do=details'; + } else { + $url .= 'index.php?do=' . $type; + } + + switch ($type) { + case 'admin': + $return = $url . '&area=' . $arg1; + break; + case 'edittask': + $return = $url . '&task_id=' . $arg1 . '&edit=yep'; + break; + case 'pm': + $return = $url . '&area=' . $arg1 . '&project=' . $arg2; + break; + case 'user': + $return = $baseurl . 'index.php?do=user&area=users&id=' . $arg1; + break; + case 'edituser': + $return = $baseurl . 'index.php?do=admin&area=users&user_id=' . $arg1; + break; + case 'logout': + $return = $baseurl . 'index.php?do=authenticate&logout=1'; + break; + + case 'details': + case 'depends': + $return = $url . '&task_id=' . $arg1; + break; + + case 'project': + $return = $baseurl . 'index.php?project=' . $arg1; + break; + + case 'reports': + case 'roadmap': + case 'toplevel': + case 'gantt': + case 'index': + case 'tasklist': + $return = $url . '&project=' . $arg1; + break; + + case 'newtask': + case 'newmultitasks': + $return = $url . '&project=' . $arg1 . ($arg2 ? '&supertask=' . $arg2 : ''); + break; + + case 'editgroup': + $return = $baseurl . 'index.php?do=' . $arg2 . '&area=editgroup&id=' . $arg1; + break; + + case 'lostpw': + case 'myprofile': + case 'register': + $return = $url; + break; + + case 'mytasks': + $return = $baseurl.'index.php?do=index&project='.$arg1.'&dev='.$arg2; + break; + + default: + $return = $baseurl . 'index.php'; + break; + } + } + + $url = new Url($return); + if( !is_null($arg3) && count($arg3) ) { + $url->addvars($arg3); + } + return $url->get(); +} + +/** + * Page numbering + * + * Thanks to Nathan Fritz for this. http://www.netflint.net/ + */ +function pagenums($pagenum, $perpage, $totalcount) +{ + global $proj; + $pagenum = intval($pagenum); + $perpage = intval($perpage); + $totalcount = intval($totalcount); + + // Just in case $perpage is something weird, like 0, fix it here: + if ($perpage < 1) { + $perpage = $totalcount > 0 ? $totalcount : 1; + } + $pages = ceil($totalcount / $perpage); + $output = sprintf(eL('page'), $pagenum, $pages); + + if ( $totalcount / $perpage > 1 ) { + $params=$_GET; + # unset unneeded params for shorter urls + unset($params['do']); + unset($params['project']); + unset($params['switch']); + $output .= '<span class="pagenums DoNotPrint">'; + + $start = max(1, $pagenum - 4 + min(2, $pages - $pagenum)); + $finish = min($start + 4, $pages); + + if ($start > 1) { + $url = Filters::noXSS(createURL('tasklist', $proj->id, null, array_merge($params, array('pagenum' => 1)))); + $output .= sprintf('<a href="%s"><<%s </a>', $url, eL('first')); + } + if ($pagenum > 1) { + $url = Filters::noXSS(createURL('tasklist', $proj->id, null, array_merge($params, array('pagenum' => $pagenum - 1)))); + $output .= sprintf('<a id="previous" accesskey="p" href="%s">< %s</a> - ', $url, eL('previous')); + } + + for ($pagelink = $start; $pagelink <= $finish; $pagelink++) { + if ($pagelink != $start) { + $output .= ' - '; + } + + if ($pagelink == $pagenum) { + $output .= sprintf('<strong>%d</strong>', $pagelink); + } else { + $url = Filters::noXSS(createURL('tasklist', $proj->id, null, array_merge($params, array('pagenum' => $pagelink)))); + $output .= sprintf('<a href="%s">%d</a>', $url, $pagelink); + } + } + + if ($pagenum < $pages) { + $url = Filters::noXSS(createURL('tasklist', $proj->id, null, array_merge($params, array('pagenum' => $pagenum + 1)))); + $output .= sprintf(' - <a id="next" accesskey="n" href="%s">%s ></a>', $url, eL('next')); + } + if ($finish < $pages) { + $url = Filters::noXSS(createURL('tasklist', $proj->id, null, array_merge($params, array('pagenum' => $pages)))); + $output .= sprintf('<a href="%s"> %s >></a>', $url, eL('last')); + } + $output .= '</span>'; + } + + return $output; +} + +class Url { + public $url = ''; + public $parsed; + + public function __construct($url = '') { + $this->url = $url; + $this->parsed = parse_url($this->url); + } + + public function seturl($url) { + $this->url = $url; + $this->parsed = parse_url($this->url); + } + + public function getinfo($type = null) { + if (is_null($type)) { + return $this->parsed; + } elseif (isset($this->parsed[$type])) { + return $this->parsed[$type]; + } else { + return ''; + } + } + + public function setinfo($type, $value) { + $this->parsed[$type] = $value; + } + + public function addfrom($method = 'get', $vars = array()) { + $append = ''; + foreach($vars as $key) { + $append .= http_build_query( (($method == 'get') ? Get::val($key) : Post::val($key)) ) . '&'; + } + $append = substr($append, 0, -1); + + $separator = ini_get('arg_separator.output'); + if (strlen($separator) != 0) { + $append = str_replace($separator, '&', $append); + } + + if ($this->getinfo('query')) { + $this->parsed['query'] .= '&' . $append; + } else { + $this->parsed['query'] = $append; + } + } + + public function addvars($vars = array()) { + $append = http_build_query($vars); + + $separator = ini_get('arg_separator.output'); + if (strlen($separator) != 0) { + $append = str_replace($separator, '&', $append); + } + + if ($this->getinfo('query')) { + $this->parsed['query'] .= '&' . $append; + } else { + $this->parsed['query'] = $append; + } + } + + public function get($fullpath = true) { + $return = ''; + if ($fullpath) { + $return .= $this->getinfo('scheme') . '://' . $this->getinfo('host'); + + if ($this->getinfo('port')) { + $return .= ':' . $this->getinfo('port'); + } + } + + $return .= $this->getinfo('path'); + + if ($this->getinfo('query')) { + $return .= '?' . $this->getinfo('query'); + } + + if ($this->getinfo('fragment')) { + $return .= '#' . $this->getinfo('fragment'); + } + + return $return; + } +} diff --git a/includes/class.user.php b/includes/class.user.php new file mode 100644 index 0000000..f79ad04 --- /dev/null +++ b/includes/class.user.php @@ -0,0 +1,555 @@ +<?php + +class User +{ + public $id = -1; + public $perms = array(); + public $infos = array(); + public $searches = array(); + public $search_keys = array('project', 'string', 'type', 'sev', 'pri', 'due', 'dev', 'cat', 'status', 'percent', 'changedfrom', 'closedfrom', + 'opened', 'closed', 'search_in_comments', 'search_in_details', 'search_for_all', 'reported', 'only_primary', 'only_watched', 'closedto', + 'changedto', 'duedatefrom', 'duedateto', 'openedfrom', 'openedto', 'has_attachment'); + + public function __construct($uid = 0) + { + global $db; + + if ($uid > 0) { + $sql = $db->query('SELECT *, g.group_id AS global_group, uig.record_id AS global_record_id + FROM {users} u, {users_in_groups} uig, {groups} g + WHERE u.user_id = ? AND uig.user_id = ? AND g.project_id = 0 + AND uig.group_id = g.group_id', + array($uid, $uid)); + } + + if ($uid > 0 && $db->countRows($sql) == 1) { + $this->infos = $db->fetchRow($sql); + $this->id = intval($uid); + } else { + $this->infos['real_name'] = L('anonuser'); + $this->infos['user_name'] = ''; + } + + $this->get_perms(); + } + + /** + * save_search + * + * @param string $do + * @access public + * @return void + * @notes FIXME: must return something, should not merge _GET and _REQUEST with other stuff. + */ + public function save_search($do = 'index') + { + global $db; + + if($this->isAnon()) { + return; + } + // Only logged in users get to use the 'last search' functionality + + if ($do == 'index') { + if (Post::val('search_name')) { + $arr = array(); + foreach ($this->search_keys as $key) { + $arr[$key] = Post::val($key, ($key == 'status') ? 'open' : null); + } + foreach (array('order', 'sort', 'order2', 'sort2') as $key) { + if (Post::val($key)) { + $arr[$key] = Post::val($key); + } + } + + $fields = array( + 'search_string'=> serialize($arr), + 'time'=> time(), + 'user_id'=> $this->id , + 'name'=> Post::val('search_name') + ); + $keys = array('name','user_id'); + $db->replace('{searches}', $fields, $keys); + } + } + + $sql = $db->query('SELECT * FROM {searches} WHERE user_id = ? ORDER BY name ASC', array($this->id)); + $this->searches = $db->fetchAllArray($sql); + } + + public function perms($name, $project = null) { + if (is_null($project)) { + global $proj; + $project = $proj->id; + } + + if (isset($this->perms[$project][$name])) { + return $this->perms[$project][$name]; + } else { + return 0; + } + } + + public function get_perms() + { + global $db, $fs; + + $fields = array('is_admin', 'manage_project', 'view_tasks', 'edit_own_comments', + 'open_new_tasks', 'modify_own_tasks', 'modify_all_tasks', + 'view_comments', 'add_comments', 'edit_comments', 'edit_assignments', + 'delete_comments', 'create_attachments', + 'delete_attachments', 'view_history', 'close_own_tasks', + 'close_other_tasks', 'assign_to_self', 'assign_others_to_self', + 'add_to_assignees', 'view_reports', 'add_votes', 'group_open','view_estimated_effort', + 'track_effort', 'view_current_effort_done', 'add_multiple_tasks', 'view_roadmap', + 'view_own_tasks', 'view_groups_tasks'); + + $this->perms = array(0 => array()); + // Get project settings which are important for permissions + # php7.2 compatible variant without create_function(), instead use a SQL UNION to fill a fake global-project with project_id=0 + $sql = $db->query(' + SELECT project_id, others_view, project_is_active, anon_open, comment_closed + FROM {projects} + UNION + SELECT 0,1,1,1,1'); + while ($row = $db->fetchRow($sql)) { + $this->perms[$row['project_id']] = $row; + } + + if (!$this->isAnon()) { + // Get the global group permissions for the current user + $sql = $db->query("SELECT ".join(', ', $fields).", g.project_id, uig.record_id, + g.group_open, g.group_id AS project_group + FROM {groups} g + LEFT JOIN {users_in_groups} uig ON g.group_id = uig.group_id + LEFT JOIN {projects} p ON g.project_id = p.project_id + WHERE uig.user_id = ? + ORDER BY g.project_id, g.group_id ASC", + array($this->id)); + + while ($row = $db->fetchRow($sql)) { + if (!isset($this->perms[$row['project_id']])) { + // should not happen, so clean up the DB + $db->query('DELETE FROM {users_in_groups} WHERE record_id = ?', array($row['record_id'])); + continue; + } + + $this->perms[$row['project_id']] = array_merge($this->perms[$row['project_id']], $row); + } + + // Set missing permissions and attachments + foreach ($this->perms as $proj_id => $value) { + foreach ($fields as $key) { + if ($key == 'project_group') { + continue; + } + + $this->perms[$proj_id][$key] = max($this->perms[0]['is_admin'], @$this->perms[$proj_id][$key], $this->perms[0][$key]); + if ($proj_id && $key != 'is_admin') { + $this->perms[$proj_id][$key] = max(@$this->perms[$proj_id]['manage_project'], $this->perms[$proj_id][$key]); + } + } + + // nobody can upload files if uploads are disabled at the system level.. + if (!$fs->max_file_size || !is_writable(BASEDIR .'/attachments')) { + $this->perms[$proj_id]['create_attachments'] = 0; + } + } + } + } + + public function check_account_ok() + { + global $conf, $baseurl; + // Anon users are always OK + if ($this->isAnon()) { + return; + } + $saltedpass = crypt($this->infos['user_pass'], $conf['general']['cookiesalt']); + + if (Cookie::val('flyspray_passhash') !== $saltedpass || !$this->infos['account_enabled'] + || !$this->perms('group_open', 0)) + { + $this->logout(); + Flyspray::redirect($baseurl); + } + } + + public function isAnon() + { + return $this->id < 0; + } + + /* }}} */ + /* permission related {{{ */ + + public function can_edit_comment($comment) + { + return $this->perms('edit_comments') + || ($comment['user_id'] == $this->id && $this->perms('edit_own_comments')); + } + + public function can_view_project($proj) + { + if (is_array($proj) && isset($proj['project_id'])) { + $proj = $proj['project_id']; + } + + return ($this->perms('view_tasks', $proj) || $this->perms('view_groups_tasks', $proj) || $this->perms('view_own_tasks', $proj)) + || ($this->perms('project_is_active', $proj) + && ($this->perms('others_view', $proj) || $this->perms('project_group', $proj))); + } + + /* can_select_project() is similiar to can_view_project(), but + * allows anonymous users/guests to select this project if the project allows anon task creation, + * but all other stuff is restricted. + */ + public function can_select_project($proj) + { + if (is_array($proj) && isset($proj['project_id'])) { + $proj = $proj['project_id']; + } + + return ( + $this->perms('view_tasks', $proj) + || $this->perms('view_groups_tasks', $proj) + || $this->perms('view_own_tasks', $proj) + ) + || + ( + $this->perms('project_is_active', $proj) + && ( + $this->perms('others_view', $proj) + || $this->perms('project_group', $proj) + || $this->perms('anon_open', $proj) + ) + ); + } + + public function can_view_task($task) + { + if ($task['task_token'] && Get::val('task_token') == $task['task_token']) { + return true; + } + + // Split into several separate tests so I can keep track on whats happening. + + // Project managers and admins allowed always. + if ($this->perms('manage_project', $task['project_id']) + || $this->perms('is_admin', $task['project_id'])) { + return true; + } + + // Allow if "allow anyone to view this project" is checked + // and task is not private. + if ($this->perms('others_view', $task['project_id']) && !$task['mark_private']) { + return true; + } + + if ($this->isAnon()) { + // Following checks need identified user. + return false; + } + + // Non-private task + if (!$task['mark_private']) { + // Can view tasks, always allow + if ($this->perms('view_tasks', $task['project_id'])) { + return true; + } + // User can view only own tasks + if ($this->perms('view_own_tasks', $task['project_id']) + && !$this->perms('view_groups_tasks', $task['project_id'])) { + if ($task['opened_by'] == $this->id) { + return true; + } + if (in_array($this->id, Flyspray::getAssignees($task['task_id']))) { + return true; + } + // No use to continue further. + return false; + } + // Ok, user *must* have view_groups_tasks permission, + // but do the check anyway just in case... there might + // appear more in the future. + if ($this->perms('view_groups_tasks', $task['project_id'])) { + // Two first checks the same as with view_own_tasks permission. + if ($task['opened_by'] == $this->id) { + return true; + } + // Fetch only once, could be needed three times. + $assignees = Flyspray::getAssignees($task['task_id']); + if (in_array($this->id, $assignees)) { + return true; + } + + // Must fetch other persons in the group now. Find out + // how to detect the right group for project and the + // other persons in it. Funny, found it in $perms. + $group = $this->perms('project_group', $task['project_id']); + $others = Project::listUsersIn($group); + + foreach ($others as $other) { + if ($other['user_id'] == $task['opened_by']) { + return true; + } + if (in_array($other['user_id'], $assignees)) { + return true; + } + } + + // Check the global group next. Note that for users in that group to be included, + // the has to be specified at global group level. So even if our permission system + // works by OR'ing the permissions together, who is actually considered to be in + // in the same group now depends on whether this permission has been given on global + // or project level. + if ($this->perms('view_groups_tasks', 0)) { + $group = $this->perms('project_group', 0); + $others = Project::listUsersIn($group); + + foreach ($others as $other) { + if ($other['user_id'] == $task['opened_by']) { + return true; + } + if (in_array($other['user_id'], $assignees)) { + return true; + } + } + } + + // No use to continue further. + return false; + } + } + + // Private task, user must be either assigned to the task + // or have opened it. + if ($task['mark_private']) { + if ($task['opened_by'] == $this->id) { + return true; + } + if (in_array($this->id, Flyspray::getAssignees($task['task_id']))) { + return true; + } + // No use to continue further. + return false; + } + + // Could not find any permission for viewing the task. + return false; + } + + public function can_edit_task($task) + { + return !$task['is_closed'] && ( + $this->perms('modify_all_tasks', $task['project_id']) || + ($this->id == $task['opened_by'] && $this->perms('modify_own_tasks', $task['project_id'])) || + in_array($this->id, Flyspray::getAssignees($task['task_id'])) + ); + } + + public function can_take_ownership($task) + { + $assignees = Flyspray::getAssignees($task['task_id']); + + return ($this->perms('assign_to_self', $task['project_id']) && empty($assignees)) + || ($this->perms('assign_others_to_self', $task['project_id']) && !in_array($this->id, $assignees)); + } + + public function can_add_to_assignees($task) + { + return ($this->perms('add_to_assignees', $task['project_id']) && !in_array($this->id, Flyspray::getAssignees($task['task_id']))); + } + + public function can_close_task($task) + { + return ($this->perms('close_own_tasks', $task['project_id']) && in_array($this->id, $task['assigned_to'])) + || $this->perms('close_other_tasks', $task['project_id']); + } + + public function can_set_task_parent($task) + { + return !$task['is_closed'] && ( + $this->perms('modify_all_tasks', $task['project_id']) || + in_array($this->id, Flyspray::getAssignees($task['task_id'])) + ); + } + + public function can_associate_task($task) + { + return !$task['is_closed'] && ( + $this->perms('modify_all_tasks', $task['project_id']) || + in_array($this->id, Flyspray::getAssignees($task['task_id'])) + ); + } + + public function can_add_task_dependency($task) + { + return !$task['is_closed'] && ( + $this->perms('modify_all_tasks', $task['project_id']) || + in_array($this->id, Flyspray::getAssignees($task['task_id'])) + ); + } + +//admin approve user registration + public function need_admin_approval() + { + global $fs; + return $this->isAnon() && $fs->prefs['need_approval'] && $fs->prefs['anon_reg']; + } + + public function get_group_id() + { + } + + /** + * tests if current configuration allows a guest user to register - without email verification code + */ + public function can_self_register() + { + global $fs; + return $this->isAnon() && $fs->prefs['anon_reg'] && !$fs->prefs['only_oauth_reg'] && !$fs->prefs['spam_proof'] ; + } + + /** + * tests if current configuration allows a guest user to register - with email verification code + */ + public function can_register() + { + global $fs; + return $this->isAnon() && $fs->prefs['anon_reg'] && !$fs->prefs['only_oauth_reg'] && $fs->prefs['spam_proof'] && !$fs->prefs['need_approval'] ; + } + + public function can_open_task($proj) + { + return $proj->id && ($this->perms('manage_project') || + $this->perms('project_is_active', $proj->id) && ($this->perms('open_new_tasks') || $this->perms('anon_open', $proj->id))); + } + + public function can_change_private($task) + { + return !$task['is_closed'] && ($this->perms('manage_project', $task['project_id']) || in_array($this->id, Flyspray::getAssignees($task['task_id']))); + } + + public function can_vote($task) + { + global $db, $fs; + + if (!$this->perms('add_votes', $task['project_id'])) { + return -1; + } + + // Check that the user hasn't already voted this task + $check = $db->query('SELECT vote_id + FROM {votes} + WHERE user_id = ? AND task_id = ?', + array($this->id, $task['task_id'])); + if ($db->countRows($check)) { + return -2; + } + + /* FS 1.0alpha daily vote limit + // Check that the user hasn't voted more than allowed today + $check = $db->query('SELECT vote_id + FROM {votes} + WHERE user_id = ? AND date_time > ?', + array($this->id, time() - 86400)); + if ($db->countRows($check) >= $fs->prefs['max_vote_per_day']) { + return -3; + } + */ + + /* FS 1.0beta2 max votes per user per project limit */ + $check = $db->query(' + SELECT COUNT(v.vote_id) + FROM {votes} v + JOIN {tasks} t ON t.task_id=v.task_id + WHERE user_id = ? + AND t.project_id = ? + AND t.is_closed <>1', + array($this->id, $task['project_id']) + ); + if ($db->countRows($check) >= $fs->prefs['votes_per_project']) { + return -4; + } + + + return 1; + } + + public function logout() + { + // Set cookie expiry time to the past, thus removing them + Flyspray::setcookie('flyspray_userid', '', time()-60); + Flyspray::setcookie('flyspray_passhash', '', time()-60); + Flyspray::setcookie('flyspray_project', '', time()-60); + if (Cookie::has(session_name())) { + Flyspray::setcookie(session_name(), '', time()-60); + } + + // Unset all of the session variables. + $_SESSION = array(); + session_destroy(); + + return !$this->isAnon(); + } + + /** + * Returns the activity by between dates for a project and user. + * @param date $startdate + * @param date $enddate + * @param integer $project_id + * @param integer $userid + * @return array used to get the count + * @access public + */ + static function getActivityUserCount($startdate, $enddate, $project_id, $userid) { + global $db; + $result = $db->query('SELECT count(event_date) as val + FROM {history} h left join {tasks} t on t.task_id = h.task_id + WHERE t.project_id = ? AND h.user_id = ? AND event_date BETWEEN ? AND ?', + array($project_id, $userid, $startdate, $enddate)); + $result = $db->fetchCol($result); + return $result[0]; + } + + /** + * Returns the day activity by the date for a project and user. + * @param date $date + * @param integer $project_id + * @param integer $userid + * @return array used to get the count + * @access public + */ + static function getDayActivityByUser($date_start, $date_end, $project_id, $userid) { + global $db; + //NOTE: from_unixtime() on mysql, to_timestamp() on PostreSQL + $func = ('mysql' == $db->dblink->dataProvider) ? 'from_unixtime' : 'to_timestamp'; + + $result = $db->query("SELECT count(date({$func}(event_date))) as val, MIN(event_date) as event_date + FROM {history} h left join {tasks} t on t.task_id = h.task_id + WHERE t.project_id = ? AND h.user_id = ? AND event_date BETWEEN ? AND ? + GROUP BY date({$func}(event_date)) ORDER BY event_date DESC", + array($project_id, $userid, $date_start, $date_end)); + + $date1 = new \DateTime("@$date_start"); + $date2 = new \DateTime("@$date_end"); + $days = $date1->diff($date2); + $days = $days->format('%a'); + $results = array(); + + for ($i = $days; $i > 0; $i--) { + $event_date = (string) strtotime("-{$i} day", $date_end); + $results[date('Y-m-d', $event_date)] = 0; + } + + while ($row = $result->fetchRow()) { + $event_date = date('Y-m-d', $row['event_date']); + $results[$event_date] = (integer) $row['val']; + } + + return array_values($results); + } + + /* }}} */ +} diff --git a/includes/constants.inc.php b/includes/constants.inc.php new file mode 100644 index 0000000..b3c3171 --- /dev/null +++ b/includes/constants.inc.php @@ -0,0 +1,96 @@ +<?php +/** + * Basic constants/variables required for flyspray operation + * + * @notes be a real paranoid here. + * @version $Id$ + */ + +define('BASEDIR', dirname(dirname(__FILE__))); + +// Change this line if you move flyspray.conf.php elsewhere +$conf = @parse_ini_file(Flyspray::get_config_path(), true); + +// $baseurl +// htmlspecialchars because PHP_SELF is user submitted data, and can be used as an XSS vector. +if (isset($conf['general']['force_baseurl']) && $conf['general']['force_baseurl'] != '') { + $baseurl = $conf['general']['force_baseurl']; +} else { + if (!isset($webdir)) { + $webdir = dirname(htmlspecialchars($_SERVER['PHP_SELF'], ENT_QUOTES, 'utf-8')); + if (!$webdir) { + $webdir = dirname($_SERVER['SCRIPT_NAME']); + } + if(substr($webdir, -13) == '/js/callbacks'){ + $webdir = dirname(dirname($webdir)); + } elseif (substr($webdir, -9) == 'index.php') { + $webdir = dirname($webdir); + } + } + + $baseurl = rtrim(Flyspray::absoluteURI($webdir),'/\\') . '/' ; +} + +if(isset($conf['general']['syntax_plugin']) && preg_match('/^[a-z0-9_]+$/iD', $conf['general']['syntax_plugin'])) { + +$path_to_plugin = sprintf('%s/plugins/%s/%s_constants.inc.php', BASEDIR, $conf['general']['syntax_plugin'], $conf['general']['syntax_plugin']); + + if (is_readable($path_to_plugin)) { + include($path_to_plugin); + } +} + +define('NOTIFY_TASK_OPENED', 1); +define('NOTIFY_TASK_CHANGED', 2); +define('NOTIFY_TASK_CLOSED', 3); +define('NOTIFY_TASK_REOPENED', 4); +define('NOTIFY_DEP_ADDED', 5); +define('NOTIFY_DEP_REMOVED', 6); +define('NOTIFY_COMMENT_ADDED', 7); +define('NOTIFY_ATT_ADDED', 8); +define('NOTIFY_REL_ADDED', 9); +define('NOTIFY_OWNERSHIP', 10); +define('NOTIFY_CONFIRMATION', 11); +define('NOTIFY_PM_REQUEST', 12); +define('NOTIFY_PM_DENY_REQUEST', 13); +define('NOTIFY_NEW_ASSIGNEE', 14); +define('NOTIFY_REV_DEP', 15); +define('NOTIFY_REV_DEP_REMOVED', 16); +define('NOTIFY_ADDED_ASSIGNEES', 17); +define('NOTIFY_ANON_TASK', 18); +define('NOTIFY_PW_CHANGE', 19); +define('NOTIFY_NEW_USER', 20); +define('NOTIFY_OWN_REGISTRATION',21); + +define('NOTIFY_EMAIL', 1); +define('NOTIFY_JABBER', 2); +define('NOTIFY_BOTH', 3); + +define('STATUS_UNCONFIRMED', 1); +define('STATUS_NEW', 2); +define('STATUS_ASSIGNED', 3); + +define('GET_CONTENTS', true); + +# resolution_id with special meaning and protection, always 6 (Flyspray history) +define('RESOLUTION_DUPLICATE', 6); + +// Others +define('MIN_PW_LENGTH', 5); +define('LOGIN_ATTEMPTS', 5); + +# 201508: webdot currently used not anymore in flyspray. Graphs can be done in future with svg or canvas elements. +define('FLYSPRAY_WEBDOT', 'http://webdot.flyspray.org/'); +define('FS_DOMAIN_HASH', md5($_SERVER['SERVER_NAME'] . BASEDIR)); +define('FS_CACHE_DIR', Flyspray::get_tmp_dir() . DIRECTORY_SEPARATOR . FS_DOMAIN_HASH); + +is_dir(FS_CACHE_DIR) || @mkdir(FS_CACHE_DIR, 0700); + +// developers or advanced users only +//define('DEBUG_SQL',true); + +# 201508: Currently without usage! Was once used in file fsjabber.php (not in src anymore), but not within class.jabber2.php. +//define('JABBER_DEBUG', true); +//define('JABBER_DEBUG_FILE', BASEDIR . '/logs/jabberlog.txt'); + +//define('FS_MAIL_LOGFILE', BASEDIR . '/logs/maillog.txt'); diff --git a/includes/events.inc.php b/includes/events.inc.php new file mode 100644 index 0000000..ec9f2a7 --- /dev/null +++ b/includes/events.inc.php @@ -0,0 +1,308 @@ +<?php + +// XXX be aware: make sure you quote correctly using qstr() +// the variables used in the $where parameter, since statement is +// executed AS IS. + +function get_events($task_id, $where = '') +{ + global $db; + return $db->query("SELECT h.*, + tt1.tasktype_name AS task_type1, + tt2.tasktype_name AS task_type2, + los1.os_name AS operating_system1, + los2.os_name AS operating_system2, + lc1.category_name AS product_category1, + lc2.category_name AS product_category2, + p1.project_title AS project_id1, + p2.project_title AS project_id2, + lv1.version_name AS product_version1, + lv2.version_name AS product_version2, + ls1.status_name AS item_status1, + ls2.status_name AS item_status2, + lr.resolution_name, + c.date_added AS c_date_added, + c.user_id AS c_user_id, + att.orig_name + + FROM {history} h + + LEFT JOIN {list_tasktype} tt1 ON tt1.tasktype_id::text = h.old_value AND h.field_changed='task_type' + LEFT JOIN {list_tasktype} tt2 ON tt2.tasktype_id::text = h.new_value AND h.field_changed='task_type' + + LEFT JOIN {list_os} los1 ON los1.os_id::text = h.old_value AND h.field_changed='operating_system' + LEFT JOIN {list_os} los2 ON los2.os_id::text = h.new_value AND h.field_changed='operating_system' + + LEFT JOIN {list_category} lc1 ON lc1.category_id::text = h.old_value AND h.field_changed='product_category' + LEFT JOIN {list_category} lc2 ON lc2.category_id::text = h.new_value AND h.field_changed='product_category' + + LEFT JOIN {list_status} ls1 ON ls1.status_id::text = h.old_value AND h.field_changed='item_status' + LEFT JOIN {list_status} ls2 ON ls2.status_id::text = h.new_value AND h.field_changed='item_status' + + LEFT JOIN {list_resolution} lr ON lr.resolution_id::text = h.new_value AND h.event_type = 2 + + LEFT JOIN {projects} p1 ON p1.project_id::text = h.old_value AND h.field_changed='project_id' + LEFT JOIN {projects} p2 ON p2.project_id::text = h.new_value AND h.field_changed='project_id' + + LEFT JOIN {comments} c ON c.comment_id::text = h.field_changed AND h.event_type = 5 + + LEFT JOIN {attachments} att ON att.attachment_id::text = h.new_value AND h.event_type = 7 + + LEFT JOIN {list_version} lv1 ON lv1.version_id::text = h.old_value + AND (h.field_changed='product_version' OR h.field_changed='closedby_version') + LEFT JOIN {list_version} lv2 ON lv2.version_id::text = h.new_value + AND (h.field_changed='product_version' OR h.field_changed='closedby_version') + + WHERE h.task_id = ? $where + ORDER BY event_date ASC, history_id ASC, event_type ASC", array($task_id)); +} + +/** + * XXX: A mess,remove my in 1.0. No time for that, sorry. + */ +function event_description($history) { + $return = ''; + global $fs, $baseurl, $details, $proj; + + $translate = array('item_summary' => 'summary', 'project_id' => 'attachedtoproject', + 'task_type' => 'tasktype', 'product_category' => 'category', 'item_status' => 'status', + 'task_priority' => 'priority', 'operating_system' => 'operatingsystem', 'task_severity' => 'severity', + 'product_version' => 'reportedversion', 'mark_private' => 'visibility', + 'estimated_effort' => 'estimatedeffort'); + // if somehing gets double escaped, add it here. + $noescape = array('new_value', 'old_value'); + + foreach($history as $key=> $value) { + if(!in_array($key, $noescape)) { + $history[$key] = Filters::noXSS($value); + } + } + + $new_value = $history['new_value']; + $old_value = $history['old_value']; + + switch($history['event_type']) { + case '3': //Field changed + if (!$new_value && !$old_value) { + $return .= eL('taskedited'); + break; + } + + $field = $history['field_changed']; + switch ($field) { + case 'item_summary': + case 'project_id': + case 'task_type': + case 'product_category': + case 'item_status': + case 'task_priority': + case 'operating_system': + case 'task_severity': + case 'product_version': + if($field == 'task_priority') { + $old_value = $fs->priorities[$old_value]; + $new_value = $fs->priorities[$new_value]; + } elseif($field == 'task_severity') { + $old_value = $fs->severities[$old_value]; + $new_value = $fs->severities[$new_value]; + } elseif($field == 'item_summary') { + $old_value = Filters::noXSS($old_value); + $new_value = Filters::noXSS($new_value); + } else { + $old_value = $history[$field . '1']; + $new_value = $history[$field . '2']; + } + $field = eL($translate[$field]); + break; + case 'closedby_version': + $field = eL('dueinversion'); + $old_value = ($old_value == '0') ? eL('undecided') : $history['product_version1']; + $new_value = ($new_value == '0') ? eL('undecided') : $history['product_version2']; + break; + case 'due_date': + $field = eL('duedate'); + $old_value = formatDate($old_value, false, eL('undecided')); + $new_value = formatDate($new_value, false, eL('undecided')); + break; + case 'percent_complete': + $field = eL('percentcomplete'); + $old_value .= '%'; + $new_value .= '%'; + break; + case 'mark_private': + $field = eL($translate[$field]); + if ($old_value == 1) { + $old_value = eL('private'); + } else { + $old_value = eL('public'); + } + if ($new_value == 1) { + $new_value = eL('private'); + } else { + $new_value = eL('public'); + } + break; + case 'detailed_desc': + $field = "<a href=\"javascript:getHistory('{$history['task_id']}', '$baseurl', 'history', '{$history['history_id']}');showTabById('history', true);\">" . eL('details') . '</a>'; + if (!empty($details)) { + $details_previous = TextFormatter::render($old_value); + $details_new = TextFormatter::render($new_value); + } + $old_value = ''; + $new_value = ''; + break; + case 'estimated_effort': + $field = eL($translate[$field]); + $old_value = effort::secondsToString($old_value, $proj->prefs['hours_per_manday'], $proj->prefs['estimated_effort_format']); + $new_value = effort::secondsToString($new_value, $proj->prefs['hours_per_manday'], $proj->prefs['estimated_effort_format']);; + break; + } + $return .= eL('fieldchanged').": {$field}"; + if ($old_value || $new_value) { + $return .= " ({$old_value} → {$new_value})"; + } + break; + case '1': //Task opened + $return .= eL('taskopened'); + break; + case '2': //Task closed + $return .= eL('taskclosed'); + $return .= " ({$history['resolution_name']}"; + if (!empty($old_value)) { + $return .= ': ' . TextFormatter::render($old_value, true); + } + $return .= ')'; + break; + case '4': //Comment added + $return .= '<a href="#comments">' . eL('commentadded') . '</a>'; + break; + case '5': //Comment edited + $return .= "<a href=\"javascript:getHistory('{$history['task_id']}', '$baseurl', 'history', '{$history['history_id']}');\">".eL('commentedited')."</a>"; + if ($history['c_date_added']) { + $return .= " (".eL('commentby').' ' . tpl_userlink($history['c_user_id']) . " - " . formatDate($history['c_date_added'], true) . ")"; + } + if ($details) { + $details_previous = TextFormatter::render($old_value); + $details_new = TextFormatter::render($new_value); + } + break; + case '6': //Comment deleted + $return .= "<a href=\"javascript:getHistory('{$history['task_id']}', '$baseurl', 'history', '{$history['history_id']}');\">".eL('commentdeleted')."</a>"; + if ($new_value != '' && $history['field_changed'] != '') { + $return .= " (". eL('commentby'). ' ' . tpl_userlink($new_value) . " - " . formatDate($history['field_changed'], true) . ")"; + } + if (!empty($details)) { + $details_previous = TextFormatter::render($old_value); + $details_new = ''; + } + break; + case '7': //Attachment added + $return .= eL('attachmentadded'); + if ($history['orig_name']) { + $return .= ": <a href=\"{$baseurl}?getfile=" . intval($new_value) . '">' . "{$history['orig_name']}</a>"; + } else if ($history['old_value']) { + $return .= ': ' . $history['old_value']; + } + break; + case '8': //Attachment deleted + $return .= eL('attachmentdeleted') . ': ' . Filters::noXSS($new_value); + break; + case '9': //Notification added + $return .= eL('notificationadded') . ': ' . tpl_userlink($new_value); + break; + case '10': //Notification deleted + $return .= eL('notificationdeleted') . ': ' . tpl_userlink($new_value); + break; + case '11': //Related task added + $return .= eL('relatedadded') . ': ' . tpl_tasklink($new_value); + break; + case '12': //Related task deleted + $return .= eL('relateddeleted') . ': ' . tpl_tasklink($new_value); + break; + case '13': //Task reopened + $return .= eL('taskreopened'); + break; + case '14': //Task assigned + if (empty($old_value)) { + $users = explode(' ', trim($new_value)); + $users = array_map('tpl_userlink', $users); + $return .= eL('taskassigned').' '; + $return .= implode(', ', $users); + } elseif (empty($new_value)) { + $return .= eL('assignmentremoved'); + } else { + $users = explode(' ', trim($new_value)); + $users = array_map('tpl_userlink', $users); + $return .= eL('taskreassigned').' '; + $return .= implode(', ', $users); + } + break; + // Mentioned in docs, not used anywhere. Will implement if suitable + // translations already exist, otherwise leave to 1.1. (Found translations) + case '15': // This task was added to another task's related list + $return .= eL('addedasrelated') . ': ' . tpl_tasklink($new_value); + break; + case '16': // This task was removed from another task's related list + $return .= eL('deletedasrelated') . ': ' . tpl_tasklink($new_value); + break; + case '17': //Reminder added + $return .= eL('reminderadded') . ': ' . tpl_userlink($new_value); + break; + case '18': //Reminder deleted + $return .= eL('reminderdeleted') . ': ' . tpl_userlink($new_value); + break; + case '19': //User took ownership + $return .= eL('ownershiptaken') . ': ' . tpl_userlink($new_value); + break; + case '20': //User requested task closure + $return .= eL('closerequestmade') . ' - ' . $new_value; + break; + case '21': //User requested task + $return .= eL('reopenrequestmade') . ' - ' . $new_value; + break; + case '22': // Dependency added + $return .= eL('depadded') . ': ' . tpl_tasklink($new_value); + break; + case '23': // Dependency added to other task + $return .= eL('depaddedother') . ': ' . tpl_tasklink($new_value); + break; + case '24': // Dependency removed + $return .= eL('depremoved') . ': ' . tpl_tasklink($new_value); + break; + case '25': // Dependency removed from other task + $return .= eL('depremovedother') . ': ' . tpl_tasklink($new_value); + break; + // 26 and 27 replaced by 0 (mark_private) + case '28': // PM request denied + $return .= eL('pmreqdenied') . ' - ' . $new_value; + break; + case '29': // User added to assignees list + $return .= eL('addedtoassignees'); + break; + case '30': // user created + $return .= eL('usercreated'); + break; + case '31': // user deleted + $return .= eL('userdeleted'); + break; + case '32': // Subtask added + $return .= eL('subtaskadded') . ' ' . tpl_tasklink($new_value); + break; + case '33': // Subtask removed + $return .= eL('subtaskremoved') . ' ' . tpl_tasklink($new_value); + break; + case '34': // supertask added + $return .= eL('supertaskadded') . ' ' . tpl_tasklink($new_value); + break; + case '35': // supertask removed + $return .= eL('supertaskremoved') . ' ' . tpl_tasklink($new_value); + break; + } + + if (isset($details_previous)) $GLOBALS['details_previous'] = $details_previous; + if (isset($details_new)) $GLOBALS['details_new'] = $details_new; + + return $return; +} + +?> diff --git a/includes/fix.inc.php b/includes/fix.inc.php new file mode 100644 index 0000000..d5d27b1 --- /dev/null +++ b/includes/fix.inc.php @@ -0,0 +1,205 @@ +<?php + +/* + * This file is meant to add every hack that is needed to fix default PHP + * behaviours, and to ensure that our PHP env will be able to run flyspray + * correctly. + * + */ +ini_set('display_errors', 0); + +// html errors will mess the layout +ini_set('html_errors', 0); + +//error_reporting(E_ALL); +if(version_compare(PHP_VERSION, '7.2.0') >= 0) { + # temporary for php7.2+ (2017-11-30) + # not all parts of Flyspray and 3rd party libs like ADODB 5.20.9 not yet 'since-php7.2-deprecated'-ready + error_reporting(E_ALL & ~E_STRICT & ~E_DEPRECATED); +}else{ + error_reporting(E_ALL & ~E_STRICT); +} +// our default charset + +ini_set('default_charset','utf-8'); + +// This to stop PHP being retarded and using the '&' char for session id delimiters +ini_set('arg_separator.output','&'); + +// no transparent session id improperly configured servers +if (isset($_SERVER['REQUEST_URI']) && strpos($_SERVER['REQUEST_URI'], '/setup/') === false) // Skip installer, as it starts the session before calling fix.inc.php causing a warning as this can't be used when a session is already active + ini_set('session.use_trans_sid', 0); + +//see http://php.net/manual/en/ref.session.php#ini.session.use-only-cookies +ini_set('session.use_only_cookies',1); + +//no session auto start +ini_set('session.auto_start',0); + +/*this stops most cookie attacks via XSS at the interpreter level +* see http://msdn.microsoft.com/workshop/author/dhtml/httponly_cookies.asp +* supported by IE 6 SP1, Safari, Konqueror, Opera, silently ignored by others +* ( sadly, including firefox) available since PHP 5.2.0 + */ + +ini_set('session.cookie_httponly',1); + +// use stronger entropy in sessions whenever possible +ini_set('session.entropy_file', '/dev/urandom'); +ini_set('session.entropy_length', 16); +// use sha-1 for sessions +ini_set('session.hash_function',1); + +ini_set('auto_detect_line_endings', 0); + +# for using stronger blowfish hashing functions also with php5.3 < yourphpversion < php5.5 +# minimal php5.3.8 recommended, see https://github.com/ircmaxell/password_compat +if(!function_exists('password_hash')){ + require_once dirname(__FILE__).'/password_compat.php'; +} + +# for php < php.5.6 +if(!function_exists('hash_equals')) { + function hash_equals($str1, $str2) { + if(strlen($str1) != strlen($str2)) { + return false; + } else { + $res = $str1 ^ $str2; + $ret = 0; + for($i = strlen($res) - 1; $i >= 0; $i--) $ret |= ord($res[$i]); + return !$ret; + } + } +} + + +ini_set('include_path', join( PATH_SEPARATOR, array( + dirname(__FILE__) . '/external' , + ini_get('include_path')))); + + +if(count($_GET)) { + foreach ($_GET as $key => $value) { + if(is_array($value)) + $_GET[$key] = filter_input(INPUT_GET, $key, FILTER_UNSAFE_RAW, FILTER_REQUIRE_ARRAY); + else + $_GET[$key] = filter_input(INPUT_GET, $key, FILTER_UNSAFE_RAW); + } +} +if(count($_POST)) { + foreach ($_POST as $key => $value) { + if(is_array($value)) + $_POST[$key] = filter_input(INPUT_POST, $key, FILTER_UNSAFE_RAW, FILTER_REQUIRE_ARRAY); + else + $_POST[$key] = filter_input(INPUT_POST, $key, FILTER_UNSAFE_RAW); + } +} +if(count($_COOKIE)) { + foreach ($_COOKIE as $key => $value) { + if(is_array($value)) + $_COOKIE[$key] = filter_input(INPUT_COOKIE, $key, FILTER_UNSAFE_RAW, FILTER_REQUIRE_ARRAY); + else + $_COOKIE[$key] = filter_input(INPUT_COOKIE, $key, FILTER_UNSAFE_RAW); + } +} +if(isset($_SESSION) && is_array($_SESSION) && count($_SESSION)) { + foreach ($_SESSION as $key => $value) { + if(is_array($value)) + $_SESSION[$key] = filter_input(INPUT_SESSION, $key, FILTER_UNSAFE_RAW, FILTER_REQUIRE_ARRAY); + else + $_SESSION[$key] = filter_input(INPUT_SESSION, $key, FILTER_UNSAFE_RAW); + } +} + + +// This is for retarded Windows servers not having REQUEST_URI + +if (!isset($_SERVER['REQUEST_URI'])) +{ + if (isset($_SERVER['SCRIPT_NAME'])) { + $_SERVER['REQUEST_URI'] = $_SERVER['SCRIPT_NAME']; + } + else { + // this is tained now. + $_SERVER['REQUEST_URI'] = $_SERVER['PHP_SELF']; + } + + if (isset($_SERVER['QUERY_STRING'])) { + $_SERVER['REQUEST_URI'] .= '?'.$_SERVER['QUERY_STRING']; + } +} + +if (!isset($_SERVER['QUERY_STRING'])) +{ + $_SERVER['QUERY_STRING'] = ''; +} + + +/** + * Replace glob() since this function is apparently + * disabled for no apparent reason ("security") on some systems + * + * @see glob() + * @require PHP 4.3.0 (fnmatch) + * @todo is this still required? + */ +function glob_compat($pattern, $flags = 0) { + + $split = explode('/', $pattern); + $match = array_pop($split); + $path = implode('/', $split); + if (($dir = opendir($path)) !== false) { + $glob = array(); + while (($file = readdir($dir)) !== false) { + if (fnmatch($match, $file)) { + if (is_dir("$path/$file") || !($flags & GLOB_ONLYDIR)) { + if ($flags & GLOB_MARK) $file .= '/'; + $glob[] = $file; + } + } + } + closedir($dir); + if (!($flags & GLOB_NOSORT)) sort($glob); + return $glob; + } + return false; +} + +// now for all those borked PHP installations... +// TODO still required. Enabled by default since 4.2 +if (!function_exists('ctype_alnum')) { + function ctype_alnum($text) { + return is_string($text) && preg_match('/^[a-z0-9]+$/iD', $text); + } +} +if (!function_exists('ctype_digit')) { + function ctype_digit($text) { + return is_string($text) && preg_match('/^[0-9]+$/iD', $text); + } +} + +if(!isset($_SERVER['SERVER_NAME']) && php_sapi_name() === 'cli') { + $_SERVER['SERVER_NAME'] = php_uname('n'); +} + +//for reasons outside flsypray, the PHP core may throw Exceptions in PHP5 +// for a good example see this article +// http://ilia.ws/archives/107-Another-unserialize-abuse.html + +function flyspray_exception_handler($exception) { + // Sometimes it helps removing temporary comments from the following three lines. + // echo "<pre>"; + // var_dump(debug_backtrace()); + // echo "</pre>"; + die("Completely unexpected exception: " . + htmlspecialchars($exception->getMessage(),ENT_QUOTES, 'utf-8') . "<br/>" . + "This should <strong> never </strong> happend, please inform Flyspray Developers"); + +} + +set_exception_handler('flyspray_exception_handler'); + + +// We don't need session IDs in URLs +output_reset_rewrite_vars(); + diff --git a/includes/i18n.inc.php b/includes/i18n.inc.php new file mode 100644 index 0000000..47d6852 --- /dev/null +++ b/includes/i18n.inc.php @@ -0,0 +1,166 @@ +<?php + +if (!defined('IN_FS')) { + die('Do not access this file directly.'); +} + +require_once BASEDIR . '/lang/en.php'; +FlySprayI18N::init('en', $language); +FlySprayI18N::setDefault($language); + +class FlySprayI18N { + private static $translations = array(); + + public static function init($lang, $translation) { + self::$translations[$lang] = $translation; + } + + public static function setDefault($translation) { + self::$translations['default'] = $translation; + } + + public static function L($key, $lang = null) { + if (!isset($lang) || empty($lang) || !is_string($lang)) { + $lang = 'default'; + } + if ($lang != 'default' && $lang != 'en' && !array_key_exists($lang, self::$translations)) { + // echo "<pre>Only once here for $lang!</pre>"; + $language = BASEDIR . DIRECTORY_SEPARATOR . 'lang' . DIRECTORY_SEPARATOR . $lang . '.php'; + if (is_readable($language)) { + if ((@require $language) !== FALSE) { + // echo "<pre>Loaded: $lang!</pre>"; + self::$translations[$lang] = $translation; + } + else { + $lang = 'default'; + } + } + else { + $lang = 'default'; + } + } + if (empty($key)) { + return ''; + } + if (isset(self::$translations[$lang][$key])) { + // echo "<pre>Case 1: $lang!</pre>"; + return self::$translations[$lang][$key]; + } + if (isset(self::$translations['default'][$key])) { + // echo "<pre>Case 2: $lang!</pre>"; + return self::$translations['default'][$key]; + } + if (isset(self::$translations['en'][$key])) { + // echo "<pre>Case 3: $lang!</pre>"; + return self::$translations['en'][$key]; + } + // echo "<pre>Case 4: $lang!". var_dump(self::$translations['en']) ."</pre>"; + return "[[$key]]"; + } +} +/** + * get the language string $key + * return string + */ + +function L($key){ + global $language; + if (empty($key)) { + return ''; + } + if (isset($language[$key])) { + return $language[$key]; + } + return "[[$key]]"; +} + +/** + * get the language string $key in $lang + * or current default language if $lang + * is not given. + * return string + */ + +function tL($key, $lang = null) { + return FlySprayI18N::L($key, $lang); +} +/** + * html escaped variant of the previous + * return $string + */ +function eL($key){ + return htmlspecialchars(L($key), ENT_QUOTES, 'utf-8'); +} + +function load_translations(){ + global $proj, $language, $user, $fs; + # Load translations + # if no valid lang_code, return english + # valid == a-z and "_" case insensitive + + if (isset($user) && array_key_exists('lang_code', $user->infos)){ + $lang_code=$user->infos['lang_code']; + } + + # 20150211 add language preferences detection of visitors + # locale_accept_from_http() not available on every hosting, so we must parse it self. + # TODO ..and we can loop later through $langs until we find a matching translation file + if((!isset($lang_code) || $lang_code=='' || $lang_code=='browser') && isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])){ + foreach( explode(',', $_SERVER['HTTP_ACCEPT_LANGUAGE']) as $lang) { + # taken from a php.net comment + $pattern = '/^(?P<primarytag>[a-zA-Z]{2,8})'. + '(?:-(?P<subtag>[a-zA-Z]{2,8}))?(?:(?:;q=)'. + '(?P<quantifier>\d\.\d))?$/'; + $splits = array(); + if (preg_match($pattern, $lang, $splits)) { + $langs[]=$splits; + } + } + # TODO maybe sort $langs-array by quantifiers, but for most browsers it should be ok, because they sent it in right order. + if(isset($langs)){ + $lang_code=$langs[0]['primarytag']; + if(isset($langs[0]['subtag'])){ + $lang_code.='_'.$langs[0]['subtag']; # '_' for our language files, '-' in HTTP_ACCEPT_LANGUAGE + } + } + } + + if(!isset($lang_code) || $lang_code=='' || $lang_code=='project'){ + if($proj->prefs['lang_code']){ + $lang_code = $proj->prefs['lang_code']; + }else{ + $lang_code = 'en'; + } + } + + if (!preg_match('/^[a-z_]+$/iD', $lang_code)) { + $lang_code ='en'; + } + + $lang_code = strtolower($lang_code); + $translation = BASEDIR.'/lang/'.$lang_code.'.php'; + if ($lang_code != 'en' && is_readable($translation)) { + include_once($translation); + $language = is_array($translation) ? array_merge($language, $translation) : $language; + FlySprayI18N::init($lang_code, $language); + }elseif( 'en'!=substr($lang_code, 0, strpos($lang_code, '_')) && is_readable(BASEDIR.'/lang/'.(substr($lang_code, 0, strpos($lang_code, '_'))).'.php') ){ + # fallback 'de_AT' to 'de', but not for 'en_US' + $translation=BASEDIR.'/lang/'.(substr($lang_code, 0, strpos($lang_code, '_'))).'.php'; + include_once($translation); + $language = is_array($translation) ? array_merge($language, $translation) : $language; + } + + FlySprayI18N::setDefault($language); + // correctly translate title since language not set when initialising the project + if (isset($proj) && !$proj->id) { + $proj->prefs['project_title'] = L('allprojects'); + $proj->prefs['feed_description'] = L('feedforall'); + } + + for ($i = 6; $i >= 1; $i--) { + $fs->priorities[$i] = L('priority' . $i); + } + for ($i = 5; $i >= 1; $i--) { + $fs->severities[$i] = L('severity' . $i); + } +} diff --git a/includes/modify.inc.php b/includes/modify.inc.php new file mode 100644 index 0000000..004a5a8 --- /dev/null +++ b/includes/modify.inc.php @@ -0,0 +1,3053 @@ +<?php + +/** + * Database Modifications + * @version $Id$ + */ + +if (!defined('IN_FS')) { + die('Do not access this file directly.'); +} + +$notify = new Notifications; + +$lt = Post::isAlnum('list_type') ? Post::val('list_type') : ''; +$list_table_name = null; +$list_column_name = null; +$list_id = null; + +if (strlen($lt)) { + $list_table_name = '{list_'.$lt .'}'; + $list_column_name = $lt . '_name'; + $list_id = $lt . '_id'; +} + +function Post_to0($key) { return Post::val($key, 0); } + +function resizeImage($file, $max_x, $max_y, $forcePng = false) +{ + if ($max_x <= 0 || $max_y <= 0) { + $max_x = 5; + $max_y = 5; + } + + $src = BASEDIR.'/avatars/'.$file; + + list($width, $height, $type) = getImageSize($src); + + $scale = min($max_x / $width, $max_y / $height); + $newWidth = $width * $scale; + $newHeight = $height * $scale; + + $img = imagecreatefromstring(file_get_contents($src)); + $black = imagecolorallocate($img, 0, 0, 0); + $resizedImage = imageCreateTrueColor($newWidth, $newHeight); + imagecolortransparent($resizedImage, $black); + imageCopyResampled($resizedImage, $img, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height); + imageDestroy($img); + unlink($src); + + if (!$forcePng) { + switch ($type) { + case IMAGETYPE_JPEG: + imageJpeg($resizedImage, BASEDIR.'/avatars/'.$file); + break; + case IMAGETYPE_GIF: + imageGif($resizedImage, BASEDIR.'/avatars/'.$file); + break; + case IMAGETYPE_PNG: + imagePng($resizedImage, BASEDIR.'/avatars/'.$file); + break; + default: + imagePng($resizedImage, BASEDIR.'/avatars/'.$file); + break; + } + } + else { + imagePng($resizedImage, BASEDIR.'/avatars/'.$file.'.png'); + } + + return; +} + +if (Req::num('task_id')) { + $task = Flyspray::getTaskDetails(Req::num('task_id')); +} + +if(isset($_SESSION)) { + unset($_SESSION['SUCCESS'], $_SESSION['ERROR'], $_SESSION['ERRORS']); +} + +switch ($action = Req::val('action')) +{ + // ################## + // Adding a new task + // ################## + case 'newtask.newtask': + + $newtaskerrors=array(); + + if (!Post::val('item_summary') || trim(Post::val('item_summary')) == '') { // description not required anymore + $newtaskerrors['summaryrequired']=1; + } + + if ($user->isAnon() && !filter_var(Post::val('anon_email'), FILTER_VALIDATE_EMAIL)) { + $newtaskerrors['invalidemail']=1; + } + + if (count($newtaskerrors)>0){ + $_SESSION['ERRORS']=$newtaskerrors; + $_SESSION['ERROR']=L('invalidnewtask'); + break; + } + + list($task_id, $token) = Backend::create_task($_POST); + // Status and redirect + if ($task_id) { + $_SESSION['SUCCESS'] = L('newtaskadded'); + + if ($user->isAnon()) { + Flyspray::redirect(createURL('details', $task_id, null, array('task_token' => $token))); + } else { + Flyspray::redirect(createURL('details', $task_id)); + } + } else { + Flyspray::show_error(L('databasemodfailed')); + break; + } + break; + + // ################## + // Adding multiple new tasks + // ################## + case 'newmultitasks.newmultitasks': + if(!isset($_POST['item_summary'])) { + #Flyspray::show_error(L('summaryanddetails')); + Flyspray::show_error(L('summaryrequired')); + break; + } + $flag = true; + foreach($_POST['item_summary'] as $summary) { + if(!$summary || trim($summary) == "") { + $flag = false; + break; + } + } + $i = 0; + foreach($_POST['detailed_desc'] as $detail) { + if($detail){ + # only for ckeditor/html, not for dokuwiki (or other syntax plugins in future) + if ($conf['general']['syntax_plugin'] != 'dokuwiki') { + $_POST['detailed_desc'][$i] = "<p>" . $detail . "</p>"; + } + } + $i++; + } + if(!$flag) { + #Flyspray::show_error(L('summaryanddetails')); + Flyspray::show_error(L('summaryrequired')); + break; + } + + $flag = true; + $length = count($_POST['detailed_desc']); + for($i = 0; $i < $length; $i++) { + $ticket = array(); + foreach($_POST as $key => $value) { + if($key == "assigned_to") { + $sql = $db->query("SELECT user_id FROM {users} WHERE user_name = ? or real_name = ?", array($value[$i], $value[$i])); + $ticket["rassigned_to"] = array(intval($db->fetchOne($sql))); + continue; + } + if(is_array($value)) + $ticket[$key] = $value[$i]; + else + $ticket[$key] = $value; + } + list($task_id, $token) = Backend::create_task($ticket); + if (!$task_id) { + $flag = false; + break; + } + } + + if(!$flag) { + Flyspray::show_error(L('databasemodfailed')); + break; + } + + $_SESSION['SUCCESS'] = L('newtaskadded'); + Flyspray::redirect(createURL('index', $proj->id)); + break; + + // ################## + // Modifying an existing task + // ################## + case 'details.update': + if (!$user->can_edit_task($task)) { + Flyspray::show_error(L('nopermission')); # TODO create a better error message + break; + } + + $errors=array(); + + # TODO add checks who should be able to move a task, modify_all_tasks perm should not be enough and the target project perms are required too. + # - User has project manager permission in source project AND in target project: Allowed to move task + # - User has project manager permission in source project, but NOT in target project: Can send request to PUSH task to target project. A user with project manager permission of target project can accept the PUSH request. + # - User has NO project manager permission in source project, but in target project: Can send request to PULL task to target project. A user with project manager permission of source project can accept the PULL request. + # - User has calculated can_edit_task permission in source project AND (at least) newtask perm in target project: Can send a request to move task (similiar to 'close task please'-request) with the target project id, sure. + + $move=0; + if($task['project_id'] != Post::val('project_id')) { + $toproject=new Project(Post::val('project_id')); + if($user->perms('modify_all_tasks', $toproject->id)){ + $move=1; + } else{ + $errors['invalidtargetproject']=1; + } + } + + if($move==1){ + # Check that a task is not moved to a different project than its + # possible parent or subtasks. Note that even closed tasks are + # included in the result, a task can be always reopened later. + $result = $db->query(' + SELECT parent.task_id, parent.project_id FROM {tasks} p + JOIN {tasks} parent ON parent.task_id = p.supertask_id + WHERE p.task_id = ? + AND parent.project_id <> ?', + array( $task['task_id'], Post::val('project_id') ) + ); + $parentcheck = $db->fetchRow($result); + if ($parentcheck && $parentcheck['task_id']) { + if ($parentcheck['project_id'] != Post::val('project_id')) { + $errors['denymovehasparent']=L('denymovehasparent'); + } + } + + $result = $db->query(' + SELECT sub.task_id, sub.project_id FROM {tasks} p + JOIN {tasks} sub ON p.task_id = sub.supertask_id + WHERE p.task_id = ? + AND sub.project_id <> ?', + array( $task['task_id'], Post::val('project_id') ) + ); + $subcheck = $db->fetchRow($result); + + # if there are any subtasks, check that the project is not changed + if ($subcheck && $subcheck['task_id']) { + $errors['denymovehassub']=L('denymovehassub'); + } + } + + # summary form input fields, so user get notified what needs to be done right to be accepted + if (!Post::val('item_summary')) { + # description can be empty now + #Flyspray::show_error(L('summaryanddetails')); + #Flyspray::show_error(L('summaryrequired')); + $errors['summaryrequired']=L('summaryrequired'); + } + + # ids of severity and priority are (probably!) intentional fixed in Flyspray. + if( isset($_POST['task_severity']) && (!is_numeric(Post::val('task_severity')) || Post::val('task_severity')>5 || Post::val('task_severity')<0 ) ){ + $errors['invalidseverity']=1; + } + + # peterdd:temp fix to allow priority 6 again + # But I think about 1-5 valid (and 0 for unset) only in future to harmonize + # with other trackers/taskplaner software and for severity-priority graphs like + # https://en.wikipedia.org/wiki/Time_management#The_Eisenhower_Method + if( isset($_POST['task_priority']) && (!is_numeric(Post::val('task_priority')) || Post::val('task_priority')>6 || Post::val('task_priority')<0 ) ){ + $errors['invalidpriority']=1; + } + + if( isset($_POST['percent_complete']) && (!is_numeric(Post::val('percent_complete')) || Post::val('percent_complete')>100 || Post::val('percent_complete')<0 ) ){ + $errors['invalidprogress']=1; + } + + # Description for the following list values here when moving a task to a different project: + # - Do we use the old invalid values? (current behavior until 1.0-beta2, invalid id-values in database can be set, can result in php-'notices' or values arent shown on pages) + # - Or set to default value of the new project? And inform the user to adjust the task properties in the new project? + # - Or create a new tasktype for the new project, but: + # - Has the user the permission to create a new tasktype for the new project? + # - similiar named tasktypes exists? + # + # Maybe let's go with 2 steps when in this situation: + # When user want move task to other project, a second page shows the form again but: + # dropdown list forms show - maybe divided as optiongroups - : + # -global list values () + # -current project list values + # -target project list values + # -option to create a new option based on current project value (if the user has the permission for the target project!) + # -option to set to default value in target project or unset value + # Also consider that not all list dropdown field may be shown to the user because of project settings (visible_fields)! + + + # which $proj should we use here? $proj object is set in header.php by a request param before modify.inc.php is loaded, so it can differ from $task['project_id']! + if($move==1){ + $statusarray=$toproject->listTaskStatuses(); + } else{ + $statusarray=$proj->listTaskStatuses(); + } + + # FIXME what if we move to different project, but the status_id is defined for the old project only (not global)? + # FIXME what if we move to different project and item_status selection is deactivated/not shown in edit task page? + if( isset($_POST['item_status']) && (!is_numeric(Post::val('item_status')) || false===Flyspray::array_find('status_id', Post::val('item_status'), $statusarray) ) ){ + $errors['invalidstatus']=1; + } + + if($move==1){ + $typearray=$toproject->listTaskTypes(); + } else{ + $typearray=$proj->listTaskTypes(); + } + + # FIXME what if we move to different project, but tasktype_id is defined for the old project only (not global)? + # FIXME what if we move to different project and task_type selection is deactiveated/not shown in edit task page? + if( isset($_POST['task_type']) && (!is_numeric(Post::val('task_type')) || false===Flyspray::array_find('tasktype_id', Post::val('task_type'), $typearray) ) ){ + $errors['invalidtasktype']=1; + } + + # FIXME what if we move to different project and reportedver selection is deactivated/not shown in edit task page? + # FIXME what if we move to different project and reportedver is deactivated/not shown in edit task page? + # FIXME what if we move to different project and closedby_version selection is deactivated/not shown in edit task page? + # FIXME what if we move to different project and closedby_version is deactivated/not shown in edit task page? + if($move==1){ + $versionarray=$toproject->listVersions(); + } else{ + $versionarray=$proj->listVersions(); + } + if( isset($_POST['reportedver']) && (!is_numeric(Post::val('reportedver')) || ( $_POST['reportedver']!=='0' && false===Flyspray::array_find('version_id', Post::val('reportedver'), $versionarray)) ) ){ + $errors['invalidreportedversion']=1; + } + if( isset($_POST['closedby_version']) && (!is_numeric(Post::val('closedby_version')) || ( $_POST['closedby_version']!=='0' && false===Flyspray::array_find('version_id', Post::val('closedby_version'), $versionarray)) ) ){ + $errors['invaliddueversion']=1; + } + + # FIXME what if we move to different project, but category_id is defined for the old project only (not global)? + # FIXME what if we move to different project and category selection is deactivated/not shown in edit task page? + if($move==1){ + $catarray=$toproject->listCategories(); + } else{ + $catarray=$proj->listCategories(); + } + if( isset($_POST['product_category']) && (!is_numeric(Post::val('product_category')) || false===Flyspray::array_find('category_id', Post::val('product_category'), $catarray) ) ){ + $errors['invalidcategory']=1; + } + + # FIXME what if we move to different project, but os_id is defined for the old project only (not global)? + # FIXME what if we move to different project and operating_system selection is deactivated/not shown in edit task page? + if($move==1){ + $osarray=$toproject->listOs(); + } else{ + $osarray=$proj->listOs(); + } + if( isset($_POST['operating_system']) && (!is_numeric(Post::val('operating_system')) || ( $_POST['operating_system']!=='0' && false===Flyspray::array_find('os_id', Post::val('operating_system'), $osarray)) ) ){ + $errors['invalidos']=1; + } + + if ($due_date = Post::val('due_date', 0)) { + $due_date = Flyspray::strtotime(Post::val('due_date')); + } + + $estimated_effort = 0; + if (($estimated_effort = effort::editStringToSeconds(Post::val('estimated_effort'), $proj->prefs['hours_per_manday'], $proj->prefs['estimated_effort_format'])) === FALSE) { + $errors['invalideffort']=1; + } + + $time = time(); + + $result = $db->query('SELECT * from {tasks} WHERE task_id = ?', array($task['task_id'])); + $defaults = $db->fetchRow($result); + + if (!Post::has('due_date')) { + $due_date = $defaults['due_date']; + } + + if (!Post::has('estimated_effort')) { + $estimated_effort = $defaults['estimated_effort']; + } + + + if(count($errors)>0){ + # some invalid input by the user. Do not save the input and in the details-edit-template show the user where in the form the invalid values are. + $_SESSION['ERRORS']=$errors; # $_SESSION['ERROR'] is very limited, holds only one string and often just overwritten + $_SESSION['ERROR']=L('invalidinput'); + # pro and contra http 303 redirect here: + # - good: browser back button works, browser history. + # - bad: form inputs of user not preserved (at the moment). Annoying if user wrote a long description and then the form submit gets denied because of other reasons. + #Flyspray::redirect(createURL('edittask', $task['task_id'])); + break; + } + + # FIXME/TODO: If a user has only 'edit own task edit' permission and task remains in the same project, + # but there are not all fields visible/editable so the browser do not send that values with the form, + # the sql update query should not touch that fields. And it should not overwrite the field with the default value in this case. + # So this update query should be build dynamic (And for the future: when 'custom fields' are implemented ..) + # Alternative: Read task field values before update query. + # And we should check too what task fields the 'edit own task only'-user is allowed to change. + # (E.g ignore form fields the user is not allowed to change. Currently hardcoded in template..) + +/* + # Dynamic creation of the UPDATE query required + # First step: Settings which task fields can be changed by 'permission level': Based on situation found in FS 1.0-rc1 'status quo' in backend::create_task() and CleanFS/templates/template details.edit.tpl + #$basicfields[]=array('item_summary','detailed_desc', 'task_type', 'product_category', 'operating_system', 'task_severity', 'percent_complete', 'product_version', 'estimated_effort'); # modify_own_tasks, anon_open + $basicfields=$proj->prefs['basic_fields']; + + # peterdd: just saved a bit work in progress for future dynamic sql update string + $sqlup=''; + foreach($basicfields as $bf){ + $sqlup.=' '.$bf.' = ?,'; + $sqlparam[]= Post::val($bf, $oldvals[$bf]); + } + $sqlup.=' last_edited_by = ?,'; + $sqlparam[]= $user->id; + $sqlup.=' last_edited_time = ?,'; + $sqlparam[]= $time; + + $devfields[]=array('task_priority', 'due_date', 'item_status', 'closedby_version'); # modify_all_tasks + $managerfields[]=array('project_id','mark_private'); # manage_project + #$customfields[]=array(); # Flyspray 1.? future: perms depend of each custom field setting in a project.. + + $sqlparam[]=$task['task_id']; + $sqlupdate='UPDATE {tasks} SET '.$sqlup.' WHERE task_id = ?'; + + echo '<pre>';print_r($sqlupdate);print_r($sqlparam);die(); + $db->query($sqlupdate, $sqlparam); +*/ + + $detailed_desc = Post::val('detailed_desc', $defaults['detailed_desc']); + + # dokuwiki syntax plugin filters on output + if($conf['general']['syntax_plugin'] != 'dokuwiki'){ + $purifierconfig = HTMLPurifier_Config::createDefault(); + $purifier = new HTMLPurifier($purifierconfig); + $detailed_desc = $purifier->purify($detailed_desc); + } + + $db->query('UPDATE {tasks} + SET + project_id = ?, + task_type = ?, + item_summary = ?, + detailed_desc = ?, + item_status = ?, + mark_private = ?, + product_category = ?, + closedby_version = ?, + operating_system = ?, + task_severity = ?, + task_priority = ?, + last_edited_by = ?, + last_edited_time = ?, + due_date = ?, + percent_complete = ?, + product_version = ?, + estimated_effort = ? + WHERE task_id = ?', + array( + Post::val('project_id', $defaults['project_id']), + Post::val('task_type', $defaults['task_type']), + Post::val('item_summary', $defaults['item_summary']), + $detailed_desc, + Post::val('item_status', $defaults['item_status']), + intval($user->can_change_private($task) && Post::val('mark_private', $defaults['mark_private'])), + Post::val('product_category', $defaults['product_category']), + Post::val('closedby_version', $defaults['closedby_version']), + Post::val('operating_system', $defaults['operating_system']), + Post::val('task_severity', $defaults['task_severity']), + Post::val('task_priority', $defaults['task_priority']), + intval($user->id), $time, intval($due_date), + Post::val('percent_complete', $defaults['percent_complete']), + Post::val('reportedver', $defaults['product_version']), + intval($estimated_effort), + $task['task_id'] + ) + ); + + // Update the list of users assigned this task + $assignees = (array) Post::val('rassigned_to'); + $assignees_changed = count(array_diff($task['assigned_to'], $assignees)) + count(array_diff($assignees, $task['assigned_to'])); + if ($user->perms('edit_assignments') && $assignees_changed) { + + // Delete the current assignees for this task + $db->query('DELETE FROM {assigned} + WHERE task_id = ?', + array($task['task_id'])); + + // Convert assigned_to and store them in the 'assigned' table + foreach ((array) Post::val('rassigned_to') as $key => $val) + { + $db->replace('{assigned}', array('user_id'=> $val, 'task_id'=> $task['task_id']), array('user_id','task_id')); + } + } + + # FIXME what if we move to different project, but tag(s) is/are defined for the old project only (not global)? + # FIXME what if we move to different project and tag input field is deactivated/not shown in edit task page? + # - Create new tag(s) in target project if user has permission to create new tags but what with the users who have not the permission? + # update tags + $tagList = explode(';', Post::val('tags')); + $tagList = array_map('strip_tags', $tagList); + $tagList = array_map('trim', $tagList); + $tagList = array_unique($tagList); # avoid duplicates for inputs like: "tag1;tag1" or "tag1; tag1<p></p>" + $storedtags=array(); + foreach($task['tags'] as $temptag){ + $storedtags[]=$temptag['tag']; + } + $tags_changed = count(array_diff($storedtags, $tagList)) + count(array_diff($tagList, $storedtags)); + + if($tags_changed){ + // Delete the current assigned tags for this task + $db->query('DELETE FROM {task_tag} WHERE task_id = ?', array($task['task_id'])); + foreach ($tagList as $tag){ + if ($tag == ''){ + continue; + } + # size of {list_tag}.tag_name, see flyspray-install.xml + if(mb_strlen($tag) > 40){ + # report that softerror + $errors['tagtoolong']=1; + continue; + } + + $res=$db->query("SELECT tag_id FROM {list_tag} WHERE (project_id=0 OR project_id=?) AND tag_name LIKE ? ORDER BY project_id", array($proj->id,$tag) ); + if($t=$db->fetchRow($res)){ + $tag_id=$t['tag_id']; + } else{ + if( $proj->prefs['freetagging']==1){ + # add to taglist of the project + $db->query("INSERT INTO {list_tag} (project_id,tag_name) VALUES (?,?)", array($proj->id,$tag)); + $tag_id=$db->insert_ID(); + } else{ + continue; + } + }; + $db->query("INSERT INTO {task_tag}(task_id,tag_id) VALUES(?,?)", array($task['task_id'], $tag_id) ); + } + } + + // Get the details of the task we just updated + // To generate the changed-task message + $new_details_full = Flyspray::getTaskDetails($task['task_id']); + // Not very nice...maybe combine compare_tasks() and logEvent() ? + $result = $db->query("SELECT * FROM {tasks} WHERE task_id = ?", + array($task['task_id'])); + $new_details = $db->fetchRow($result); + + foreach ($new_details as $key => $val) { + if (strstr($key, 'last_edited_') || $key == 'assigned_to' || is_numeric($key)) { + continue; + } + + if ($val != $task[$key]) { + // Log the changed fields in the task history + Flyspray::logEvent($task['task_id'], 3, $val, $task[$key], $key, $time); + } + } + + $changes = Flyspray::compare_tasks($task, $new_details_full); + if (count($changes) > 0) { + $notify->create(NOTIFY_TASK_CHANGED, $task['task_id'], $changes, null, NOTIFY_BOTH, $proj->prefs['lang_code']); + } + + if ($assignees_changed) { + // Log to task history + Flyspray::logEvent($task['task_id'], 14, implode(' ', $assignees), implode(' ', $task['assigned_to']), '', $time); + + // Notify the new assignees what happened. This obviously won't happen if the task is now assigned to no-one. + if (count($assignees)) { + $new_assignees = array_diff($task['assigned_to'], $assignees); + // Remove current user from notification list + if (!$user->infos['notify_own']) { + $new_assignees = array_filter($new_assignees, function($u) use($user) { return $user->id != $u; } ); + } + if(count($new_assignees)) { + $notify->create(NOTIFY_NEW_ASSIGNEE, $task['task_id'], null, $notify->specificAddresses($new_assignees), NOTIFY_BOTH, $proj->prefs['lang_code']); + } + } + } + + Backend::add_comment($task, Post::val('comment_text'), $time); + Backend::delete_files(Post::val('delete_att')); + Backend::upload_files($task['task_id'], '0', 'usertaskfile'); + Backend::delete_links(Post::val('delete_link')); + Backend::upload_links($task['task_id'], '0', 'userlink'); + + $_SESSION['SUCCESS'] = L('taskupdated'); + # report minor/soft errors too that does not hindered saving task + if(count($errors)>0){ + $_SESSION['ERRORS']=$errors; + } + Flyspray::redirect(createURL('details', $task['task_id'])); + break; + + // ################## + // closing a task + // ################## + case 'details.close': + if (!$user->can_close_task($task)) { + break; + } + + if ($task['is_closed']) { + break; + } + + if (!Post::val('resolution_reason')) { + Flyspray::show_error(L('noclosereason')); + break; + } + + Backend::close_task($task['task_id'], Post::val('resolution_reason'), Post::val('closure_comment', ''), Post::val('mark100', false)); + + $_SESSION['SUCCESS'] = L('taskclosedmsg'); + # FIXME there are several pages using this form, details and pendingreq at least + #Flyspray::redirect(createURL('details', $task['task_id'])); + break; + + case 'details.associatesubtask': + if ( $task['task_id'] == Post::num('associate_subtask_id')) { + Flyspray::show_error(L('selfsupertasknotallowed')); + break; + } + $sql = $db->query('SELECT supertask_id, project_id FROM {tasks} WHERE task_id = ?', + array(Post::num('associate_subtask_id'))); + + $suptask = $db->fetchRow($sql); + + // check to see if the subtask exists. + if (!$suptask) { + Flyspray::show_error(L('subtasknotexist')); + break; + } + + // if the user has not the permission to view all tasks, check if the task + // is in tasks allowed to see, otherwise tell that the task does not exist. + if (!$user->perms('view_tasks')) { + $taskcheck = Flyspray::getTaskDetails(Post::num('associate_subtask_id')); + if (!$user->can_view_task($taskcheck)) { + Flyspray::show_error(L('subtasknotexist')); + break; + } + } + + // check to see if associated subtask is already the parent of this task + if ($suptask['supertask_id'] == Post::num('associate_subtask_id')) { + Flyspray::show_error(L('subtaskisparent')); + break; + } + + // check to see if associated subtask already has a parent task + if ($suptask['supertask_id']) { + Flyspray::show_error(L('subtaskalreadyhasparent')); + break; + } + + // check to see that both tasks belong to the same project + if ($task['project_id'] != $suptask['project_id']) { + Flyspray::show_error(L('musthavesameproject')); + break; + } + + //associate the subtask + $db->query('UPDATE {tasks} SET supertask_id=? WHERE task_id=?',array( $task['task_id'], Post::num('associate_subtask_id'))); + Flyspray::logEvent($task['task_id'], 32, Post::num('associate_subtask_id')); + Flyspray::logEvent(Post::num('associate_subtask_id'), 34, $task['task_id']); + + $_SESSION['SUCCESS'] = sprintf( L('associatedsubtask'), Post::num('associate_subtask_id') ); + break; + + + case 'reopen': + // ################## + // re-opening an task + // ################## + if (!$user->can_close_task($task)) { + break; + } + + // Get last % + $old_percent = $db->query("SELECT old_value, new_value + FROM {history} + WHERE field_changed = 'percent_complete' + AND task_id = ? AND old_value != '100' + ORDER BY event_date DESC + LIMIT 1", + array($task['task_id'])); + $old_percent = $db->fetchRow($old_percent); + + $db->query("UPDATE {tasks} + SET resolution_reason = 0, closure_comment = '', date_closed = 0, + last_edited_time = ?, last_edited_by = ?, is_closed = 0, percent_complete = ? + WHERE task_id = ?", + array(time(), $user->id, intval($old_percent['old_value']), $task['task_id'])); + + Flyspray::logEvent($task['task_id'], 3, $old_percent['old_value'], $old_percent['new_value'], 'percent_complete'); + + $notify->create(NOTIFY_TASK_REOPENED, $task['task_id'], null, null, NOTIFY_BOTH, $proj->prefs['lang_code']); + + // add comment of PM request to comment page if accepted + $sql = $db->query('SELECT * FROM {admin_requests} WHERE task_id = ? AND request_type = ? AND resolved_by = 0', + array($task['task_id'], 2)); + $request = $db->fetchRow($sql); + if ($request) { + $db->query('INSERT INTO {comments} + (task_id, date_added, last_edited_time, user_id, comment_text) + VALUES ( ?, ?, ?, ?, ? )', + array($task['task_id'], time(), time(), $request['submitted_by'], $request['reason_given'])); + // delete existing PM request + $db->query('UPDATE {admin_requests} + SET resolved_by = ?, time_resolved = ? + WHERE request_id = ?', + array($user->id, time(), $request['request_id'])); + } + + Flyspray::logEvent($task['task_id'], 13); + + $_SESSION['SUCCESS'] = L('taskreopenedmsg'); + # FIXME there are several pages using this form, details and pendingreq at least + #Flyspray::redirect(createURL('details', $task['task_id'])); + break; + + // ################## + // adding a comment + // ################## + case 'details.addcomment': + if (!Backend::add_comment($task, Post::val('comment_text'))) { + Flyspray::show_error(L('nocommententered')); + break; + } + + if (Post::val('notifyme') == '1') { + // If the user wanted to watch this task for changes + Backend::add_notification($user->id, $task['task_id']); + } + + $_SESSION['SUCCESS'] = L('commentaddedmsg'); + Flyspray::redirect(createURL('details', $task['task_id'])); + break; + + // ################## + // Tracking + // ################## + case 'details.efforttracking': + + require_once BASEDIR . '/includes/class.effort.php'; + $effort = new effort($task['task_id'],$user->id); + + + if(Post::val('start_tracking')){ + if($effort->startTracking()) + { + $_SESSION['SUCCESS'] = L('efforttrackingstarted'); + } + else + { + $_SESSION['ERROR'] = L('efforttrackingnotstarted'); + } + } + + if(Post::val('stop_tracking')){ + $effort->stopTracking(); + $_SESSION['SUCCESS'] = L('efforttrackingstopped'); + } + + if(Post::val('cancel_tracking')){ + $effort->cancelTracking(); + $_SESSION['SUCCESS'] = L('efforttrackingcancelled'); + } + + if(Post::val('manual_effort')){ + if($effort->addEffort(Post::val('effort_to_add'), $proj)){ + $_SESSION['SUCCESS'] = L('efforttrackingadded'); + } + } + + Flyspray::redirect(createURL('details', $task['task_id']).'#effort'); + break; + + // ################## + // sending a new user a confirmation code + // ################## + case 'register.sendcode': + if (!$user->can_register()) { + break; + } + + $captchaerrors=array(); + if($fs->prefs['captcha_securimage']){ + $image = new Securimage(); + if( !Post::isAlnum('captcha_code') || !$image->check(Post::val('captcha_code'))) { + $captchaerrors['invalidsecurimage']=1; + } + } + + if($fs->prefs['captcha_recaptcha']){ + require_once('class.recaptcha.php'); + if( !recaptcha::verify()) { + $captchaerrors['invalidrecaptcha']=1; + } + } + + if(count($captchaerrors)){ + $_SESSION['ERRORS']=$captchaerrors; + Flyspray::show_error(L('captchaerror')); + break; + } + + if (!Post::val('user_name') || !Post::val('real_name') + || !Post::val('email_address')) + { + // If the form wasn't filled out correctly, show an error + Flyspray::show_error(L('registererror')); + break; + } + + if ($fs->prefs['repeat_emailaddress'] && Post::val('email_address') != Post::val('verify_email_address')) + { + Flyspray::show_error(L('emailverificationwrong')); + break; + } + + $email = strtolower(Post::val('email_address')); + $jabber_id = strtolower(Post::val('jabber_id')); + + //email is mandatory + if (!$email || !Flyspray::check_email($email)) { + Flyspray::show_error(L('novalidemail')); + break; + } + //jabber_id is optional + if ($jabber_id && !Jabber::check_jid($jabber_id)) { + Flyspray::show_error(L('novalidjabber')); + break; + } + + $user_name = Backend::clean_username(Post::val('user_name')); + + // Limit length + $real_name = substr(trim(Post::val('real_name')), 0, 100); + // Remove doubled up spaces and control chars + $real_name = preg_replace('![\x00-\x1f\s]+!u', ' ', $real_name); + + if (!$user_name || empty($user_name) || !$real_name) { + Flyspray::show_error(L('entervalidusername')); + break; + } + + // Delete registration codes older than 24 hours + $yesterday = time() - 86400; + $db->query('DELETE FROM {registrations} WHERE reg_time < ?', array($yesterday)); + + $sql = $db->query('SELECT COUNT(*) FROM {users} u, {registrations} r + WHERE u.user_name = ? OR r.user_name = ?', + array($user_name, $user_name)); + if ($db->fetchOne($sql)) { + Flyspray::show_error(L('usernametaken')); + break; + } + + $sql = $db->query("SELECT COUNT(*) FROM {users} WHERE + jabber_id = ? AND jabber_id != '' + OR email_address = ? AND email_address != ''", + array($jabber_id, $email)); + if ($db->fetchOne($sql)) { + Flyspray::show_error(L('emailtaken')); + break; + } + + // Generate a random bunch of numbers for the confirmation code and the confirmation url + + foreach(array('randval','magic_url') as $genrandom) { + + $$genrandom = md5(function_exists('openssl_random_pseudo_bytes') ? + openssl_random_pseudo_bytes(32) : + uniqid(mt_rand(), true)); + } + + $confirm_code = substr($randval, 0, 20); + + // echo "<pre>Am I here?</pre>"; + // send the email first + $userconfirmation = array(); + $userconfirmation[$email] = array('recipient' => $email, 'lang' => $fs->prefs['lang_code']); + $recipients = array($userconfirmation); + if($notify->create(NOTIFY_CONFIRMATION, null, array($baseurl, $magic_url, $user_name, $confirm_code), + $recipients, + NOTIFY_EMAIL)) { + + //email sent succefully, now update the database. + $reg_values = array(time(), $confirm_code, $user_name, $real_name, + $email, $jabber_id, + Post::num('notify_type'), $magic_url, Post::num('time_zone')); + // Insert everything into the database + $query = $db->query("INSERT INTO {registrations} + ( reg_time, confirm_code, user_name, real_name, + email_address, jabber_id, notify_type, + magic_url, time_zone ) + VALUES ( " . $db->fill_placeholders($reg_values) . ' )', $reg_values); + + if ($query) { + $_SESSION['SUCCESS'] = L('codesent'); + Flyspray::redirect($baseurl); + } + + } else { + Flyspray::show_error(L('codenotsent')); + break; + } + + break; + + // ################## + // new user self-registration with a confirmation code + // ################## + case 'register.registeruser': + if (!$user->can_register()) { + break; + } + + if (!Post::val('user_pass') || !Post::val('confirmation_code')) { + Flyspray::show_error(L('formnotcomplete')); + break; + } + + if (strlen(Post::val('user_pass')) < MIN_PW_LENGTH) { + Flyspray::show_error(L('passwordtoosmall')); + break; + } + + if ( $fs->prefs['repeat_password'] && Post::val('user_pass') != Post::val('user_pass2')) { + Flyspray::show_error(L('nomatchpass')); + break; + } + + // Check that the user entered the right confirmation code + $sql = $db->query("SELECT * FROM {registrations} WHERE magic_url = ?", + array(Post::val('magic_url'))); + $reg_details = $db->fetchRow($sql); + + if ($reg_details['confirm_code'] != trim(Post::val('confirmation_code'))) { + Flyspray::show_error(L('confirmwrong')); + break; + } + + $profile_image = 'profile_image'; + $image_path = ''; + + if(isset($_FILES[$profile_image])) { + if(!empty($_FILES[$profile_image]['name'])) { + $allowed = array('jpg', 'jpeg', 'gif', 'png'); + + $image_name = $_FILES[$profile_image]['name']; + $explode = explode('.', $image_name); + $image_extn = strtolower(end($explode)); + $image_temp = $_FILES[$profile_image]['tmp_name']; + + if(in_array($image_extn, $allowed)) { + $avatar_name = substr(md5(time()), 0, 10).'.'.$image_extn; + $image_path = BASEDIR.'/avatars/'.$avatar_name; + move_uploaded_file($image_temp, $image_path); + resizeImage($avatar_name, $fs->prefs['max_avatar_size'], $fs->prefs['max_avatar_size']); + } else { + Flyspray::show_error(L('incorrectfiletype')); + break; + } + } + } + + $enabled = 1; + if (!Backend::create_user($reg_details['user_name'], + Post::val('user_pass'), + $reg_details['real_name'], + $reg_details['jabber_id'], + $reg_details['email_address'], + $reg_details['notify_type'], $reg_details['time_zone'], $fs->prefs['anon_group'], $enabled ,'', '', $image_path)) { + Flyspray::show_error(L('usernametaken')); + break; + } + + $db->query('DELETE FROM {registrations} WHERE magic_url = ? AND confirm_code = ?', + array(Post::val('magic_url'), Post::val('confirmation_code'))); + + + $_SESSION['SUCCESS'] = L('accountcreated'); + // If everything is ok, add here a notify to both administrators and the user. + // Otherwise, explain what wen wrong. + + define('NO_DO', true); + break; + + // ################## + // new user self-registration without a confirmation code + // ################## + case 'register.newuser': + case 'admin.newuser': + if (!($user->perms('is_admin') || $user->can_self_register())) { + break; + } + + $captchaerrors=array(); + if( !($user->perms('is_admin')) && $fs->prefs['captcha_securimage']) { + $image = new Securimage(); + if( !Post::isAlnum('captcha_code') || !$image->check(Post::val('captcha_code'))) { + $captchaerrors['invalidsecurimage']=1; + } + } + + if( !($user->perms('is_admin')) && $fs->prefs['captcha_recaptcha']){ + require_once('class.recaptcha.php'); + if( !recaptcha::verify()) { + $captchaerrors['invalidrecaptcha']=1; + } + } + + # if both captchatypes are configured, maybe show the user which one or both failed. + if(count($captchaerrors)){ + $_SESSION['ERRORS']=$captchaerrors; + Flyspray::show_error(L('captchaerror')); + break; + } + + if (!Post::val('user_name') || !Post::val('real_name') || !Post::val('email_address')) + { + // If the form wasn't filled out correctly, show an error + Flyspray::show_error(L('registererror')); + break; + } + + // Check email format + if (!Post::val('email_address') || !Flyspray::check_email(Post::val('email_address'))) + { + Flyspray::show_error(L('novalidemail')); + break; + } + + if ( $fs->prefs['repeat_emailaddress'] && Post::val('email_address') != Post::val('verify_email_address')) + { + Flyspray::show_error(L('emailverificationwrong')); + break; + } + + if (strlen(Post::val('user_pass')) && (strlen(Post::val('user_pass')) < MIN_PW_LENGTH)) { + Flyspray::show_error(L('passwordtoosmall')); + break; + } + + if ( $fs->prefs['repeat_password'] && Post::val('user_pass') != Post::val('user_pass2')) { + Flyspray::show_error(L('nomatchpass')); + break; + } + + if ($user->perms('is_admin')) { + $group_in = Post::val('group_in'); + } else { + $group_in = $fs->prefs['anon_group']; + } + + if(!$user->perms('is_admin')) { + + $sql = $db->query("SELECT COUNT(*) FROM {users} WHERE + jabber_id = ? AND jabber_id != '' + OR email_address = ? AND email_address != ''", + array(Post::val('jabber_id'), Post::val('email_address'))); + + if ($db->fetchOne($sql)) { + Flyspray::show_error(L('emailtaken')); + break; + } + } + + $enabled = 1; + if($user->need_admin_approval()) $enabled = 0; + + $profile_image = 'profile_image'; + $image_path = ''; + + if(isset($_FILES[$profile_image])) { + if(!empty($_FILES[$profile_image]['name'])) { + $allowed = array('jpg', 'jpeg', 'gif', 'png'); + + $image_name = $_FILES[$profile_image]['name']; + $explode = explode('.', $image_name); + $image_extn = strtolower(end($explode)); + $image_temp = $_FILES[$profile_image]['tmp_name']; + + if(in_array($image_extn, $allowed)) { + $avatar_name = substr(md5(time()), 0, 10).'.'.$image_extn; + $image_path = BASEDIR.'/avatars/'.$avatar_name; + move_uploaded_file($image_temp, $image_path); + resizeImage($avatar_name, $fs->prefs['max_avatar_size'], $fs->prefs['max_avatar_size']); + } else { + Flyspray::show_error(L('incorrectfiletype')); + break; + } + } + } + + if (!Backend::create_user(Post::val('user_name'), Post::val('user_pass'), + Post::val('real_name'), Post::val('jabber_id'), + Post::val('email_address'), Post::num('notify_type'), + Post::num('time_zone'), $group_in, $enabled, '', '', $image_path)) { + Flyspray::show_error(L('usernametaken')); + break; + } + + $_SESSION['SUCCESS'] = L('newusercreated'); + + if (!$user->perms('is_admin')) { + define('NO_DO', true); + } + break; + + + // ################## + // Admin based bulk registration of users + // ################## + case 'register.newuserbulk': + case 'admin.newuserbulk': + if (!($user->perms('is_admin'))) + break; + + $group_in = Post::val('group_in'); + $error = ''; + $success = ''; + $noUsers = true; + + // For each user in post, add them + for ($i = 0 ; $i < 10 ; $i++) + { + $user_name = Post::val('user_name' . $i); + $real_name = Post::val('real_name' . $i); + $email_address = Post::val('email_address' . $i); + + + if( $user_name == '' || $real_name == '' || $email_address == '') + continue; + else + $noUsers = false; + + $enabled = 1; + + // Avoid dups + $sql = $db->query("SELECT COUNT(*) FROM {users} WHERE email_address = ?", + array($email_address)); + + if ($db->fetchOne($sql)) + { + $error .= "\n" . L('emailtakenbulk') . ": $email_address\n"; + continue; + } + + if (!Backend::create_user($user_name, Post::val('user_pass'), + $real_name, '', + $email_address, + Post::num('notify_type'), + Post::num('time_zone'), $group_in, $enabled, '', '', '')) + { + $error .= "\n" . L('usernametakenbulk') .": $user_name\n"; + continue; + } + else + $success .= ' '.$user_name.' '; + } + + if ($error != '') + Flyspray::show_error($error); + else if ( $noUsers == true) + Flyspray::show_error(L('nouserstoadd')); + else + { + $_SESSION['SUCCESS'] = L('created').$success; + if (!$user->perms('is_admin')) { + define('NO_DO', true); + } + } + break; + + + // ################## + // Bulk User Edit Form + // ################## + case 'admin.editallusers': + + if (!($user->perms('is_admin'))) { + break; + } + + $userids = Post::val('checkedUsers'); + + if(!is_array($userids)){ + break; + } + + $users=array(); + + foreach ($userids as $uid) { + if( ctype_digit($uid) ) { + if( $user->id == $uid ){ + Flyspray::show_error(L('nosuicide')); + } else{ + $users[]=$uid; + } + } else{ + Flyspray::show_error(L('invalidinput')); + break 2; + } + } + + if (count($users) == 0){ + Flyspray::show_error(L('nouserselected')); + break; + } + + // Make array of users to modify + $ids = "(" . $users[0]; + for ($i = 1 ; $i < count($users) ; $i++) + { + $ids .= ", " . $users[$i]; + } + $ids .= ")"; + + // Grab the action + if (isset($_POST['enable'])) + { + $sql = $db->query("UPDATE {users} SET account_enabled = 1 WHERE user_id IN $ids"); + } + else if (isset($_POST['disable'])) + { + $sql = $db->query("UPDATE {users} SET account_enabled = 0 WHERE user_id IN $ids"); + } + else if (isset($_POST['delete'])) + { + //$sql = $db->query("DELETE FROM {users} WHERE user_id IN $ids"); + foreach ($users as $uid) { + Backend::delete_user($uid); + } + } + + // Show success message and exit + $_SESSION['SUCCESS'] = L('usersupdated'); + break; + + + + // ################## + // adding a new group + // ################## + case 'pm.newgroup': + case 'admin.newgroup': + if (!$user->perms('manage_project')) { + break; + } + + if (!Post::val('group_name')) { + Flyspray::show_error(L('groupanddesc')); + break; + } else { + // Check to see if the group name is available + $sql = $db->query("SELECT COUNT(*) + FROM {groups} + WHERE group_name = ? AND project_id = ?", + array(Post::val('group_name'), $proj->id)); + + if ($db->fetchOne($sql)) { + Flyspray::show_error(L('groupnametaken')); + break; + } else { + $cols = array('group_name', 'group_desc', 'manage_project', 'edit_own_comments', + 'view_tasks', 'open_new_tasks', 'modify_own_tasks', 'add_votes', + 'modify_all_tasks', 'view_comments', 'add_comments', 'edit_assignments', + 'edit_comments', 'delete_comments', 'create_attachments', + 'delete_attachments', 'view_history', 'close_own_tasks', + 'close_other_tasks', 'assign_to_self', 'show_as_assignees', + 'assign_others_to_self', 'add_to_assignees', 'view_reports', 'group_open', + 'view_estimated_effort', 'track_effort', 'view_current_effort_done', + 'add_multiple_tasks', 'view_roadmap', 'view_own_tasks', 'view_groups_tasks'); + + $params = array_map('Post_to0',$cols); + array_unshift($params, $proj->id); + + $db->query("INSERT INTO {groups} (project_id, ". join(',', $cols).") + VALUES (". $db->fill_placeholders($cols, 1) . ')', $params); + + $_SESSION['SUCCESS'] = L('newgroupadded'); + } + } + + break; + + // ################## + // Update the global application preferences + // ################## + case 'globaloptions': + if (!$user->perms('is_admin')) { + break; + } + + $errors=array(); + + $settings = array('jabber_server', 'jabber_port', 'jabber_username', 'notify_registration', + 'jabber_password', 'anon_group', 'user_notify', 'admin_email', 'email_ssl', 'email_tls', + 'lang_code', 'gravatars', 'hide_emails', 'spam_proof', 'default_project', 'default_entry', + 'dateformat','dateformat_extended', + 'jabber_ssl', 'anon_reg', 'global_theme', 'smtp_server', 'page_title', + 'smtp_user', 'smtp_pass', 'funky_urls', 'reminder_daemon','cache_feeds', 'intro_message', + 'disable_lostpw','disable_changepw','days_before_alert', 'emailNoHTML', 'need_approval', 'pages_welcome_msg', + 'active_oauths', 'only_oauth_reg', 'enable_avatars', 'max_avatar_size', 'default_order_by', + 'max_vote_per_day', 'votes_per_project', 'url_rewriting', + 'custom_style', 'general_integration', 'footer_integration', + 'repeat_password', 'repeat_emailaddress', 'massops'); + + if(!isset($fs->prefs['massops'])){ + $db->query("INSERT INTO {prefs} (pref_name,pref_value) VALUES('massops',0)"); + } + + # candid for a plugin, so separate them for the future. + $settings[]='captcha_securimage'; + if(!isset($fs->prefs['captcha_securimage'])){ + $db->query("INSERT INTO {prefs} (pref_name,pref_value) VALUES('captcha_securimage',0)"); + } + + # candid for a plugin + $settings[]='captcha_recaptcha'; + $settings[]='captcha_recaptcha_sitekey'; + $settings[]='captcha_recaptcha_secret'; + if(!isset($fs->prefs['captcha_recaptcha'])){ + $db->query("INSERT INTO {prefs} (pref_name,pref_value) VALUES('captcha_recaptcha',0),('captcha_recaptcha_sitekey',''),('captcha_recaptcha_secret','')"); + } + + if(Post::val('need_approval') == '1' && Post::val('spam_proof')){ + unset($_POST['spam_proof']); // if self register request admin to approve, disable spam_proof + // if you think different, modify functions in class.user.php directing different regiser tpl + } + if (Post::val('url_rewriting') == '1' && !$fs->prefs['url_rewriting']) { + # Setenv can't be used to set the env variable in .htaccess, because apache module setenv is often disabled on hostings and brings server error 500. + # First check if htaccess is turned on + #if (!array_key_exists('HTTP_HTACCESS_ENABLED', $_SERVER)) { + # Flyspray::show_error(L('enablehtaccess')); + # break; + #} + + # Make sure mod_rewrite is enabled by checking a env var defined as HTTP_MOD_REWRITE in the .htaccess . + # It is possible to be converted to REDIRECT_HTTP_MOD_REWRITE . It's sound weired, but that's the case here. + if ( !array_key_exists('HTTP_MOD_REWRITE', $_SERVER) && !array_key_exists('REDIRECT_HTTP_MOD_REWRITE' , $_SERVER) ) { + #print_r($_SERVER);die(); + Flyspray::show_error(L('nomodrewrite')); + break; + } + } + + if( substr(Post::val('custom_style'), -4) != '.css'){ + $_POST['custom_style']=''; + } + + # TODO validation + if( Post::val('default_order_by2') !='' && Post::val('default_order_by2') !='n'){ + $_POST['default_order_by']=$_POST['default_order_by'].' '.$_POST['default_order_by_dir'].', '.$_POST['default_order_by2'].' '.$_POST['default_order_by_dir2']; + } else{ + $_POST['default_order_by']=$_POST['default_order_by'].' '.$_POST['default_order_by_dir']; + } + + + foreach ($settings as $setting) { + $db->query('UPDATE {prefs} SET pref_value = ? WHERE pref_name = ?', + array(Post::val($setting, 0), $setting)); + // Update prefs for following scripts + $fs->prefs[$setting] = Post::val($setting, 0); + } + + // Process the list of groups into a format we can store + $viscols = trim(Post::val('visible_columns')); + $db->query("UPDATE {prefs} SET pref_value = ? + WHERE pref_name = 'visible_columns'", + array($viscols)); + $fs->prefs['visible_columns'] = $viscols; + + $visfields = trim(Post::val('visible_fields')); + $db->query("UPDATE {prefs} SET pref_value = ? + WHERE pref_name = 'visible_fields'", + array($visfields)); + $fs->prefs['visible_fields'] = $visfields; + + //save logo + if($_FILES['logo']['error'] == 0){ + if( in_array(exif_imagetype($_FILES['logo']['tmp_name']), array(IMAGETYPE_GIF, IMAGETYPE_JPEG, IMAGETYPE_PNG)) ) { + $logofilename=strtolower(basename($_FILES['logo']['name'])); + $logoexplode = explode('.', $logofilename); + $logoextension = strtolower(end($logoexplode)); + $allowedextensions = array('gif', 'jpg', 'jpeg', 'png'); + + if(in_array($logoextension, $allowedextensions)){ + move_uploaded_file($_FILES['logo']['tmp_name'], './' . $logofilename); + $sql = $db->query("SELECT * FROM {prefs} WHERE pref_name='logo'"); + if(!$db->fetchOne($sql)){ + $db->query("INSERT INTO {prefs} (pref_name) VALUES('logo')"); + } + $db->query("UPDATE {prefs} SET pref_value = ? WHERE pref_name='logo'", $logofilename); + } else{ + $errors['invalidfileextension']=1; + } + } + } + //saved logo + + $_SESSION['SUCCESS'] = L('optionssaved'); + if(count($errors)>0){ + $_SESSION['ERRORS']=$errors; + } + + break; + + // ################## + // adding a new project + // ################## + case 'admin.newproject': + if (!$user->perms('is_admin')) { + break; + } + + if (!Post::val('project_title')) { + Flyspray::show_error(L('emptytitle')); + break; + } + + $viscols = $fs->prefs['visible_columns'] + ? $fs->prefs['visible_columns'] + : 'id tasktype priority severity summary status dueversion progress'; + + $visfields = $fs->prefs['visible_fields'] + ? $fs->prefs['visible_fields'] + : 'id tasktype priority severity summary status dueversion progress'; + + + $db->query('INSERT INTO {projects} + ( project_title, theme_style, intro_message, + others_view, others_viewroadmap, anon_open, project_is_active, + visible_columns, visible_fields, lang_code, notify_email, notify_jabber, disp_intro) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)', + array(Post::val('project_title'), Post::val('theme_style'), + Post::val('intro_message'), Post::num('others_view', 0), Post::num('others_viewroadmap', 0), + Post::num('anon_open', 0), $viscols, $visfields, + Post::val('lang_code', 'en'), '', '', + Post::num('disp_intro') + )); + + // $sql = $db->query('SELECT project_id FROM {projects} ORDER BY project_id DESC', false, 1); + // $pid = $db->fetchOne($sql); + $pid = $db->insert_ID(); + + $cols = array( 'manage_project', 'view_tasks', 'open_new_tasks', + 'modify_own_tasks', 'modify_all_tasks', 'view_comments', + 'add_comments', 'edit_comments', 'delete_comments', 'show_as_assignees', + 'create_attachments', 'delete_attachments', 'view_history', 'add_votes', + 'close_own_tasks', 'close_other_tasks', 'assign_to_self', 'edit_own_comments', + 'assign_others_to_self', 'add_to_assignees', 'view_reports', 'group_open', + 'view_estimated_effort', 'view_current_effort_done', 'track_effort', + 'add_multiple_tasks', 'view_roadmap', 'view_own_tasks', 'view_groups_tasks', + 'edit_assignments'); + $args = array_fill(0, count($cols), '1'); + array_unshift($args, 'Project Managers', + 'Permission to do anything related to this project.', + intval($pid)); + + $db->query("INSERT INTO {groups} + ( group_name, group_desc, project_id, + ".join(',', $cols).") + VALUES ( ". $db->fill_placeholders($cols, 3) .")", $args); + + $db->query("INSERT INTO {list_category} + ( project_id, category_name, + show_in_list, category_owner, lft, rgt) + VALUES ( ?, ?, 1, 0, 1, 4)", array($pid, 'root')); + + $db->query("INSERT INTO {list_category} + ( project_id, category_name, + show_in_list, category_owner, lft, rgt ) + VALUES ( ?, ?, 1, 0, 2, 3)", array($pid, 'Backend / Core')); + + $db->query("INSERT INTO {list_os} + ( project_id, os_name, list_position, show_in_list ) + VALUES (?, ?, 1, 1)", array($pid, 'All')); + + $db->query("INSERT INTO {list_version} + ( project_id, version_name, list_position, + show_in_list, version_tense ) + VALUES (?, ?, 1, 1, 2)", array($pid, '1.0')); + + $_SESSION['SUCCESS'] = L('projectcreated'); + Flyspray::redirect(createURL('pm', 'prefs', $pid)); + break; + + // ################## + // updating project preferences + // ################## + case 'pm.updateproject': + if (!$user->perms('manage_project')) { + break; + } + + if (Post::val('delete_project')) { + if (Backend::delete_project($proj->id, Post::val('move_to'))) { + $_SESSION['SUCCESS'] = L('projectdeleted'); + } else { + $_SESSION['ERROR'] = L('projectnotdeleted'); + } + + if (Post::val('move_to')) { + Flyspray::redirect(createURL('pm', 'prefs', Post::val('move_to'))); + } else { + Flyspray::redirect($baseurl); + } + } + + if (!Post::val('project_title')) { + Flyspray::show_error(L('emptytitle')); + break; + } + + $cols = array( 'project_title', 'theme_style', 'lang_code', 'default_task', 'default_entry', + 'intro_message', 'notify_email', 'notify_jabber', 'notify_subject', 'notify_reply', + 'feed_description', 'feed_img_url','default_due_version','use_effort_tracking', + 'pages_intro_msg', 'estimated_effort_format', 'current_effort_done_format'); + $args = array_map('Post_to0', $cols); + $cols = array_merge($cols, $ints = array('project_is_active', 'others_view', 'others_viewroadmap', 'anon_open', 'comment_closed', 'auto_assign', 'freetagging')); + $args = array_merge($args, array_map(array('Post', 'num'), $ints)); + $cols[] = 'notify_types'; + $args[] = implode(' ', (array) Post::val('notify_types')); + $cols[] = 'last_updated'; + $args[] = time(); + $cols[] = 'disp_intro'; + $args[] = Post::num('disp_intro'); + $cols[] = 'default_cat_owner'; + $args[] = Flyspray::UserNameToId(Post::val('default_cat_owner')); + $cols[] = 'custom_style'; + $args[] = Post::val('custom_style'); + + // Convert to seconds. + if (Post::val('hours_per_manday')) { + $args[] = effort::editStringToSeconds(Post::val('hours_per_manday'), $proj->prefs['hours_per_manday'], $proj->prefs['estimated_effort_format']); + $cols[] = 'hours_per_manday'; + } + + # TODO validation + if( Post::val('default_order_by2') !=''){ + $_POST['default_order_by']=$_POST['default_order_by'].' '.$_POST['default_order_by_dir'].', '.$_POST['default_order_by2'].' '.$_POST['default_order_by_dir2']; + } else{ + $_POST['default_order_by']=$_POST['default_order_by'].' '.$_POST['default_order_by_dir']; + } + $cols[]='default_order_by'; + $args[]= $_POST['default_order_by']; + + $args[] = $proj->id; + + $update = $db->query("UPDATE {projects} + SET ".join('=?, ', $cols)."=? + WHERE project_id = ?", $args); + + $update = $db->query('UPDATE {projects} SET visible_columns = ? WHERE project_id = ?', + array(trim(Post::val('visible_columns')), $proj->id)); + + $update = $db->query('UPDATE {projects} SET visible_fields = ? WHERE project_id = ?', + array(trim(Post::val('visible_fields')), $proj->id)); + + // Update project prefs for following scripts + $proj = new Project($proj->id); + $_SESSION['SUCCESS'] = L('projectupdated'); + Flyspray::redirect(createURL('pm', 'prefs', $proj->id)); + break; + + // ################## + // modifying user details/profile + // ################## + case 'admin.edituser': + case 'myprofile.edituser': + if (Post::val('delete_user')) { + // There probably is a bug here somewhere but I just can't find it just now. + // Anyway, I get the message also when just editing my details. + if ($user->id == (int)Post::val('user_id') && $user->perms('is_admin')) { + Flyspray::show_error(L('nosuicide')); + break; + } + else { + // check that he is not the last user + $sql = $db->query('SELECT count(*) FROM {users}'); + if ($db->fetchOne($sql) > 1) { + Backend::delete_user(Post::val('user_id')); + $_SESSION['SUCCESS'] = L('userdeleted'); + Flyspray::redirect(createURL('admin', 'groups')); + } else { + Flyspray::show_error(L('lastuser')); + break; + } + } + } + + if (!Post::val('onlypmgroup')): + if ($user->perms('is_admin') || $user->id == Post::val('user_id')): // only admin or user himself can change + + if (!Post::val('real_name') || (!Post::val('email_address') && !Post::val('jabber_id'))) { + Flyspray::show_error(L('realandnotify')); + break; + } + + // Check email format + if (!Post::val('email_address') || !Flyspray::check_email(Post::val('email_address'))) + { + Flyspray::show_error(L('novalidemail')); + break; + } + + # current CleanFS template skips oldpass input requirement for admin accounts: if someone is able to catch an admin session he could simply create another admin acc for example. + #if ( (!$user->perms('is_admin') || $user->id == Post::val('user_id')) && !Post::val('oldpass') + if ( !$user->perms('is_admin') && !Post::val('oldpass') && (Post::val('changepass') || Post::val('confirmpass')) ) { + Flyspray::show_error(L('nooldpass')); + break; + } + + if ($user->infos['oauth_uid'] && Post::val('changepass')) { + Flyspray::show_error(sprintf(L('oauthreqpass'), ucfirst($uesr->infos['oauth_provider']))); + break; + } + + if (Post::val('changepass')) { + if ($fs->prefs['repeat_password'] && Post::val('changepass') != Post::val('confirmpass')) { + Flyspray::show_error(L('passnomatch')); + break; + } + if (Post::val('oldpass')) { + $sql = $db->query('SELECT user_pass FROM {users} WHERE user_id = ?', array(Post::val('user_id'))); + $oldpass = $db->fetchRow($sql); + + $pwtest=false; + if(strlen($oldpass['user_pass'])==32){ + $pwtest=hash_equals($oldpass['user_pass'], md5(Post::val('oldpass'))); + }elseif(strlen($oldpass['user_pass'])==40){ + $pwtest=hash_equals($oldpass['user_pass'], sha1(Post::val('oldpass'))); + }elseif(strlen($oldpass['user_pass'])==128){ + $pwtest=hash_equals($oldpass['user_pass'], hash('sha512',Post::val('oldpass'))); + }else{ + $pwtest=password_verify(Post::val('oldpass'), $oldpass['user_pass']); + } + + if (!$pwtest){ + Flyspray::show_error(L('oldpasswrong')); + break; + } + } + $new_hash = Flyspray::cryptPassword(Post::val('changepass')); + $db->query('UPDATE {users} SET user_pass = ? WHERE user_id = ?', + array($new_hash, Post::val('user_id'))); + + // If the user is changing their password, better update their cookie hash + if ($user->id == Post::val('user_id')) { + Flyspray::setCookie('flyspray_passhash', + crypt($new_hash, $conf['general']['cookiesalt']), time()+3600*24*30,null,null,null,true); + } + } + $jabId = Post::val('jabber_id'); + if (!empty($jabId) && Post::val('old_jabber_id') != $jabId) { + Notifications::JabberRequestAuth(Post::val('jabber_id')); + } + + $db->query('UPDATE {users} + SET real_name = ?, email_address = ?, notify_own = ?, + jabber_id = ?, notify_type = ?, + dateformat = ?, dateformat_extended = ?, + tasks_perpage = ?, time_zone = ?, lang_code = ?, + hide_my_email = ?, notify_online = ? + WHERE user_id = ?', + array(Post::val('real_name'), Post::val('email_address'), Post::num('notify_own', 0), + Post::val('jabber_id', ''), Post::num('notify_type'), + Post::val('dateformat', 0), Post::val('dateformat_extended', 0), + Post::num('tasks_perpage'), Post::num('time_zone'), Post::val('lang_code', 'en'), + Post::num('hide_my_email', 0), Post::num('notify_online', 0), Post::num('user_id'))); + + # 20150307 peterdd: Now we must reload translations, because the user maybe changed his language preferences! + # first reload user info + $user=new User($user->id); + load_translations(); + + $profile_image = 'profile_image'; + + if(isset($_FILES[$profile_image])) { + if(!empty($_FILES[$profile_image]['name'])) { + $allowed = array('jpg', 'jpeg', 'gif', 'png'); + + $image_name = $_FILES[$profile_image]['name']; + $explode = explode('.', $image_name); + $image_extn = strtolower(end($explode)); + $image_temp = $_FILES[$profile_image]['tmp_name']; + + if(in_array($image_extn, $allowed)) { + $sql = $db->query('SELECT profile_image FROM {users} WHERE user_id = ?', array(Post::val('user_id'))); + $avatar_oldname = $db->fetchRow($sql); + + if (is_file(BASEDIR.'/avatars/'.$avatar_oldname['profile_image'])) + unlink(BASEDIR.'/avatars/'.$avatar_oldname['profile_image']); + + $avatar_name = substr(md5(time()), 0, 10).'.'.$image_extn; + $image_path = BASEDIR.'/avatars/'.$avatar_name; + move_uploaded_file($image_temp, $image_path); + resizeImage($avatar_name, $fs->prefs['max_avatar_size'], $fs->prefs['max_avatar_size']); + $db->query('UPDATE {users} SET profile_image = ? WHERE user_id = ?', + array($avatar_name, Post::num('user_id'))); + } else { + Flyspray::show_error(L('incorrectfiletype')); + break; + } + } + } + + endif; // end only admin or user himself can change + + if ($user->perms('is_admin')) { + if($user->id == (int)Post::val('user_id')) { + if (Post::val('account_enabled', 0) <= 0 || Post::val('old_global_id') != 1) { + Flyspray::show_error(L('nosuicide')); + break; + } + } else { + $db->query('UPDATE {users} SET account_enabled = ? WHERE user_id = ?', + array(Post::val('account_enabled', 0), Post::val('user_id'))); + $db->query('UPDATE {users_in_groups} SET group_id = ? + WHERE group_id = ? AND user_id = ?', + array(Post::val('group_in'), Post::val('old_global_id'), Post::val('user_id'))); + } + } + + endif; // end non project group changes + + if ($user->perms('manage_project') && !is_null(Post::val('project_group_in')) && Post::val('project_group_in') != Post::val('old_group_id')) { + $db->query('DELETE FROM {users_in_groups} WHERE group_id = ? AND user_id = ?', + array(Post::val('old_group_id'), Post::val('user_id'))); + if (Post::val('project_group_in')) { + $db->query('INSERT INTO {users_in_groups} (group_id, user_id) VALUES(?, ?)', + array(Post::val('project_group_in'), Post::val('user_id'))); + } + } + + $_SESSION['SUCCESS'] = L('userupdated'); + if ($action === 'myprofile.edituser') { + Flyspray::redirect(createURL('myprofile')); + } elseif ($action === 'admin.edituser' && Post::val('area') === 'users') { + Flyspray::redirect(createURL('edituser', Post::val('user_id'))); + } else { + Flyspray::redirect(createURL('user', Post::val('user_id'))); + } + break; + // ################## + // approving a new user registration + // ################## + case 'approve.user': + if($user->perms('is_admin')) { + $db->query('UPDATE {users} SET account_enabled = ? WHERE user_id = ?', + array(1, Post::val('user_id'))); + + $db->query('UPDATE {admin_requests} + SET resolved_by = ?, time_resolved = ? + WHERE submitted_by = ? AND request_type = ?', + array($user->id, time(), Post::val('user_id'), 3)); + // Missing event constant, can't log yet... + // Missing notification constant, can't notify yet... + // Notification constant added, write the code for sending that message... + + } + break; + // ################## + // updating a group definition + // ################## + case 'pm.editgroup': + case 'admin.editgroup': + if (!$user->perms('manage_project')) { + break; + } + + if (!Post::val('group_name')) { + Flyspray::show_error(L('groupanddesc')); + break; + } + + $cols = array('group_name', 'group_desc'); + + // Add a user to a group + if ($uid = Post::val('uid')) { + $uids = preg_split('/[,;]+/', $uid, -1, PREG_SPLIT_NO_EMPTY); + foreach ($uids as $uid) { + $uid = Flyspray::usernameToId($uid); + if (!$uid) { + continue; + } + + // If user is already a member of one of the project's groups, **move** (not add) him to the new group + $sql = $db->query('SELECT g.group_id + FROM {users_in_groups} uig, {groups} g + WHERE g.group_id = uig.group_id AND uig.user_id = ? AND project_id = ?', + array($uid, $proj->id)); + if ($db->countRows($sql)) { + $oldid = $db->fetchOne($sql); + $db->query('UPDATE {users_in_groups} SET group_id = ? WHERE user_id = ? AND group_id = ?', + array(Post::val('group_id'), $uid, $oldid)); + } else { + $db->query('INSERT INTO {users_in_groups} (group_id, user_id) VALUES(?, ?)', + array(Post::val('group_id'), $uid)); + } + } + } + + if (Post::val('delete_group') && Post::val('group_id') != '1') { + $db->query('DELETE FROM {groups} WHERE group_id = ?', Post::val('group_id')); + + if (Post::val('move_to')) { + $db->query('UPDATE {users_in_groups} SET group_id = ? WHERE group_id = ?', + array(Post::val('move_to'), Post::val('group_id'))); + } + + $_SESSION['SUCCESS'] = L('groupupdated'); + Flyspray::redirect(createURL( (($proj->id) ? 'pm' : 'admin'), 'groups', $proj->id)); + } + // Allow all groups to update permissions except for global Admin + if (Post::val('group_id') != '1') { + $cols = array_merge($cols, + array('manage_project', 'view_tasks', 'edit_own_comments', + 'open_new_tasks', 'modify_own_tasks', 'modify_all_tasks', + 'view_comments', 'add_comments', 'edit_comments', 'delete_comments', + 'create_attachments', 'delete_attachments', 'show_as_assignees', + 'view_history', 'close_own_tasks', 'close_other_tasks', 'edit_assignments', + 'assign_to_self', 'assign_others_to_self', 'add_to_assignees', 'view_reports', + 'add_votes', 'group_open', 'view_estimated_effort', 'track_effort', + 'view_current_effort_done', 'add_multiple_tasks', 'view_roadmap', + 'view_own_tasks', 'view_groups_tasks')); + } + + $args = array_map('Post_to0', $cols); + $args[] = Post::val('group_id'); + $args[] = $proj->id; + + $db->query("UPDATE {groups} + SET ".join('=?,', $cols)."=? + WHERE group_id = ? AND project_id = ?", $args); + + $_SESSION['SUCCESS'] = L('groupupdated'); + break; + + // ################## + // updating a list + // ################## + case 'update_list': + if (!$user->perms('manage_project') || !isset($list_table_name)) { + break; + } + + $listnames = Post::val('list_name'); + $listposition = Post::val('list_position'); + $listshow = Post::val('show_in_list'); + $listdelete = Post::val('delete'); + if($lt=='tag'){ + $listclass = Post::val('list_class'); + } + foreach ($listnames as $id => $listname) { + if ($listname != '') { + if (!isset($listshow[$id])) { + $listshow[$id] = 0; + } + + $check = $db->query("SELECT COUNT(*) + FROM $list_table_name + WHERE (project_id = 0 OR project_id = ?) + AND $list_column_name = ? + AND $list_id <> ?", + array($proj->id, $listnames[$id], $id) + ); + $itemexists = $db->fetchOne($check); + + if ($itemexists) { + Flyspray::show_error(sprintf(L('itemexists'), $listnames[$id])); + return; + } + + if($lt=='tag'){ + $update = $db->query("UPDATE $list_table_name + SET $list_column_name=?, list_position=?, show_in_list=?, class=? + WHERE $list_id=? AND project_id=?", + array($listnames[$id], intval($listposition[$id]), intval($listshow[$id]), $listclass[$id], $id, $proj->id) + ); + } else{ + $update = $db->query("UPDATE $list_table_name + SET $list_column_name=?, list_position=?, show_in_list=? + WHERE $list_id=? AND project_id=?", + array($listnames[$id], intval($listposition[$id]), intval($listshow[$id]), $id, $proj->id) + ); + } + } else { + Flyspray::show_error(L('fieldsmissing')); + } + } + + if (is_array($listdelete) && count($listdelete)) { + $deleteids = "$list_id = " . join(" OR $list_id =", array_map('intval', array_keys($listdelete))); + $db->query("DELETE FROM $list_table_name WHERE project_id = ? AND ($deleteids)", array($proj->id)); + } + + $_SESSION['SUCCESS'] = L('listupdated'); + break; + + // ################## + // adding a list item + // ################## + case 'pm.add_to_list': + case 'admin.add_to_list': + if (!$user->perms('manage_project') || !isset($list_table_name)) { + break; + } + + if (!Post::val('list_name')) { + Flyspray::show_error(L('fillallfields')); + break; + } + + $position = Post::num('list_position'); + if (!$position) { + $position = intval($db->fetchOne($db->query("SELECT max(list_position)+1 + FROM $list_table_name + WHERE project_id = ?", + array($proj->id)))); + } + + $check = $db->query("SELECT COUNT(*) + FROM $list_table_name + WHERE (project_id = 0 OR project_id = ?) + AND $list_column_name = ?", + array($proj->id, Post::val('list_name'))); + $itemexists = $db->fetchOne($check); + + if ($itemexists) { + Flyspray::show_error(sprintf(L('itemexists'), Post::val('list_name'))); + return; + } + + $db->query("INSERT INTO $list_table_name + (project_id, $list_column_name, list_position, show_in_list) + VALUES (?, ?, ?, ?)", + array($proj->id, Post::val('list_name'), $position, '1')); + + $_SESSION['SUCCESS'] = L('listitemadded'); + break; + + // ################## + // updating the version list + // ################## + case 'update_version_list': + if (!$user->perms('manage_project') || !isset($list_table_name)) { + break; + } + + $listnames = Post::val('list_name'); + $listposition = Post::val('list_position'); + $listshow = Post::val('show_in_list'); + $listtense = Post::val('version_tense'); + $listdelete = Post::val('delete'); + + foreach ($listnames as $id => $listname) { + if (is_numeric($listposition[$id]) && $listnames[$id] != '') { + if (!isset($listshow[$id])) { + $listshow[$id] = 0; + } + + $check = $db->query("SELECT COUNT(*) + FROM $list_table_name + WHERE (project_id = 0 OR project_id = ?) + AND $list_column_name = ? + AND $list_id <> ?", + array($proj->id, $listnames[$id], $id)); + $itemexists = $db->fetchOne($check); + + if ($itemexists) { + Flyspray::show_error(sprintf(L('itemexists'), $listnames[$id])); + return; + } + + $update = $db->query("UPDATE $list_table_name + SET $list_column_name = ?, list_position = ?, + show_in_list = ?, version_tense = ? + WHERE $list_id = ? AND project_id = ?", + array($listnames[$id], intval($listposition[$id]), + intval($listshow[$id]), intval($listtense[$id]), $id, $proj->id)); + } else { + Flyspray::show_error(L('fieldsmissing')); + } + } + + if (is_array($listdelete) && count($listdelete)) { + $deleteids = "$list_id = " . join(" OR $list_id =", array_map('intval', array_keys($listdelete))); + $db->query("DELETE FROM $list_table_name WHERE project_id = ? AND ($deleteids)", array($proj->id)); + } + + $_SESSION['SUCCESS'] = L('listupdated'); + break; + + // ################## + // adding a version list item + // ################## + case 'pm.add_to_version_list': + case 'admin.add_to_version_list': + if (!$user->perms('manage_project') || !isset($list_table_name)) { + break; + } + + if (!Post::val('list_name')) { + Flyspray::show_error(L('fillallfields')); + break; + } + + $position = Post::num('list_position'); + if (!$position) { + $position = $db->fetchOne($db->query("SELECT max(list_position)+1 + FROM $list_table_name + WHERE project_id = ?", + array($proj->id))); + } + + $check = $db->query("SELECT COUNT(*) + FROM $list_table_name + WHERE (project_id = 0 OR project_id = ?) + AND $list_column_name = ?", + array($proj->id, Post::val('list_name'))); + $itemexists = $db->fetchOne($check); + + if ($itemexists) { + Flyspray::show_error(sprintf(L('itemexists'), Post::val('list_name'))); + return; + } + + $db->query("INSERT INTO $list_table_name + (project_id, $list_column_name, list_position, show_in_list, version_tense) + VALUES (?, ?, ?, ?, ?)", + array($proj->id, Post::val('list_name'), + intval($position), '1', Post::val('version_tense'))); + + $_SESSION['SUCCESS'] = L('listitemadded'); + break; + + // ################## + // updating the category list + // ################## + case 'update_category': + if (!$user->perms('manage_project')) { + break; + } + + $listnames = Post::val('list_name'); + $listshow = Post::val('show_in_list'); + $listdelete = Post::val('delete'); + $listlft = Post::val('lft'); + $listrgt = Post::val('rgt'); + $listowners = Post::val('category_owner'); + + foreach ($listnames as $id => $listname) { + if ($listname != '') { + if (!isset($listshow[$id])) { + $listshow[$id] = 0; + } + + // Check for duplicates on the same sub-level under same parent category. + // First, we'll have to find the right parent for the current category. + $sql = $db->query('SELECT * + FROM {list_category} + WHERE project_id = ? AND lft < ? and rgt > ? + AND lft = (SELECT MAX(lft) FROM {list_category} WHERE lft < ? and rgt > ?)', + array($proj->id, intval($listlft[$id]), intval($listrgt[$id]), intval($listlft[$id]), intval($listrgt[$id]))); + + $parent = $db->fetchRow($sql); + + $check = $db->query('SELECT COUNT(*) + FROM {list_category} c + WHERE project_id = ? AND category_name = ? AND lft > ? AND rgt < ? + AND category_id <> ? + AND NOT EXISTS (SELECT * + FROM {list_category} + WHERE project_id = ? + AND lft > ? AND rgt < ? + AND lft < c.lft AND rgt > c.rgt)', + array($proj->id, $listname, $parent['lft'], $parent['rgt'], intval($id), $proj->id, $parent['lft'], $parent['rgt'])); + $itemexists = $db->fetchOne($check); + + // echo "<pre>" . $parent['category_name'] . "," . $listname . ", " . intval($id) . ", " . intval($listlft[$id]) . ", " . intval($listrgt[$id]) . ", " . $itemexists ."</pre>"; + + if ($itemexists) { + Flyspray::show_error(sprintf(L('categoryitemexists'), $listname, $parent['category_name'])); + return; + } + + + $update = $db->query('UPDATE {list_category} + SET category_name = ?, + show_in_list = ?, category_owner = ?, + lft = ?, rgt = ? + WHERE category_id = ? AND project_id = ?', + array($listname, intval($listshow[$id]), Flyspray::UserNameToId($listowners[$id]), intval($listlft[$id]), intval($listrgt[$id]), intval($id), $proj->id)); + // Correct visibility for sub categories + if ($listshow[$id] == 0) { + foreach ($listnames as $key => $value) { + if ($listlft[$key] > $listlft[$id] && $listrgt[$key] < $listrgt[$id]) { + $listshow[$key] = 0; + } + } + } + } else { + Flyspray::show_error(L('fieldsmissing')); + } + } + + if (is_array($listdelete) && count($listdelete)) { + $deleteids = "$list_id = " . join(" OR $list_id =", array_map('intval', array_keys($listdelete))); + $db->query("DELETE FROM {list_category} WHERE project_id = ? AND ($deleteids)", array($proj->id)); + } + + $_SESSION['SUCCESS'] = L('listupdated'); + break; + + // ################## + // adding a category list item + // ################## + case 'pm.add_category': + case 'admin.add_category': + if (!$user->perms('manage_project')) { + break; + } + + if (!Post::val('list_name')) { + Flyspray::show_error(L('fillallfields')); + break; + } + + // Get right value of last node + // Need also left value of parent for duplicate check and category name for errormessage. + $sql = $db->query('SELECT rgt, lft, category_name FROM {list_category} WHERE category_id = ?', array(Post::val('parent_id', -1))); + $parent = $db->fetchRow($sql); + $right = $parent['rgt']; + $left = $parent['lft']; + + // echo "<pre>Parent: " . Post::val('parent_id', -1) . ", left: $left, right: $right</pre>"; + + // If parent has subcategories, check for possible duplicates + // on the same sub-level and under the same parent. + if ($left + 1 != $right) { + $check = $db->query('SELECT COUNT(*) + FROM {list_category} c + WHERE project_id = ? AND category_name = ? AND lft > ? AND rgt < ? + AND NOT EXISTS (SELECT * + FROM {list_category} + WHERE project_id = ? + AND lft > ? AND rgt < ? + AND lft < c.lft AND rgt > c.rgt)', + array($proj->id, Post::val('list_name'), $left, $right, $proj->id, $left, $right)); + $itemexists = $db->fetchOne($check); + + if ($itemexists) { + Flyspray::show_error(sprintf(L('categoryitemexists'), Post::val('list_name'), $parent['category_name'])); + return; + } + } + + $db->query('UPDATE {list_category} SET rgt=rgt+2 WHERE rgt >= ? AND project_id = ?', array($right, $proj->id)); + $db->query('UPDATE {list_category} SET lft=lft+2 WHERE lft >= ? AND project_id = ?', array($right, $proj->id)); + + $db->query("INSERT INTO {list_category} + ( project_id, category_name, show_in_list, category_owner, lft, rgt ) + VALUES (?, ?, 1, ?, ?, ?)", + array($proj->id, Post::val('list_name'), + Post::val('category_owner', 0) == '' ? '0' : Flyspray::usernameToId(Post::val('category_owner', 0)), $right, $right+1)); + + $_SESSION['SUCCESS'] = L('listitemadded'); + break; + + // ################## + // adding a related task entry + // ################## + case 'details.add_related': + if (!$user->can_edit_task($task)) { + Flyspray::show_error(L('nopermission'));//TODO: create a better error message + break; + } + + // if the user has not the permission to view all tasks, check if the task + // is in tasks allowed to see, otherwise tell that the task does not exist. + if (!$user->perms('view_tasks')) { + $taskcheck = Flyspray::getTaskDetails(Post::val('related_task')); + if (!$user->can_view_task($taskcheck)) { + Flyspray::show_error(L('relatedinvalid')); + break; + } + } + + $sql = $db->query('SELECT project_id + FROM {tasks} + WHERE task_id = ?', + array(Post::val('related_task'))); + if (!$db->countRows($sql)) { + Flyspray::show_error(L('relatedinvalid')); + break; + } + + $sql = $db->query("SELECT related_id + FROM {related} + WHERE this_task = ? AND related_task = ? + OR + related_task = ? AND this_task = ?", + array($task['task_id'], Post::val('related_task'), + $task['task_id'], Post::val('related_task'))); + + if ($db->countRows($sql)) { + Flyspray::show_error(L('relatederror')); + break; + } + + $db->query("INSERT INTO {related} (this_task, related_task) VALUES(?,?)", + array($task['task_id'], Post::val('related_task'))); + + Flyspray::logEvent($task['task_id'], 11, Post::val('related_task')); + Flyspray::logEvent(Post::val('related_task'), 15, $task['task_id']); + $notify->create(NOTIFY_REL_ADDED, $task['task_id'], Post::val('related_task'), null, NOTIFY_BOTH, $proj->prefs['lang_code']); + + $_SESSION['SUCCESS'] = L('relatedaddedmsg'); + break; + + // ################## + // Removing a related task entry + // ################## + case 'remove_related': + if (!$user->can_edit_task($task)) { + Flyspray::show_error(L('nopermission'));//TODO: create a better error message + break; + } + if (!is_array(Post::val('related_id'))) { + Flyspray::show_error(L('formnotcomplete')); + break; + } + + foreach (Post::val('related_id') as $related) { + $sql = $db->query('SELECT this_task, related_task FROM {related} WHERE related_id = ?', + array($related)); + $db->query('DELETE FROM {related} WHERE related_id = ? AND (this_task = ? OR related_task = ?)', + array($related, $task['task_id'], $task['task_id'])); + if ($db->affectedRows()) { + $related_task = $db->fetchRow($sql); + $related_task = ($related_task['this_task'] == $task['task_id']) ? $related_task['related_task'] : $task['task_id']; + Flyspray::logEvent($task['task_id'], 12, $related_task); + Flyspray::logEvent($related_task, 16, $task['task_id']); + $_SESSION['SUCCESS'] = L('relatedremoved'); + } + } + + break; + + // ################## + // adding a user to the notification list + // ################## + case 'details.add_notification': + if (Req::val('user_id')) { + $userId = Req::val('user_id'); + } else { + $userId = Flyspray::usernameToId(Req::val('user_name')); + } + if (!Backend::add_notification($userId, Req::val('ids'))) { + Flyspray::show_error(L('couldnotaddusernotif')); + break; + } + + // TODO: Log event in a later version. + + $_SESSION['SUCCESS'] = L('notifyadded'); + Flyspray::redirect(createURL('details', $task['task_id']).'#notify'); + break; + + // ################## + // removing a notification entry + // ################## + case 'remove_notification': + Backend::remove_notification(Req::val('user_id'), Req::val('ids')); + + // TODO: Log event in a later version. + + $_SESSION['SUCCESS'] = L('notifyremoved'); + # if on details page we should redirect to details with a GET + # but what if the request comes from another page (like myprofile for instance maybe in future) + Flyspray::redirect(createURL('details', $task['task_id']).'#notify'); + break; + + // ################## + // editing a comment + // ################## + case 'editcomment': + if (!($user->perms('edit_comments') || $user->perms('edit_own_comments'))) { + break; + } + + $where = ''; + + $comment_text=Post::val('comment_text'); + $previous_text=Post::val('previous_text'); + + # dokuwiki syntax plugin filters on output + if($conf['general']['syntax_plugin'] != 'dokuwiki'){ + $purifierconfig = HTMLPurifier_Config::createDefault(); + $purifier = new HTMLPurifier($purifierconfig); + $comment_text = $purifier->purify($comment_text); + $previous_text= $purifier->purify($comment_text); + } + + $params = array($comment_text, time(), Post::val('comment_id'), $task['task_id']); + + if ($user->perms('edit_own_comments') && !$user->perms('edit_comments')) { + $where = ' AND user_id = ?'; + array_push($params, $user->id); + } + + $db->query("UPDATE {comments} + SET comment_text = ?, last_edited_time = ? + WHERE comment_id = ? AND task_id = ? $where", $params); + $db->query("DELETE FROM {cache} WHERE topic = ? AND type = ?", array(Post::val('comment_id'), 'comm')); + + Flyspray::logEvent($task['task_id'], 5, $comment_text, $previous_text, Post::val('comment_id')); + + Backend::upload_files($task['task_id'], Post::val('comment_id')); + Backend::delete_files(Post::val('delete_att')); + Backend::upload_links($task['task_id'], Post::val('comment_id')); + Backend::delete_links(Post::val('delete_link')); + + $_SESSION['SUCCESS'] = L('editcommentsaved'); + break; + + // ################## + // deleting a comment + // ################## + case 'details.deletecomment': + if (!$user->perms('delete_comments')) { + break; + } + + $result = $db->query('SELECT task_id, comment_text, user_id, date_added + FROM {comments} + WHERE comment_id = ?', + array(Get::val('comment_id'))); + $comment = $db->fetchRow($result); + + // Check for files attached to this comment + $check_attachments = $db->query('SELECT * + FROM {attachments} + WHERE comment_id = ?', + array(Req::val('comment_id'))); + + if ($db->countRows($check_attachments) && !$user->perms('delete_attachments')) { + Flyspray::show_error(L('commentattachperms')); + break; + } + + $db->query("DELETE FROM {comments} WHERE comment_id = ? AND task_id = ?", + array(Req::val('comment_id'), $task['task_id'])); + + if ($db->affectedRows()) { + Flyspray::logEvent($task['task_id'], 6, $comment['user_id'], + $comment['comment_text'], '', $comment['date_added']); + } + + while ($attachment = $db->fetchRow($check_attachments)) { + $db->query("DELETE from {attachments} WHERE attachment_id = ?", + array($attachment['attachment_id'])); + + @unlink(BASEDIR .'/attachments/' . $attachment['file_name']); + + Flyspray::logEvent($attachment['task_id'], 8, $attachment['orig_name']); + } + + $_SESSION['SUCCESS'] = L('commentdeletedmsg'); + break; + + // ################## + // adding a reminder + // ################## + case 'details.addreminder': + $how_often = Post::val('timeamount1', 1) * Post::val('timetype1'); + $start_time = Flyspray::strtotime(Post::val('timeamount2', 0)); + + $userId = Flyspray::usernameToId(Post::val('to_user_id')); + if (!Backend::add_reminder($task['task_id'], Post::val('reminder_message'), $how_often, $start_time, $userId)) { + Flyspray::show_error(L('usernotexist')); + break; + } + + // TODO: Log event in a later version. + + $_SESSION['SUCCESS'] = L('reminderaddedmsg'); + break; + + // ################## + // removing a reminder + // ################## + case 'deletereminder': + if (!$user->perms('manage_project') || !is_array(Post::val('reminder_id'))) { + break; + } + + foreach (Post::val('reminder_id') as $reminder_id) { + $sql = $db->query('SELECT to_user_id FROM {reminders} WHERE reminder_id = ?', + array($reminder_id)); + $reminder = $db->fetchOne($sql); + $db->query('DELETE FROM {reminders} WHERE reminder_id = ? AND task_id = ?', + array($reminder_id, $task['task_id'])); + if ($db && $db->affectedRows()) { + Flyspray::logEvent($task['task_id'], 18, $reminder); + } + } + + $_SESSION['SUCCESS'] = L('reminderdeletedmsg'); + break; + + // ################## + // change a bunch of users' groups + // ################## + case 'movetogroup': + // Check that both groups belong to the same project + $sql = $db->query('SELECT project_id FROM {groups} WHERE group_id = ? OR group_id = ?', + array(Post::val('switch_to_group'), Post::val('old_group'))); + $old_pr = $db->fetchOne($sql); + $new_pr = $db->fetchOne($sql); + if ($proj->id != $old_pr || ($new_pr && $new_pr != $proj->id)) { + break; + } + + if (!$user->perms('manage_project', $old_pr) || !is_array(Post::val('users'))) { + break; + } + + foreach (Post::val('users') as $user_id => $val) { + if($user->id!=$user_id || $proj->id!=0){ + if (Post::val('switch_to_group') == '0') { + $db->query('DELETE FROM {users_in_groups} WHERE user_id=? AND group_id=?', + array($user_id, Post::val('old_group')) + ); + } else { + # special case: user exists in multiple global groups (shouldn't, but happened) + # avoids duplicate entry error + if($old_pr==0){ + $sql = $db->query('SELECT group_id FROM {users_in_groups} WHERE user_id = ? AND group_id = ?', + array($user_id, Post::val('switch_to_group')) + ); + $uigexists = $db->fetchOne($sql); + if($uigexists > 0){ + $db->query('DELETE FROM {users_in_groups} WHERE user_id=? AND group_id=?', + array($user_id, Post::val('old_group')) + ); + } + } + + $db->query('UPDATE {users_in_groups} SET group_id=? WHERE user_id=? AND group_id=?', + array(Post::val('switch_to_group'), $user_id, Post::val('old_group')) + ); + } + } else { + Flyspray::show_error(L('nosuicide')); + } + } + + // TODO: Log event in a later version. + + $_SESSION['SUCCESS'] = L('groupswitchupdated'); + break; + + // ################## + // taking ownership + // ################## + case 'takeownership': + Backend::assign_to_me($user->id, Req::val('ids')); + + // TODO: Log event in a later version. + + $_SESSION['SUCCESS'] = L('takenownershipmsg'); + break; + + // ################## + // add to assignees list + // ################## + case 'addtoassignees': + Backend::add_to_assignees($user->id, Req::val('ids')); + + // TODO: Log event in a later version. + + $_SESSION['SUCCESS'] = L('addedtoassignees'); + break; + + // ################## + // admin request + // ################## + case 'requestclose': + case 'requestreopen': + if ($action == 'requestclose') { + Flyspray::adminRequest(1, $proj->id, $task['task_id'], $user->id, Post::val('reason_given')); + Flyspray::logEvent($task['task_id'], 20, Post::val('reason_given')); + } elseif ($action == 'requestreopen') { + Flyspray::adminRequest(2, $proj->id, $task['task_id'], $user->id, Post::val('reason_given')); + Flyspray::logEvent($task['task_id'], 21, Post::val('reason_given')); + Backend::add_notification($user->id, $task['task_id']); + } + + // Now, get the project managers' details for this project + $sql = $db->query("SELECT u.user_id + FROM {users} u + LEFT JOIN {users_in_groups} uig ON u.user_id = uig.user_id + LEFT JOIN {groups} g ON uig.group_id = g.group_id + WHERE g.project_id = ? AND g.manage_project = '1'", + array($proj->id)); + + $pms = $db->fetchCol($sql); + if (count($pms)) { + // Call the functions to create the address arrays, and send notifications + $notify->create(NOTIFY_PM_REQUEST, $task['task_id'], null, $notify->specificAddresses($pms), NOTIFY_BOTH, $proj->prefs['lang_code']); + } + + $_SESSION['SUCCESS'] = L('adminrequestmade'); + break; + + // ################## + // denying a PM request + // ################## + case 'denypmreq': + $result = $db->query("SELECT task_id, project_id + FROM {admin_requests} + WHERE request_id = ?", + array(Req::val('req_id'))); + $req_details = $db->fetchRow($result); + + if (!$user->perms('manage_project', $req_details['project_id'])) { + break; + } + + // Mark the PM request as 'resolved' + $db->query("UPDATE {admin_requests} + SET resolved_by = ?, time_resolved = ?, deny_reason = ? + WHERE request_id = ?", + array($user->id, time(), Req::val('deny_reason'), Req::val('req_id'))); + + Flyspray::logEvent($req_details['task_id'], 28, Req::val('deny_reason')); + $notify->create(NOTIFY_PM_DENY_REQUEST, $req_details['task_id'], Req::val('deny_reason'), null, NOTIFY_BOTH, $proj->prefs['lang_code']); + + $_SESSION['SUCCESS'] = L('pmreqdeniedmsg'); + break; + + // ################## + // deny a new user request + // ################## + case 'denyuserreq': + if($user->perms('is_admin')) { + $db->query("UPDATE {admin_requests} + SET resolved_by = ?, time_resolved = ?, deny_reason = ? + WHERE request_id = ?", + array($user->id, time(), Req::val('deny_reason'), Req::val('req_id'))); + // Wrong event constant + Flyspray::logEvent(0, 28, Req::val('deny_reason'));//nee a new event number. need notification. fix smtp first + // Missing notification constant, can't notify yet... + $_SESSION['SUCCESS'] = "New user register request denied"; + } + break; + + // ################## + // adding a dependency + // ################## + case 'details.newdep': + if (!$user->can_edit_task($task)) { + Flyspray::show_error(L('nopermission'));//TODO: create a better error message + break; + } + + if (!Post::val('dep_task_id')) { + Flyspray::show_error(L('formnotcomplete')); + break; + } + + // TODO: do the checks in some other order. Think about possibility + // to combine many of the checks used to to see if a task exists, + // if it's something user is allowed to know about etc to just one + // function taking the necessary arguments and could be used in + // several other places too. + + // if the user has not the permission to view all tasks, check if the task + // is in tasks allowed to see, otherwise tell that the task does not exist. + if (!$user->perms('view_tasks')) { + $taskcheck = Flyspray::getTaskDetails(Post::val('dep_task_id')); + if (!$user->can_view_task($taskcheck)) { + Flyspray::show_error(L('dependaddfailed')); + break; + } + } + + // First check that the user hasn't tried to add this twice + $sql1 = $db->query('SELECT COUNT(*) FROM {dependencies} + WHERE task_id = ? AND dep_task_id = ?', + array($task['task_id'], Post::val('dep_task_id'))); + + // or that they are trying to reverse-depend the same task, creating a mutual-block + $sql2 = $db->query('SELECT COUNT(*) FROM {dependencies} + WHERE task_id = ? AND dep_task_id = ?', + array(Post::val('dep_task_id'), $task['task_id'])); + + // Check that the dependency actually exists! + $sql3 = $db->query('SELECT COUNT(*) FROM {tasks} WHERE task_id = ?', + array(Post::val('dep_task_id'))); + + if ($db->fetchOne($sql1) || $db->fetchOne($sql2) || !$db->fetchOne($sql3) + // Check that the user hasn't tried to add the same task as a dependency + || Post::val('task_id') == Post::val('dep_task_id')) + { + Flyspray::show_error(L('dependaddfailed')); + break; + } + $notify->create(NOTIFY_DEP_ADDED, $task['task_id'], Post::val('dep_task_id'), null, NOTIFY_BOTH, $proj->prefs['lang_code']); + $notify->create(NOTIFY_REV_DEP, Post::val('dep_task_id'), $task['task_id'], null, NOTIFY_BOTH, $proj->prefs['lang_code']); + + // Log this event to the task history, both ways + Flyspray::logEvent($task['task_id'], 22, Post::val('dep_task_id')); + Flyspray::logEvent(Post::val('dep_task_id'), 23, $task['task_id']); + + $db->query('INSERT INTO {dependencies} (task_id, dep_task_id) + VALUES (?,?)', + array($task['task_id'], Post::val('dep_task_id'))); + + $_SESSION['SUCCESS'] = L('dependadded'); + break; + + // ################## + // removing a subtask + // ################## + case 'removesubtask': + + //check if the user has permissions to remove the subtask + if (!$user->can_edit_task($task)) { + Flyspray::show_error(L('nopermission'));//TODO: create a better error message + break; + } + + //set the subtask supertask_id to 0 removing parent child relationship + $db->query("UPDATE {tasks} SET supertask_id=0 WHERE task_id = ?", + array(Post::val('subtaskid'))); + + //write event log + Flyspray::logEvent(Get::val('task_id'), 33, Post::val('subtaskid')); + //post success message to the user + $_SESSION['SUCCESS'] = L('subtaskremovedmsg'); + //redirect the user back to the right task + Flyspray::redirect(createURL('details', Get::val('task_id'))); + break; + + // ################## + // removing a dependency + // ################## + case 'removedep': + if (!$user->can_edit_task($task)) { + Flyspray::show_error(L('nopermission'));//TODO: create a better error message + break; + } + + $result = $db->query('SELECT * FROM {dependencies} + WHERE depend_id = ?', + array(Post::val('depend_id'))); + $dep_info = $db->fetchRow($result); + + $db->query('DELETE FROM {dependencies} WHERE depend_id = ? AND task_id = ?', + array(Post::val('depend_id'), $task['task_id'])); + + if ($db->affectedRows()) { + $notify->create(NOTIFY_DEP_REMOVED, $dep_info['task_id'], $dep_info['dep_task_id'], null, NOTIFY_BOTH, $proj->prefs['lang_code']); + $notify->create(NOTIFY_REV_DEP_REMOVED, $dep_info['dep_task_id'], $dep_info['task_id'], null, NOTIFY_BOTH, $proj->prefs['lang_code']); + + Flyspray::logEvent($dep_info['task_id'], 24, $dep_info['dep_task_id']); + Flyspray::logEvent($dep_info['dep_task_id'], 25, $dep_info['task_id']); + + $_SESSION['SUCCESS'] = L('depremovedmsg'); + } else { + Flyspray::show_error(L('erroronform')); + } + + //redirect the user back to the right task + Flyspray::redirect(createURL('details', Post::val('return_task_id'))); + break; + + // ################## + // user requesting a password change + // ################## + case 'lostpw.sendmagic': + // Check that the username exists + $sql = $db->query('SELECT * FROM {users} WHERE user_name = ?', + array(Post::val('user_name'))); + + // If the username doesn't exist, throw an error + if (!$db->countRows($sql)) { + Flyspray::show_error(L('usernotexist')); + break; + } + + $user_details = $db->fetchRow($sql); + + if ($user_details['oauth_provider']) { + Flyspray::show_error(sprintf(L('oauthreqpass'), ucfirst($user_details['oauth_provider']))); + Flyspray::redirect($baseurl); + break; + } + + //no microtime(), time,even with microseconds is predictable ;-) + $magic_url = md5(function_exists('openssl_random_pseudo_bytes') ? + openssl_random_pseudo_bytes(32) : + uniqid(mt_rand(), true)); + + + // Insert the random "magic url" into the user's profile + $db->query('UPDATE {users} + SET magic_url = ? + WHERE user_id = ?', + array($magic_url, $user_details['user_id'])); + + if(count($user_details)) { + $notify->create(NOTIFY_PW_CHANGE, null, array($baseurl, $magic_url), $notify->specificAddresses(array($user_details['user_id']), NOTIFY_EMAIL)); + } + + // TODO: Log event in a later version. + + $_SESSION['SUCCESS'] = L('magicurlsent'); + break; + + // ################## + // Change the user's password + // ################## + case 'lostpw.chpass': + // Check that the user submitted both the fields, and they are the same + if (!Post::val('pass1') || strlen(trim(Post::val('magic_url'))) !== 32) { + Flyspray::show_error(L('erroronform')); + break; + } + + if ($fs->prefs['repeat_password'] && Post::val('pass1') != Post::val('pass2')) { + Flyspray::show_error(L('passnomatch')); + break; + } + + $new_pass_hash = Flyspray::cryptPassword(Post::val('pass1')); + $db->query("UPDATE {users} SET user_pass = ?, magic_url = '' + WHERE magic_url = ?", + array($new_pass_hash, Post::val('magic_url'))); + + // TODO: Log event in a later version. + + $_SESSION['SUCCESS'] = L('passchanged'); + Flyspray::redirect($baseurl); + break; + + // ################## + // making a task private + // ################## + case 'makeprivate': + // TODO: Have to think about this one a bit more. Are project manager + // rights really needed for making a task a private? Are there some + // other conditions that would permit it? Also making it back to public. + if (!$user->perms('manage_project')) { + break; + } + + $db->query('UPDATE {tasks} + SET mark_private = 1 + WHERE task_id = ?', array($task['task_id'])); + + Flyspray::logEvent($task['task_id'], 3, 1, 0, 'mark_private'); + + $_SESSION['SUCCESS'] = L('taskmadeprivatemsg'); + break; + + // ################## + // making a task public + // ################## + case 'makepublic': + if (!$user->perms('manage_project')) { + break; + } + + $db->query('UPDATE {tasks} + SET mark_private = 0 + WHERE task_id = ?', array($task['task_id'])); + + Flyspray::logEvent($task['task_id'], 3, 0, 1, 'mark_private'); + + $_SESSION['SUCCESS'] = L('taskmadepublicmsg'); + break; + + // ################## + // Adding a vote for a task + // ################## + case 'details.addvote': + if (Backend::add_vote($user->id, $task['task_id'])) { + $_SESSION['SUCCESS'] = L('voterecorded'); + } else { + Flyspray::show_error(L('votefailed')); + break; + } + // TODO: Log event in a later version. + break; + + + // ################## + // Removing a vote for a task + // ################## + # used to remove a vote from myprofile page + case 'removevote': + # peterdd: I found no details.removevote action in source, so details.removevote is not used, but was planned on the task details page or in the old blue theme? + case 'details.removevote': + if (Backend::remove_vote($user->id, $task['task_id'])) { + $_SESSION['SUCCESS'] = L('voteremoved'); + } else { + Flyspray::show_error(L('voteremovefailed')); + break; + } + // TODO: Log event in a later version, but also see if maybe done here Backend::remove_vote()... + break; + + + // ################## + // set supertask id + // ################## + case 'details.setparent': + if (!$user->can_edit_task($task)) { + Flyspray::show_error(L('nopermission'));//TODO: create a better error message + break; + } + + if (!Post::val('supertask_id')) { + Flyspray::show_error(L('formnotcomplete')); + break; + } + + // check that supertask_id is not same as task_id + // preventing it from referring to itself + if (Post::val('task_id') == Post::val('supertask_id')) { + Flyspray::show_error(L('selfsupertasknotallowed')); + break; + } + + // Check that the supertask_id looks like unsigned integer + if ( !preg_match("/^[1-9][0-9]{0,8}$/", Post::val('supertask_id')) ) { + Flyspray::show_error(L('invalidsupertaskid')); + break; + } + + $sql = $db->query('SELECT project_id FROM {tasks} WHERE task_id = ?', array(Post::val('supertask_id')) ); + // check that supertask_id is a valid task id + $parent = $db->fetchRow($sql); + if (!$parent) { + Flyspray::show_error(L('invalidsupertaskid')); + break; + } + + // if the user has not the permission to view all tasks, check if the task + // is in tasks allowed to see, otherwise tell that the task does not exist. + if (!$user->perms('view_tasks')) { + $taskcheck = Flyspray::getTaskDetails(Post::val('supertask_id')); + if (!$user->can_view_task($taskcheck)) { + Flyspray::show_error(L('invalidsupertaskid')); + break; + } + } + + // check to see that both tasks belong to the same project + if ($task['project_id'] != $parent['project_id']) { + Flyspray::show_error(L('musthavesameproject')); + break; + } + + // finally looks like all the checks are valid so update the supertask_id for the current task + $db->query('UPDATE {tasks} + SET supertask_id = ? + WHERE task_id = ?', + array(Post::val('supertask_id'),Post::val('task_id'))); + + // If task already had a different parent, then log removal too + if ($task['supertask_id']) { + Flyspray::logEvent($task['supertask_id'], 33, Post::val('task_id')); + Flyspray::logEvent(Post::val('task_id'), 35, $task['supertask_id']); + } + + // Log the events in the task history + Flyspray::logEvent(Post::val('supertask_id'), 32, Post::val('task_id')); + Flyspray::logEvent(Post::val('task_id'), 34, Post::val('supertask_id')); + + // set success message + $_SESSION['SUCCESS'] = L('supertaskmodified'); + + break; + case 'notifications.remove': + if(!isset($_POST['message_id'])) { + // Flyspray::show_error(L('summaryanddetails')); + break; + } + + if (!is_array($_POST['message_id'])) { + // Flyspray::show_error(L('summaryanddetails')); + break; + } + if (!count($_POST['message_id'])) { + // Nothing to do. + break; + } + + $validids = array(); + foreach ($_POST['message_id'] as $id) { + if (is_numeric($id)) { + if (settype($id, 'int') && $id > 0) { + $validids[] = $id; + } + } + } + + if (!count($validids)) { + // Nothing to do. + break; + } + + Notifications::NotificationsHaveBeenRead($validids); + break; + case 'task.bulkupdate': + # TODO check if the user has the right to do each action on each task id he send with the form! + # TODO check if tasks have open subtasks before closing + # TODO SQL Transactions with rollback function if something went wrong in the middle of bulk action + # disabled by default and if currently allowed only for admins until proper checks are done + if(isset($fs->prefs['massops']) && $fs->prefs['massops']==1 && $user->perms('is_admin')){ + + // TODO: Log events in a later version. + + if(Post::val('updateselectedtasks') == "true") { + //process quick actions + switch(Post::val('bulk_quick_action')) + { + case 'bulk_take_ownership': + Backend::assign_to_me(Post::val('user_id'),Post::val('ids')); + break; + case 'bulk_start_watching': + Backend::add_notification(Post::val('user_id'),Post::val('ids')); + break; + case 'bulk_stop_watching': + Backend::remove_notification(Post::val('user_id'),Post::val('ids')); + break; + } + + //Process the tasks. + $columns = array(); + $values = array(); + + //determine the tasks properties that have been modified. + if(!Post::val('bulk_status')==0){ + array_push($columns,'item_status'); + array_push($values, Post::val('bulk_status')); + } + if(!Post::val('bulk_percent_complete')==0){ + array_push($columns,'percent_complete'); + array_push($values, Post::val('bulk_percent_complete')); + } + if(!Post::val('bulk_task_type')==0){ + array_push($columns,'task_type'); + array_push($values, Post::val('bulk_task_type')); + } + if(!Post::val('bulk_category')==0){ + array_push($columns,'product_category'); + array_push($values, Post::val('bulk_category')); + } + if(!Post::val('bulk_os')==0){ + array_push($columns,'operating_system'); + array_push($values, Post::val('bulk_os')); + } + if(!Post::val('bulk_severity')==0){ + array_push($columns,'task_severity'); + array_push($values, Post::val('bulk_severity')); + } + if(!Post::val('bulk_priority')==0){ + array_push($columns,'task_priority'); + array_push($values, Post::val('bulk_priority')); + } + if(!Post::val('bulk_reportedver')==0){ + array_push($columns,'product_version'); + array_push($values, Post::val('bulk_reportedver')); + } + if(!Post::val('bulk_due_version')==0){ + array_push($columns,'closedby_version'); + array_push($values, Post::val('bulk_due_version')); + } + # TODO Does the user has similiar rights in current and target projects? + # TODO Does a task has subtasks? What happens to them? What if they are open/closed? + # But: Allowing task dependencies between tasks in different projects is a feature! + if(!Post::val('bulk_projects')==0){ + array_push($columns,'project_id'); + array_push($values, Post::val('bulk_projects')); + } + if(!is_null(Post::val('bulk_due_date'))){ + array_push($columns,'due_date'); + array_push($values, Flyspray::strtotime(Post::val('bulk_due_date'))); + } + + //only process if one of the task fields has been updated. + if(!array_count_values($columns)==0 && Post::val('ids')){ + //add the selected task id's to the query string + $task_ids = Post::val('ids'); + $valuesAndTasks = array_merge_recursive($values,$task_ids); + + //execute the database update on all selected queries + $update = $db->query("UPDATE {tasks} + SET ".join('=?, ', $columns)."=? + WHERE". substr(str_repeat(' task_id = ? OR ', count(Post::val('ids'))), 0, -3), $valuesAndTasks); + } + + //Set the assignments + if(Post::val('bulk_assignment')){ + // Delete the current assignees for the selected tasks + $db->query("DELETE FROM {assigned} WHERE". substr(str_repeat(' task_id = ? OR ', count(Post::val('ids'))), 0, -3),Post::val('ids')); + + // Convert assigned_to and store them in the 'assigned' table + foreach ((array)Post::val('ids') as $id){ + //iterate the users that are selected on the user list. + foreach ((array) Post::val('bulk_assignment') as $assignee){ + //if 'noone' has been selected then dont do the database update. + if(!$assignee == 0){ + //insert the task and user id's into the assigned table. + $db->query('INSERT INTO {assigned} + (task_id,user_id) + VALUES (?, ?)',array($id,$assignee)); + } + } + } + } + + // set success message + $_SESSION['SUCCESS'] = L('tasksupdated'); + break; + } + //bulk close + else { + if (!Post::val('resolution_reason')) { + Flyspray::show_error(L('noclosereason')); + break; + } + $task_ids = Post::val('ids'); + foreach($task_ids as $task_id) { + $task = Flyspray::getTaskDetails($task_id); + if (!$user->can_close_task($task)) { + continue; + } + + if ($task['is_closed']) { + continue; + } + + Backend::close_task($task_id, Post::val('resolution_reason'), Post::val('closure_comment', ''), Post::val('mark100', false)); + } + $_SESSION['SUCCESS'] = L('taskclosedmsg'); + break; + } + } # end if massopsenabled + else{ + Flyspray::show_error(L('massopsdisabled')); + } + } diff --git a/includes/password_compat.php b/includes/password_compat.php new file mode 100644 index 0000000..d681634 --- /dev/null +++ b/includes/password_compat.php @@ -0,0 +1,319 @@ +<?php +/** + * A Compatibility library with PHP 5.5's simplified password hashing API. + * + * @author Anthony Ferrara <ircmaxell@php.net> + * @license http://www.opensource.org/licenses/mit-license.html MIT License + * @copyright 2012 The Authors + * + * Comment of Flyspray dev peterdd: This is lib/password.php from https://github.com/ircmaxell/password_compat master branch at 2016-06-27 + */ + +namespace { + + if (!defined('PASSWORD_BCRYPT')) { + /** + * PHPUnit Process isolation caches constants, but not function declarations. + * So we need to check if the constants are defined separately from + * the functions to enable supporting process isolation in userland + * code. + */ + define('PASSWORD_BCRYPT', 1); + define('PASSWORD_DEFAULT', PASSWORD_BCRYPT); + define('PASSWORD_BCRYPT_DEFAULT_COST', 10); + } + + if (!function_exists('password_hash')) { + + /** + * Hash the password using the specified algorithm + * + * @param string $password The password to hash + * @param int $algo The algorithm to use (Defined by PASSWORD_* constants) + * @param array $options The options for the algorithm to use + * + * @return string|false The hashed password, or false on error. + */ + function password_hash($password, $algo, array $options = array()) { + if (!function_exists('crypt')) { + trigger_error("Crypt must be loaded for password_hash to function", E_USER_WARNING); + return null; + } + if (is_null($password) || is_int($password)) { + $password = (string) $password; + } + if (!is_string($password)) { + trigger_error("password_hash(): Password must be a string", E_USER_WARNING); + return null; + } + if (!is_int($algo)) { + trigger_error("password_hash() expects parameter 2 to be long, " . gettype($algo) . " given", E_USER_WARNING); + return null; + } + $resultLength = 0; + switch ($algo) { + case PASSWORD_BCRYPT: + $cost = PASSWORD_BCRYPT_DEFAULT_COST; + if (isset($options['cost'])) { + $cost = (int) $options['cost']; + if ($cost < 4 || $cost > 31) { + trigger_error(sprintf("password_hash(): Invalid bcrypt cost parameter specified: %d", $cost), E_USER_WARNING); + return null; + } + } + // The length of salt to generate + $raw_salt_len = 16; + // The length required in the final serialization + $required_salt_len = 22; + $hash_format = sprintf("$2y$%02d$", $cost); + // The expected length of the final crypt() output + $resultLength = 60; + break; + default: + trigger_error(sprintf("password_hash(): Unknown password hashing algorithm: %s", $algo), E_USER_WARNING); + return null; + } + $salt_req_encoding = false; + if (isset($options['salt'])) { + switch (gettype($options['salt'])) { + case 'NULL': + case 'boolean': + case 'integer': + case 'double': + case 'string': + $salt = (string) $options['salt']; + break; + case 'object': + if (method_exists($options['salt'], '__tostring')) { + $salt = (string) $options['salt']; + break; + } + case 'array': + case 'resource': + default: + trigger_error('password_hash(): Non-string salt parameter supplied', E_USER_WARNING); + return null; + } + if (PasswordCompat\binary\_strlen($salt) < $required_salt_len) { + trigger_error(sprintf("password_hash(): Provided salt is too short: %d expecting %d", PasswordCompat\binary\_strlen($salt), $required_salt_len), E_USER_WARNING); + return null; + } elseif (0 == preg_match('#^[a-zA-Z0-9./]+$#D', $salt)) { + $salt_req_encoding = true; + } + } else { + $buffer = ''; + $buffer_valid = false; + if (function_exists('mcrypt_create_iv') && !defined('PHALANGER')) { + $buffer = mcrypt_create_iv($raw_salt_len, MCRYPT_DEV_URANDOM); + if ($buffer) { + $buffer_valid = true; + } + } + if (!$buffer_valid && function_exists('openssl_random_pseudo_bytes')) { + $strong = false; + $buffer = openssl_random_pseudo_bytes($raw_salt_len, $strong); + if ($buffer && $strong) { + $buffer_valid = true; + } + } + if (!$buffer_valid && @is_readable('/dev/urandom')) { + $file = fopen('/dev/urandom', 'r'); + $read = 0; + $local_buffer = ''; + while ($read < $raw_salt_len) { + $local_buffer .= fread($file, $raw_salt_len - $read); + $read = PasswordCompat\binary\_strlen($local_buffer); + } + fclose($file); + if ($read >= $raw_salt_len) { + $buffer_valid = true; + } + $buffer = str_pad($buffer, $raw_salt_len, "\0") ^ str_pad($local_buffer, $raw_salt_len, "\0"); + } + if (!$buffer_valid || PasswordCompat\binary\_strlen($buffer) < $raw_salt_len) { + $buffer_length = PasswordCompat\binary\_strlen($buffer); + for ($i = 0; $i < $raw_salt_len; $i++) { + if ($i < $buffer_length) { + $buffer[$i] = $buffer[$i] ^ chr(mt_rand(0, 255)); + } else { + $buffer .= chr(mt_rand(0, 255)); + } + } + } + $salt = $buffer; + $salt_req_encoding = true; + } + if ($salt_req_encoding) { + // encode string with the Base64 variant used by crypt + $base64_digits = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + $bcrypt64_digits = + './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + $base64_string = base64_encode($salt); + $salt = strtr(rtrim($base64_string, '='), $base64_digits, $bcrypt64_digits); + } + $salt = PasswordCompat\binary\_substr($salt, 0, $required_salt_len); + + $hash = $hash_format . $salt; + + $ret = crypt($password, $hash); + + if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != $resultLength) { + return false; + } + + return $ret; + } + + /** + * Get information about the password hash. Returns an array of the information + * that was used to generate the password hash. + * + * array( + * 'algo' => 1, + * 'algoName' => 'bcrypt', + * 'options' => array( + * 'cost' => PASSWORD_BCRYPT_DEFAULT_COST, + * ), + * ) + * + * @param string $hash The password hash to extract info from + * + * @return array The array of information about the hash. + */ + function password_get_info($hash) { + $return = array( + 'algo' => 0, + 'algoName' => 'unknown', + 'options' => array(), + ); + if (PasswordCompat\binary\_substr($hash, 0, 4) == '$2y$' && PasswordCompat\binary\_strlen($hash) == 60) { + $return['algo'] = PASSWORD_BCRYPT; + $return['algoName'] = 'bcrypt'; + list($cost) = sscanf($hash, "$2y$%d$"); + $return['options']['cost'] = $cost; + } + return $return; + } + + /** + * Determine if the password hash needs to be rehashed according to the options provided + * + * If the answer is true, after validating the password using password_verify, rehash it. + * + * @param string $hash The hash to test + * @param int $algo The algorithm used for new password hashes + * @param array $options The options array passed to password_hash + * + * @return boolean True if the password needs to be rehashed. + */ + function password_needs_rehash($hash, $algo, array $options = array()) { + $info = password_get_info($hash); + if ($info['algo'] !== (int) $algo) { + return true; + } + switch ($algo) { + case PASSWORD_BCRYPT: + $cost = isset($options['cost']) ? (int) $options['cost'] : PASSWORD_BCRYPT_DEFAULT_COST; + if ($cost !== $info['options']['cost']) { + return true; + } + break; + } + return false; + } + + /** + * Verify a password against a hash using a timing attack resistant approach + * + * @param string $password The password to verify + * @param string $hash The hash to verify against + * + * @return boolean If the password matches the hash + */ + function password_verify($password, $hash) { + if (!function_exists('crypt')) { + trigger_error("Crypt must be loaded for password_verify to function", E_USER_WARNING); + return false; + } + $ret = crypt($password, $hash); + if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != PasswordCompat\binary\_strlen($hash) || PasswordCompat\binary\_strlen($ret) <= 13) { + return false; + } + + $status = 0; + for ($i = 0; $i < PasswordCompat\binary\_strlen($ret); $i++) { + $status |= (ord($ret[$i]) ^ ord($hash[$i])); + } + + return $status === 0; + } + } + +} + +namespace PasswordCompat\binary { + + if (!function_exists('PasswordCompat\\binary\\_strlen')) { + + /** + * Count the number of bytes in a string + * + * We cannot simply use strlen() for this, because it might be overwritten by the mbstring extension. + * In this case, strlen() will count the number of *characters* based on the internal encoding. A + * sequence of bytes might be regarded as a single multibyte character. + * + * @param string $binary_string The input string + * + * @internal + * @return int The number of bytes + */ + function _strlen($binary_string) { + if (function_exists('mb_strlen')) { + return mb_strlen($binary_string, '8bit'); + } + return strlen($binary_string); + } + + /** + * Get a substring based on byte limits + * + * @see _strlen() + * + * @param string $binary_string The input string + * @param int $start + * @param int $length + * + * @internal + * @return string The substring + */ + function _substr($binary_string, $start, $length) { + if (function_exists('mb_substr')) { + return mb_substr($binary_string, $start, $length, '8bit'); + } + return substr($binary_string, $start, $length); + } + + /** + * Check if current PHP version is compatible with the library + * + * @return boolean the check result + */ + function check() { + static $pass = NULL; + + if (is_null($pass)) { + if (function_exists('crypt')) { + $hash = '$2y$04$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG'; + $test = crypt("password", $hash); + $pass = $test == $hash; + } else { + $pass = false; + } + } + return $pass; + } + + } +} diff --git a/includes/utf8.inc.php b/includes/utf8.inc.php new file mode 100644 index 0000000..f950721 --- /dev/null +++ b/includes/utf8.inc.php @@ -0,0 +1,118 @@ +<?php + +if (!defined('IN_FS')) { + die('Do not access this file directly.'); +} + +require_once(dirname(__DIR__) . '/plugins/dokuwiki/inc/utf8.php'); + +// a-z A-Z . _ -, extended latin chars, Cyrillic and Greek +global $UTF8_ALPHA_CHARS; +$UTF8_ALPHA_CHARS = array( + 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x4b, 0x4c, + 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, + 0x59, 0x5a, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, + 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, + 0x77, 0x78, 0x79, 0x7a, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x2e, 0x2d, 0x5f, 0x20, 0x00c1, 0x00e1, 0x0106, 0x0107, + 0x00c9, 0x00e9, 0x00cd, 0x00ed, 0x0139, 0x013a, 0x0143, 0x0144, 0x00d3, + 0x00f3, 0x0154, 0x0155, 0x015a, 0x015b, 0x00da, 0x00fa, 0x00dd, 0x00fd, + 0x0179, 0x017a, 0x010f, 0x013d, 0x013e, 0x0165, 0x0102, 0x0103, 0x011e, + 0x011f, 0x016c, 0x016d, 0x010c, 0x010d, 0x010e, 0x011a, 0x011b, 0x0147, + 0x0148, 0x0158, 0x0159, 0x0160, 0x0161, 0x0164, 0x017d, 0x017e, 0x00c7, + 0x00e7, 0x0122, 0x0123, 0x0136, 0x0137, 0x013b, 0x013c, 0x0145, 0x0146, + 0x0156, 0x0157, 0x015e, 0x015f, 0x0162, 0x0163, 0x00c2, 0x00e2, 0x0108, + 0x0109, 0x00ca, 0x00ea, 0x011c, 0x011d, 0x0124, 0x0125, 0x00ce, 0x00ee, + 0x0134, 0x0135, 0x00d4, 0x00f4, 0x015c, 0x015d, 0x00db, 0x00fb, 0x0174, + 0x0175, 0x0176, 0x0177, 0x00c4, 0x00e4, 0x00cb, 0x00eb, 0x00cf, 0x00ef, + 0x00d6, 0x00f6, 0x00dc, 0x00fc, 0x0178, 0x00ff, 0x010a, 0x010b, 0x0116, + 0x0117, 0x0120, 0x0121, 0x0130, 0x0131, 0x017b, 0x017c, 0x0150, 0x0151, + 0x0170, 0x0171, 0x00c0, 0x00e0, 0x00c8, 0x00e8, 0x00cc, 0x00ec, 0x00d2, + 0x00f2, 0x00d9, 0x00f9, 0x01a0, 0x01a1, 0x01af, 0x01b0, 0x0100, 0x0101, + 0x0112, 0x0113, 0x012a, 0x012b, 0x014c, 0x014d, 0x016a, 0x016b, 0x0104, + 0x0105, 0x0118, 0x0119, 0x012e, 0x012f, 0x0172, 0x0173, 0x00c5, 0x00e5, + 0x016e, 0x016f, 0x0110, 0x0111, 0x0126, 0x0127, 0x0141, 0x0142, 0x00d8, + 0x00f8, 0x00c3, 0x00e3, 0x00d1, 0x00f1, 0x00d5, 0x00f5, 0x00c6, 0x00e6, + 0x0152, 0x0153, 0x00d0, 0x00f0, 0x00de, 0x00fe, 0x00df, 0x017f, 0x0391, + 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397, 0x0398, 0x0399, 0x039a, + 0x039b, 0x039c, 0x039d, 0x039e, 0x039f, 0x03a0, 0x03a1, 0x03a3, 0x03a4, + 0x03a5, 0x03a6, 0x03a7, 0x03a8, 0x03a9, 0x0386, 0x0388, 0x0389, 0x038a, + 0x038c, 0x038e, 0x038f, 0x03aa, 0x03ab, 0x03b1, 0x03b2, 0x03b3, 0x03b4, + 0x03b5, 0x03b6, 0x03b7, 0x03b8, 0x03b9, 0x03ba, 0x03bb, 0x03bc, 0x03bd, + 0x03be, 0x03bf, 0x03c0, 0x03c1, 0x03c3, 0x03c2, 0x03c4, 0x03c5, 0x03c6, + 0x03c7, 0x03c8, 0x03c9, 0x03ac, 0x03ad, 0x03ae, 0x03af, 0x03cc, 0x03cd, + 0x03ce, 0x03ca, 0x03cb, 0x0390, 0x03b0, 0x0410, 0x0411, 0x0412, 0x0413, + 0x0414, 0x0415, 0x0401, 0x0416, 0x0417, 0x0406, 0x0419, 0x041a, 0x041b, + 0x041c, 0x041d, 0x041e, 0x041f, 0x0420, 0x0421, 0x0422, 0x0423, 0x040e, + 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x042b, 0x042c, 0x042d, 0x042e, + 0x042f, 0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0451, 0x0436, + 0x0437, 0x0456, 0x0439, 0x043a, 0x043b, 0x043c, 0x043d, 0x043e, 0x043f, + 0x0440, 0x0441, 0x0442, 0x0443, 0x045e, 0x0444, 0x0445, 0x0446, 0x0447, + 0x0448, 0x044b, 0x044c, 0x044d, 0x044e, 0x044f, 0x0418, 0x0429, 0x042a, + 0x0438, 0x0449, 0x044a, 0x0403, 0x0405, 0x0408, 0x0409, 0x040a, 0x040c, + 0x040f, 0x0453, 0x0455, 0x0458, 0x0459, 0x045a, 0x045c, 0x045f, 0x0402, + 0x040b, 0x0452, 0x045b, 0x0490, 0x0404, 0x0407, 0x0491, 0x0454, 0x0457, + 0x04e8, 0x04ae, 0x04e9, 0x04af, +); + +function utf8_keepalphanum($string) +{ + + // a-z A-Z . _ -, extended latin chars, Cyrillic and Greek + static $UTF8_ALPHA_CHARS = array( + 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x4b, 0x4c, + 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, + 0x59, 0x5a, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, + 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, + 0x77, 0x78, 0x79, 0x7a, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x2e, 0x2d, 0x5f, 0x20, 0x00c1, 0x00e1, 0x0106, 0x0107, + 0x00c9, 0x00e9, 0x00cd, 0x00ed, 0x0139, 0x013a, 0x0143, 0x0144, 0x00d3, + 0x00f3, 0x0154, 0x0155, 0x015a, 0x015b, 0x00da, 0x00fa, 0x00dd, 0x00fd, + 0x0179, 0x017a, 0x010f, 0x013d, 0x013e, 0x0165, 0x0102, 0x0103, 0x011e, + 0x011f, 0x016c, 0x016d, 0x010c, 0x010d, 0x010e, 0x011a, 0x011b, 0x0147, + 0x0148, 0x0158, 0x0159, 0x0160, 0x0161, 0x0164, 0x017d, 0x017e, 0x00c7, + 0x00e7, 0x0122, 0x0123, 0x0136, 0x0137, 0x013b, 0x013c, 0x0145, 0x0146, + 0x0156, 0x0157, 0x015e, 0x015f, 0x0162, 0x0163, 0x00c2, 0x00e2, 0x0108, + 0x0109, 0x00ca, 0x00ea, 0x011c, 0x011d, 0x0124, 0x0125, 0x00ce, 0x00ee, + 0x0134, 0x0135, 0x00d4, 0x00f4, 0x015c, 0x015d, 0x00db, 0x00fb, 0x0174, + 0x0175, 0x0176, 0x0177, 0x00c4, 0x00e4, 0x00cb, 0x00eb, 0x00cf, 0x00ef, + 0x00d6, 0x00f6, 0x00dc, 0x00fc, 0x0178, 0x00ff, 0x010a, 0x010b, 0x0116, + 0x0117, 0x0120, 0x0121, 0x0130, 0x0131, 0x017b, 0x017c, 0x0150, 0x0151, + 0x0170, 0x0171, 0x00c0, 0x00e0, 0x00c8, 0x00e8, 0x00cc, 0x00ec, 0x00d2, + 0x00f2, 0x00d9, 0x00f9, 0x01a0, 0x01a1, 0x01af, 0x01b0, 0x0100, 0x0101, + 0x0112, 0x0113, 0x012a, 0x012b, 0x014c, 0x014d, 0x016a, 0x016b, 0x0104, + 0x0105, 0x0118, 0x0119, 0x012e, 0x012f, 0x0172, 0x0173, 0x00c5, 0x00e5, + 0x016e, 0x016f, 0x0110, 0x0111, 0x0126, 0x0127, 0x0141, 0x0142, 0x00d8, + 0x00f8, 0x00c3, 0x00e3, 0x00d1, 0x00f1, 0x00d5, 0x00f5, 0x00c6, 0x00e6, + 0x0152, 0x0153, 0x00d0, 0x00f0, 0x00de, 0x00fe, 0x00df, 0x017f, 0x0391, + 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397, 0x0398, 0x0399, 0x039a, + 0x039b, 0x039c, 0x039d, 0x039e, 0x039f, 0x03a0, 0x03a1, 0x03a3, 0x03a4, + 0x03a5, 0x03a6, 0x03a7, 0x03a8, 0x03a9, 0x0386, 0x0388, 0x0389, 0x038a, + 0x038c, 0x038e, 0x038f, 0x03aa, 0x03ab, 0x03b1, 0x03b2, 0x03b3, 0x03b4, + 0x03b5, 0x03b6, 0x03b7, 0x03b8, 0x03b9, 0x03ba, 0x03bb, 0x03bc, 0x03bd, + 0x03be, 0x03bf, 0x03c0, 0x03c1, 0x03c3, 0x03c2, 0x03c4, 0x03c5, 0x03c6, + 0x03c7, 0x03c8, 0x03c9, 0x03ac, 0x03ad, 0x03ae, 0x03af, 0x03cc, 0x03cd, + 0x03ce, 0x03ca, 0x03cb, 0x0390, 0x03b0, 0x0410, 0x0411, 0x0412, 0x0413, + 0x0414, 0x0415, 0x0401, 0x0416, 0x0417, 0x0406, 0x0419, 0x041a, 0x041b, + 0x041c, 0x041d, 0x041e, 0x041f, 0x0420, 0x0421, 0x0422, 0x0423, 0x040e, + 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x042b, 0x042c, 0x042d, 0x042e, + 0x042f, 0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0451, 0x0436, + 0x0437, 0x0456, 0x0439, 0x043a, 0x043b, 0x043c, 0x043d, 0x043e, 0x043f, + 0x0440, 0x0441, 0x0442, 0x0443, 0x045e, 0x0444, 0x0445, 0x0446, 0x0447, + 0x0448, 0x044b, 0x044c, 0x044d, 0x044e, 0x044f, 0x0418, 0x0429, 0x042a, + 0x0438, 0x0449, 0x044a, 0x0403, 0x0405, 0x0408, 0x0409, 0x040a, 0x040c, + 0x040f, 0x0453, 0x0455, 0x0458, 0x0459, 0x045a, 0x045c, 0x045f, 0x0402, + 0x040b, 0x0452, 0x045b, 0x0490, 0x0404, 0x0407, 0x0491, 0x0454, 0x0457, + 0x04e8, 0x04ae, 0x04e9, 0x04af, + ); + $chars = utf8_to_unicode($string); + + for ($i = 0, $size = count($chars); $i < $size; ++$i) + { + if (!in_array($chars[$i], $UTF8_ALPHA_CHARS)) + { + unset($chars[$i]); + } + } + return unicode_to_utf8($chars); +} |