_getSalt(22); return crypt($password, $salt); } protected function _checkPassword($password, $stored_hash) { return crypt($password, $stored_hash) == $stored_hash; } protected function _sessionStart($force = false) { if (!isset($_SESSION) && ($force || isset($_COOKIE[session_name()]))) { session_start(); } return true; } public function keepSessionAlive() { $this->_sessionStart(true); } public function login($id, $passe) { $db = DB::getInstance(); $champ_id = Config::getInstance()->get('champ_identifiant'); $r = $db->simpleQuerySingle('SELECT id, passe, id_categorie FROM membres WHERE '.$champ_id.' = ? LIMIT 1;', true, trim($id)); if (empty($r)) return false; if (!$this->_checkPassword(trim($passe), $r['passe'])) return false; $droits = $this->getDroits($r['id_categorie']); if ($droits['connexion'] == self::DROIT_AUCUN) return false; $this->_sessionStart(true); $db->simpleExec('UPDATE membres SET date_connexion = datetime(\'now\') WHERE id = ?;', $r['id']); return $this->updateSessionData($r['id'], $droits); } public function recoverPasswordCheck($id) { $db = DB::getInstance(); $config = Config::getInstance(); $champ_id = $config->get('champ_identifiant'); $membre = $db->simpleQuerySingle('SELECT id, email FROM membres WHERE '.$champ_id.' = ? LIMIT 1;', true, trim($id)); if (!$membre || trim($membre['email']) == '') { return false; } $this->_sessionStart(true); $hash = sha1($membre['email'] . $membre['id'] . 'recover' . ROOT . time()); $_SESSION['recover_password'] = [ 'id' => (int) $membre['id'], 'email' => $membre['email'], 'hash' => $hash ]; $message = "Bonjour,\n\nVous avez oublié votre mot de passe ? Pas de panique !\n\n"; $message.= "Il vous suffit de cliquer sur le lien ci-dessous pour recevoir un nouveau mot de passe.\n\n"; $message.= WWW_URL . 'admin/password.php?c=' . substr($hash, -10); $message.= "\n\nSi vous n'avez pas demandé à recevoir ce message, ignorez-le, votre mot de passe restera inchangé."; return utils::mail($membre['email'], '['.$config->get('nom_asso').'] Mot de passe perdu ?', $message); } public function recoverPasswordConfirm($hash) { $this->_sessionStart(); if (empty($_SESSION['recover_password']['hash'])) return false; if (substr($_SESSION['recover_password']['hash'], -10) != $hash) return false; $config = Config::getInstance(); $db = DB::getInstance(); $password = utils::suggestPassword(); $dest = $_SESSION['recover_password']['email']; $id = (int)$_SESSION['recover_password']['id']; $message = "Bonjour,\n\nVous avez demandé un nouveau mot de passe pour votre compte.\n\n"; $message.= "Votre adresse email : ".$dest."\n"; $message.= "Votre nouveau mot de passe : ".$password."\n\n"; $message.= "Si vous n'avez pas demandé à recevoir ce message, merci de nous le signaler."; $password = $this->_hashPassword($password); $db->simpleUpdate('membres', ['passe' => $password], 'id = '.(int)$id); return utils::mail($dest, '['.$config->get('nom_asso').'] Nouveau mot de passe', $message); } public function updateSessionData($membre = null, $droits = null) { if (is_null($membre)) { $membre = $this->get($_SESSION['logged_user']['id']); } elseif (is_int($membre)) { $membre = $this->get($membre); } if (is_null($droits)) { $droits = $this->getDroits($membre['id_categorie']); } $membre['droits'] = $droits; $_SESSION['logged_user'] = $membre; return true; } public function localLogin() { if (!defined('Garradin\LOCAL_LOGIN')) return false; if (trim(LOCAL_LOGIN) == '') return false; $db = DB::getInstance(); $config = Config::getInstance(); $champ_id = $config->get('champ_identifiant'); if (is_int(LOCAL_LOGIN) && $db->simpleQuerySingle('SELECT 1 FROM membres WHERE id = ? LIMIT 1;', true, LOCAL_LOGIN)) { $this->_sessionStart(true); return $this->updateSessionData(LOCAL_LOGIN); } elseif ($id = $db->simpleQuerySingle('SELECT id FROM membres WHERE '.$champ_id.' = ? LIMIT 1;', true, LOCAL_LOGIN)) { $this->_sessionStart(true); return $this->updateSessionData($membre); } throw new UserException('Le membre ' . LOCAL_LOGIN . ' n\'existe pas, merci de modifier la directive Garradin\LOCAL_LOGIN.'); } public function isLogged() { $this->_sessionStart(); if (empty($_SESSION['logged_user'])) { if (defined('Garradin\LOCAL_LOGIN')) { return $this->localLogin(); } return false; } return true; } public function getLoggedUser() { if (!$this->isLogged()) return false; return $_SESSION['logged_user']; } public function logout() { $_SESSION = []; setcookie(session_name(), '', 0, '/'); return true; } public function sessionStore($key, $value) { if (!isset($_SESSION['storage'])) { $_SESSION['storage'] = []; } if ($value === null) { unset($_SESSION['storage'][$key]); } else { $_SESSION['storage'][$key] = $value; } return true; } public function sessionGet($key) { if (!isset($_SESSION['storage'][$key])) { return null; } return $_SESSION['storage'][$key]; } public function sendMessage($dest, $sujet, $message, $copie = false) { if (!$this->isLogged()) { throw new \LogicException('Cette fonction ne peut être appelée que par un utilisateur connecté.'); } $from = $this->getLoggedUser(); $from = $from['email']; // Uniquement adresse email pour le moment car faudrait trouver comment // indiquer le nom mais qu'il soit correctement échappé FIXME $config = Config::getInstance(); $message .= "\n\n--\nCe message a été envoyé par un membre de ".$config->get('nom_asso'); $message .= ", merci de contacter ".$config->get('email_asso')." en cas d'abus."; if ($copie) { utils::mail($from, $sujet, $message); } return utils::mail($dest, $sujet, $message, ['From' => $from]); } // Gestion des données /////////////////////////////////////////////////////// public function _checkFields(&$data, $check_editable = true, $check_password = true) { $champs = Config::getInstance()->get('champs_membres'); foreach ($champs->getAll() as $key=>$config) { if (!$check_editable && (!empty($config['private']) || empty($config['editable']))) { unset($data[$key]); continue; } if (!isset($data[$key]) || (!is_array($data[$key]) && trim($data[$key]) === '') || (is_array($data[$key]) && empty($data[$key]))) { if (!empty($config['mandatory']) && ($check_password || $key != 'passe')) { throw new UserException('Le champ "' . $config['title'] . '" doit obligatoirement être renseigné.'); } elseif (!empty($config['mandatory'])) { continue; } } if (isset($data[$key])) { if ($config['type'] == 'email' && trim($data[$key]) !== '' && !filter_var($data[$key], FILTER_VALIDATE_EMAIL)) { throw new UserException('Adresse e-mail invalide dans le champ "' . $config['title'] . '".'); } elseif ($config['type'] == 'url' && trim($data[$key]) !== '' && !filter_var($data[$key], FILTER_VALIDATE_URL)) { throw new UserException('Adresse URL invalide dans le champ "' . $config['title'] . '".'); } elseif ($config['type'] == 'date' && trim($data[$key]) !== '' && !utils::checkDate($data[$key])) { throw new UserException('Date invalide "' . $config['title'] . '", format attendu : AAAA-MM-JJ.'); } elseif ($config['type'] == 'datetime' && trim($data[$key]) !== '') { if (!utils::checkDateTime($data[$key]) || !($dt = new DateTime($data[$key]))) { throw new UserException('Date invalide "' . $config['title'] . '", format attendu : AAAA-MM-JJ HH:mm.'); } $data[$key] = $dt->format('Y-m-d H:i'); } elseif ($config['type'] == 'tel') { $data[$key] = utils::normalizePhoneNumber($data[$key]); } elseif ($config['type'] == 'country') { $data[$key] = strtoupper(substr($data[$key], 0, 2)); } elseif ($config['type'] == 'checkbox') { $data[$key] = empty($data[$key]) ? 0 : 1; } elseif ($config['type'] == 'number' && trim($data[$key]) !== '') { if (empty($data[$key])) { $data[$key] = 0; } if (!is_numeric($data[$key])) throw new UserException('Le champ "' . $config['title'] . '" doit contenir un chiffre.'); } elseif ($config['type'] == 'select' && !in_array($data[$key], $config['options'])) { throw new UserException('Le champ "' . $config['title'] . '" ne correspond pas à un des choix proposés.'); } elseif ($config['type'] == 'multiple') { if (empty($data[$key]) || !is_array($data[$key])) { $data[$key] = 0; continue; } $binary = 0; foreach ($data[$key] as $k => $v) { if (array_key_exists($k, $config['options']) && !empty($v)) { $binary |= 0x01 << $k; } } $data[$key] = $binary; } // Un champ texte vide c'est un champ NULL if (is_string($data[$key]) && trim($data[$key]) === '') { $data[$key] = null; } } } if (isset($data['code_postal']) && trim($data['code_postal']) != '') { if (!empty($data['pays']) && $data['pays'] == 'FR' && !preg_match('!^\d{5}$!', $data['code_postal'])) { throw new UserException('Code postal invalide.'); } } if (!empty($data['passe']) && strlen($data['passe']) < 5) { throw new UserException('Le mot de passe doit faire au moins 5 caractères.'); } return true; } public function add($data = []) { $this->_checkFields($data); $db = DB::getInstance(); $config = Config::getInstance(); $id = $config->get('champ_identifiant'); if (!empty($data[$id]) && $db->simpleQuerySingle('SELECT 1 FROM membres WHERE '.$id.' = ? LIMIT 1;', false, $data[$id])) { throw new UserException('La valeur du champ '.$id.' est déjà utilisée par un autre membre, hors ce champ doit être unique à chaque membre.'); } if (isset($data['passe']) && trim($data['passe']) != '') { $data['passe'] = $this->_hashPassword($data['passe']); } else { unset($data['passe']); } if (empty($data['id_categorie'])) { $data['id_categorie'] = Config::getInstance()->get('categorie_membres'); } $db->simpleInsert('membres', $data); return $db->lastInsertRowId(); } public function edit($id, $data = [], $check_editable = true) { $db = DB::getInstance(); $config = Config::getInstance(); if (isset($data['id']) && ($data['id'] == $id || empty($data['id']))) { unset($data['id']); } $this->_checkFields($data, $check_editable, false); $champ_id = $config->get('champ_identifiant'); if (!empty($data[$champ_id]) && $db->simpleQuerySingle('SELECT 1 FROM membres WHERE '.$champ_id.' = ? AND id != ? LIMIT 1;', false, $data[$champ_id], (int)$id)) { throw new UserException('La valeur du champ '.$champ_id.' est déjà utilisée par un autre membre, hors ce champ doit être unique à chaque membre.'); } if (!empty($data['id'])) { if ($db->simpleQuerySingle('SELECT 1 FROM membres WHERE id = ?;', false, (int)$data['id'])) { throw new UserException('Ce numéro est déjà attribué à un autre membre.'); } // Si on ne vérifie pas toutes les tables qui sont liées ici à un ID de membre // la requête de modification provoquera une erreur de contrainte de foreign key // ce qui est normal. Donc : il n'est pas possible de changer l'ID d'un membre qui // a participé au wiki, à la compta, etc. if ($db->simpleQuerySingle('SELECT 1 FROM wiki_revisions WHERE id_auteur = ?;', false, (int)$id) || $db->simpleQuerySingle('SELECT 1 FROM compta_journal WHERE id_auteur = ?;', false, (int)$id)) # FIXME || $db->simpleQuerySingle('SELECT 1 FROM wiki_suivi WHERE id_membre = ?;', false, (int)$id)) { throw new UserException('Le numéro n\'est pas modifiable pour ce membre car des contenus sont liés à ce numéro de membre (wiki, compta, etc.).'); } } if (!empty($data['passe']) && trim($data['passe'])) { $data['passe'] = $this->_hashPassword($data['passe']); } else { unset($data['passe']); } if (isset($data['id_categorie']) && empty($data['id_categorie'])) { $data['id_categorie'] = Config::getInstance()->get('categorie_membres'); } if (empty($data)) { return true; } return $db->simpleUpdate('membres', $data, 'id = '.(int)$id); } public function get($id) { $db = DB::getInstance(); $config = Config::getInstance(); return $db->simpleQuerySingle('SELECT *, '.$config->get('champ_identite').' AS identite, strftime(\'%s\', date_inscription) AS date_inscription, strftime(\'%s\', date_connexion) AS date_connexion FROM membres WHERE id = ? LIMIT 1;', true, (int)$id); } public function delete($ids) { if (!is_array($ids)) { $ids = [(int)$ids]; } if ($this->isLogged()) { $user = $this->getLoggedUser(); foreach ($ids as $id) { if ($user['id'] == $id) { throw new UserException('Il n\'est pas possible de supprimer son propre compte.'); } } } return self::_deleteMembres($ids); } public function getNom($id) { $db = DB::getInstance(); $config = Config::getInstance(); return $db->simpleQuerySingle('SELECT '.$config->get('champ_identite').' FROM membres WHERE id = ? LIMIT 1;', false, (int)$id); } public function getDroits($id) { $db = DB::getInstance(); $droits = $db->simpleQuerySingle('SELECT * FROM membres_categories WHERE id = ?;', true, (int)$id); foreach ($droits as $key=>$value) { unset($droits[$key]); $key = str_replace('droit_', '', $key, $found); if ($found) { $droits[$key] = (int) $value; } } return $droits; } public function search($field, $query) { $db = DB::getInstance(); $config = Config::getInstance(); $champs = $config->get('champs_membres'); if ($field != 'id' && !$champs->get($field)) { throw new \UnexpectedValueException($field . ' is not a valid field'); } $champ = $champs->get($field); if ($champ['type'] == 'multiple') { $where = 'WHERE '.$field.' & (1 << '.(int)$query.')'; $order = false; } elseif ($champ['type'] == 'tel') { $query = utils::normalizePhoneNumber($query); $query = preg_replace('!^0+!', '', $query); if ($query == '') { return false; } $where = 'WHERE '.$field.' LIKE \'%'.$db->escapeString($query).'\''; $order = $field; } elseif (!$champs->isText($field)) { $where = 'WHERE '.$field.' = \''.$db->escapeString($query).'\''; $order = $field; } else { $where = 'WHERE transliterate_to_ascii('.$field.') LIKE transliterate_to_ascii(\'%'.$db->escapeString($query).'%\')'; $order = 'transliterate_to_ascii('.$field.') COLLATE NOCASE'; } $fields = array_keys($champs->getListedFields()); if (!in_array($field, $fields)) { $fields[] = $field; } if (!in_array('email', $fields)) { $fields[] = 'email'; } return $db->simpleStatementFetch( 'SELECT id, id_categorie, ' . implode(', ', $fields) . ', '.$config->get('champ_identite').' AS identite, strftime(\'%s\', date_inscription) AS date_inscription FROM membres ' . $where . ($order ? ' ORDER BY ' . $order : '') . ' LIMIT 1000;', SQLITE3_ASSOC ); } public function listByCategory($cat, $fields, $page = 1, $order = null, $desc = false) { $begin = ($page - 1) * self::ITEMS_PER_PAGE; $db = DB::getInstance(); $config = Config::getInstance(); $champs = $config->get('champs_membres'); if (is_int($cat) && $cat) $where = 'WHERE id_categorie = '.(int)$cat; elseif (is_array($cat)) $where = 'WHERE id_categorie IN ('.implode(',', $cat).')'; else $where = ''; if (is_null($order) || !$champs->get($order)) $order = 'id'; if (!empty($fields) && $order != 'id' && $champs->isText($order)) { $order = 'transliterate_to_ascii('.$order.') COLLATE NOCASE'; } if ($desc) { $order .= ' DESC'; } if (!in_array('email', $fields)) { $fields []= 'email'; } $fields = implode(', ', $fields); $query = 'SELECT id, id_categorie, '.$fields.', '.$config->get('champ_identite').' AS identite, strftime(\'%s\', date_inscription) AS date_inscription FROM membres '.$where.' ORDER BY '.$order.' LIMIT ?, ?;'; return $db->simpleStatementFetch($query, SQLITE3_ASSOC, $begin, self::ITEMS_PER_PAGE); } public function countByCategory($cat = 0) { $db = DB::getInstance(); if (is_int($cat) && $cat) $where = 'WHERE id_categorie = '.(int)$cat; elseif (is_array($cat)) $where = 'WHERE id_categorie IN ('.implode(',', $cat).')'; else $where = ''; return $db->simpleQuerySingle('SELECT COUNT(*) FROM membres '.$where.';'); } public function countAllButHidden() { $db = DB::getInstance(); return $db->simpleQuerySingle('SELECT COUNT(*) FROM membres WHERE id_categorie NOT IN (SELECT id FROM membres_categories WHERE cacher = 1);'); } static public function changeCategorie($id_cat, $membres) { foreach ($membres as &$id) { $id = (int) $id; } $db = DB::getInstance(); return $db->simpleUpdate('membres', ['id_categorie' => (int)$id_cat], 'id IN ('.implode(',', $membres).')' ); } static protected function _deleteMembres($membres) { foreach ($membres as &$id) { $id = (int) $id; } $membres = implode(',', $membres); $db = DB::getInstance(); $db->exec('UPDATE wiki_revisions SET id_auteur = NULL WHERE id_auteur IN ('.$membres.');'); $db->exec('UPDATE compta_journal SET id_auteur = NULL WHERE id_auteur IN ('.$membres.');'); //$db->exec('DELETE FROM wiki_suivi WHERE id_membre IN ('.$membres.');'); return $db->exec('DELETE FROM membres WHERE id IN ('.$membres.');'); } public function sendMessageToCategory($dest, $sujet, $message, $subscribed_only = false) { $config = Config::getInstance(); $headers = [ 'From' => '"'.$config->get('nom_asso').'" <'.$config->get('email_asso').'>', ]; $message .= "\n\n--\n".$config->get('nom_asso')."\n".$config->get('site_asso'); if ($dest == 0) $where = 'id_categorie NOT IN (SELECT id FROM membres_categories WHERE cacher = 1)'; else $where = 'id_categorie = '.(int)$dest; if ($subscribed_only) { $where .= ' AND lettre_infos = 1'; } $db = DB::getInstance(); $res = $db->query('SELECT email FROM membres WHERE LENGTH(email) > 0 AND '.$where.' ORDER BY id;'); $sujet = '['.$config->get('nom_asso').'] '.$sujet; while ($row = $res->fetchArray(SQLITE3_ASSOC)) { utils::mail($row['email'], $sujet, $message, $headers); } return true; } public function searchSQL($query) { $db = DB::getInstance(); $st = $db->prepare($query); if (!$st->readOnly()) { throw new UserException('Seules les requêtes en lecture sont autorisées.'); } if (!preg_match('/LIMIT\s+/', $query)) { $query = preg_replace('/;?\s*$/', '', $query); $query .= ' LIMIT 100'; } if (!preg_match('/FROM\s+membres(?:\s+|$|;)/i', $query)) { throw new UserException('Seules les requêtes sur la table membres sont autorisées.'); } if (preg_match('/;\s*(.+?)$/', $query)) { throw new UserException('Une seule requête peut être envoyée en même temps.'); } $st = $db->prepare($query); $res = $st->execute(); $out = []; while ($row = $res->fetchArray(SQLITE3_ASSOC)) { if (array_key_exists('passe', $row)) { unset($row['passe']); } $out[] = $row; } return $out; } public function schemaSQL() { $db = DB::getInstance(); $tables = [ 'membres' => $db->querySingle('SELECT sql FROM sqlite_master WHERE type = \'table\' AND name = \'membres\';'), 'categories'=> $db->querySingle('SELECT sql FROM sqlite_master WHERE type = \'table\' AND name = \'membres_categories\';'), ]; return $tables; } } ?>