login = $_SESSION["user"]["login"]; $this->client = isset($_SESSION["user"]["client"]) ? $_SESSION["user"]["client"] : 'website'; } /** * Retrieve information about the current user from the database. * If a username is given, first validate that it is the same * currently in the session. * * @param string|null $login * @return User * @throws AuthenticationException */ private function getUser($login = null) { if(! is_null($login) && $_SESSION["user"]["login"] !== $login) { throw new AuthenticationException("BadLogin", "Login '$login' is invalid.", AuthenticationException::BAD_LOGIN); } $this->checkSession(); if(strlen($this->login) == 0) { throw new AuthenticationException("LoginEmpty", "No login information found in session.", AuthenticationException::LOGIN_EMPTY); } $user = User::find($this->login); if (!$user) { throw new AuthenticationException("UserNotFound", "No user found for '{$this->login}'.", AuthenticationException::USER_NOT_FOUND); } return $user; } /** * Retrieve file information (samples and zip) for a list of books based * on their code (NoticeNr). * This should be called only if those information are not already in Solr * for the given books. * * @param array $codes * @return array File information indexed by book code */ private function GetFiles(array $codes) { $codes = array_map('intval', $codes); $uri = sprintf("%s%s", Configuration::get('checkfile_url'), http_build_query(array("book" => implode(',', $codes))) ); $ch = curl_init($uri); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HEADER, 0); $json = curl_exec($ch); curl_close($ch); return json_decode($json, true); } /** * Save files information (samples and zip) for books into Solr * based on their id (NoticeId). * * @param $files */ private function SetFiles($files) { $json = json_encode(array_values(array_map(function($f) { return array( 'id' => $f['id'], 'samples' => array('set' => isset($f['samples']) ? $f['samples'] : array()), 'zip' => array('set' => isset($f['zip']['uri']) ? $f['zip']['uri'] : ''), 'zip_size' => array('set' => isset($f['zip']['size']) ? $f['zip']['size'] : 0), ); }, $files))); $uri = sprintf('%s:%s/%s/update?commitWithin=500', Configuration::get('solr.server'), Configuration::get('solr.port'), Configuration::get('solr.path') ); $ch = curl_init($uri); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); curl_setopt($ch, CURLOPT_POSTFIELDS, $json); curl_setopt($ch, CURLOPT_HTTPHEADER, array( 'Content-Type: application/json', 'Content-Length: ' . strlen($json) )); curl_exec($ch); curl_close($ch); } /** * Add some information to each books : * 1° File information if not already their (@see GetFiles), * those information are then saved into Solr (@see SetFiles) * 2° Correctly set hash on zip file to authenticate download * 3° Compatibility fields for mobile apps * * You can pass either a single book or an array of books. * * @param array $books either one or a list of books * @return array either one or a list of books */ private function AddBookData(array $books) { if(isset($books['code'])) { $result = $this->AddBookData(array($books)); return reset($result); } // add complementary data to each book $books = array_map(function($b) { // add files if we already have them $files = array(); if(isset($b['samples']) && count($b['zip']) > 0) { $files['samples'] = $b['samples']; } unset($b['samples']); if(isset($b['zip']) && strlen($b['zip']) > 0) { $files['zip'] = array( 'uri' => $b['zip'], 'size' => $b['zip_size'], ); } unset($b['zip']); unset($b['zip_size']); if(! empty($files)) { $b['files'] = $files; } // date will be already set if we are updating Circulations if(! isset($b['date'])) { $b['date'] = date('Y.m.d', strtotime($b['availabilityDate'])); } // add fields for mobile apps compatibility $b['readBy'] = isset($b['reader']) ? $b['reader'] : 'Lecteur inconnu'; $b['category'] = $b['genre']; $b['code3'] = $b['producerCode']; $b['code3Long'] = $b['producer']; $b['typeMedia1'] = $b['mediaType']; return $b; }, $books); // retrieve files information for the book that don't have all of them at this time $booksWithoutFiles = array_filter($books, function($b) { return ! ( isset($b['files']) && isset($b['files']['samples']) && count($b['files']['samples']) == 2 && // we want two samples (mp3 and ogg) isset($b['files']['zip']) // we want a zip file ); }); if(count($booksWithoutFiles) > 0) { $ids = array_map(function($b) { return $b['code']; }, $booksWithoutFiles); $files = $this->GetFiles($ids); if(count($files) > 0) { foreach($booksWithoutFiles as $k => $b) { $fileCode = sprintf("%05u", $b['code']); if(isset($files[$fileCode])) { $books[$k]['files'] = $files[$fileCode]; $files[$fileCode]['id'] = $b['id']; } else { // we need to have an empty array for mobile apps compatibility. $books[$k]['files'] = array(); } } } $this->SetFiles($files); } // retrieve login information if they exists to generate the URL $this->CheckSession(); // add hash, client and login into zip file uri $books = array_map(function($b) { $b['files']['cacheable_uri'] = isset($b['files']['zip']['uri']) ? $b['files']['zip']['uri'] : false; if(strlen($this->login) > 0 && isset($b['files']['zip']['uri'])) { $key = 'babf2cfbe2633c3082f8cfffdb3d9008b4b3b300'; $code = sprintf("%05u", $b['code']); $hash = sha1($this->client.$this->login.$key.$code.date('Ymd')); $b['files']['zip']['uri'] = str_replace(array( '{client}', '{login}', '{hash}', ), array( $this->client, $this->login, $hash, ), $b['files']['zip']['uri']); } else { unset($b['files']['zip']); } return $b; }, $books); return $books; } /** * Return true if the value matches a book code (a number with 1 to 5 digits) * @param $val * @return int */ protected function IsBookCode($val) { return preg_match('/^[0-9]{1,5}$/', $val); } // ********************************** // * Public methods * // ********************************** /** * This method register a download made through the website or mobile application * as a lent and returned book (OldCirculation). * * @param string $client * @param string $login * @param int $code * @return array * @throws Lib\Exception\SqlException * @throws WebException */ public function AddDownloadLog($client, $login, $code) { $client = str_replace("'", "", $client); $login = str_replace("'", "", $login); $code = ltrim(str_replace("'", "", $code), '0'); $itemNr = $code . 'V'; $sql = "SELECT itemID FROM Items WHERE LTRIM(RTRIM(ItemNr)) = '$itemNr';"; $result = Connection::execute($sql, false); if ($row = $result->current()) { $itemId = $row['itemID']; } else { throw new WebException("ItemNotFound", "cannot find item", -1030); } $sql = "SELECT UserAccountID FROM UserAccounts WHERE LTRIM(RTRIM(UserAccountNr)) = '$login';"; $result = Connection::execute($sql, false); if ($row = $result->current()) { $userId = $row['UserAccountID']; } else { throw new WebException("UserNotFound", "cannot find user", -1031); } $sql = "SELECT circulationId FROM OldCirculations WHERE UserAccountID= $userId AND itemID = $itemId AND LTRIM(RTRIM(remark)) = '$client';"; $result = Connection::execute($sql, false); if ($row = $result->current()) { $id = $row['circulationId']; $sql = "UPDATE OldCirculations SET CheckInDate=GETDATE(), CheckOutDate=GETDATE() WHERE circulationID = $id"; Connection::execute($sql); return array('success' => true); } $sql = "SELECT TOP 1 circulationID FROM OldCirculations ORDER BY CirculationID DESC"; $result = Connection::execute($sql, false); if ($row = $result->current()) { $nextId = $row['circulationID'] + 1; } else { $nextId = 1; } $sql = "UPDATE UserAccounts SET Circulations = Circulations + 1, TotalCirculations = TotalCirculations + 1 WHERE UserAccountID = $userId;"; Connection::execute($sql); $sql = "UPDATE Items SET Circulations = Circulations + 1, TotalCirculations = TotalCirculations + 1 WHERE ItemID = $itemId;"; Connection::execute($sql); $worker_id = Configuration::get('netbiblio_worker_id'); $sql = "INSERT INTO OldCirculations ( CirculationID, ItemID, UserAccountID, Remark, DueDate, CheckOutDate, CheckInDate, CheckOutBranchOfficeID, CheckOutEmployeeID, CheckInBranchOfficeID, CheckInEmployeeID, Reminders, Renewals, PreReminder, InfoCode, CheckOutSIP2Info, CheckInSIP2Info ) VALUES ( $nextId, $itemId, $userId, '$client', DATEADD(month, 2, GETDATE()), GETDATE(), GETDATE(), 2, $worker_id, 2, $worker_id, 0, 0, 1, '-', 1, 1 );"; $status = Connection::execute($sql); return array('success' => $status && ! $status->is_error() && $status->get_num_rows() > 0); } /** * This method authenticates and store the login information into the session so * that the next calls can be made for this user. * * @param $login * @param $password * @param string $client * @return array * @throws AuthenticationException */ public function Authenticate($login, $password, $client = "website") { session_unset(); /* destroy all session vars */ $user = User::authenticate($login, $password); if (! $user) { throw new AuthenticationException("AuthenticationFailed", "Invalid login or password.", AuthenticationException::AUTHENTICATION_FAILED); } $_SESSION["user"]["login"] = $login; $_SESSION["user"]["client"] = $client; $this->login = $login; $this->client = $client; return $user->toArray(); } /** * This method disconnects the current user and clear all session data. * * @return array */ public function Disconnect() { $_SESSION = array(); if (ini_get("session.use_cookies")) { $params = session_get_cookie_params(); setcookie(session_name(), '', time() - 42000, $params["path"], $params["domain"], $params["secure"], $params["httponly"]); } return array('success' => true); } /** * Return the information associated with the currently authenticated user * if any. * * @return array * @throws AuthenticationException */ public function IsAuthenticated() { return $this->getUser()->toArray(); } /** * This method returns the account information for the given login, but only * if it matches the currently authenticated user. * * @param string $login * @return array * @throws AuthenticationException */ public function FindAccount($login) { return $this->getUser($login)->toArray(); } /** * This method returns the books on the currently authenticated user wishlist. * * @return array * @throws AuthenticationException */ public function GetWishes() { $books = $this->getUser()->getWishes(); return array_values($this->AddBookData($books)); } /** * This method returns the list of all Circulations for the currently * authenticated user in a format suited for BSR internal tools * (CD engraving for example). * * @return array * @throws AuthenticationException */ public function GetCirculations() { return $this->getUser()->GetCirculations(); } /** * This method returns the list of books that are currently lent to the * authenticated user. * * @return array * @throws AuthenticationException */ public function GetLoans() { $circulations = $this->getUser()->GetLoans(); return array_values($this->AddBookData($circulations)); } /** * This method returns the list of books that the currently authenticated user * has downloaded or that were lent to him. * * @return array * @throws AuthenticationException */ public function GetOldLoans() { $circulations = $this->getUser()->GetOldLoans(); return array_values($this->AddBookData($circulations)); } /** * This method adds a book to the currently authenticated user wishlist * based on its code. * * @param int $code * @return array * @throws AuthenticationException */ public function AddWish($code) { $status = $this->getUser()->addWish($code); return array('success' => $status); } /** * This method deletes the given book from the currently authenticated * user wishlist based on its code. * * @param int $code * @return array * @throws AuthenticationException */ public function DeleteWish($code) { $status = $this->getUser()->deleteWish($code); return array('success' => $status); } /** * This method returns an array of books based on their code * * @param array|int[] $codes * @return array */ public function FindBooks($codes) { return array_values($this->AddBookData(BookSearch::GetBooks(json_decode($codes)))); } /** * This method return a book based on its code * * @param int $code * @return array */ public function FindBook($code) { $books = $this->AddBookData(BookSearch::GetBooks(array($code))); return reset($books); } /** * This method returns a list of random books. * For a given seed, the list should be always the same at least while * Solr is not restarted (or documents re-indexed) * * @param int $number Number of random books to return * @param null $seed * @param int $page * @return array * @throws WebException */ public function GetRandomBooks($number = 100, $seed = null, $page = 0) { if(is_null($seed)) { $seed = time(); } $bs = new BookSearch(); $bs->addSortField('random_'.$seed); $bs->addFilterQuery(1, 'visible'); $results = $bs->getResults($page * $number, $number); return $this->AddBookData($results['books']); } /** * This method is used by the iOS application to perform searching. * If a number is given, search in the book codes, otherwise perform * a full text search. * * @param string $text Text to search * @param int $start * @param int $limit * @return array an array of books * @throws WebException */ public function Search($text, $start, $limit) { $query = array( 'queryText' => $text, 'count' => $limit, 'page' => max(intval($start) - 1, 0), ); if($this->IsBookCode($text)) { $query['queryType'] = 'code'; } $data = $this->NewSearch(json_encode($query)); // remove fields that are not used in "old" search unset($data['count']); unset($data['facets']); return $data; } /** * This method is used by the website and the Android application to perform * book search. * * Search parameters : * * ° queryText : the text to search for * ° queryType : the field to search in, defaults to 'text' * * ° jeunesse : only display books for kids (must have format 'jeunesse' => array('filtrer' => 'filtrer') * * ° producerCode : filter by 'producerCode' * ° genreCode : filter by 'genreCode' * ° author : filter by 'author' * ° reader : filter by 'reader' * ° motsMatieres : filter by 'motsMatieres' * * ° duration : exact duration in minutes * ° durationMin : minimal duration in minutes * ° durationMax : maximal duration in minutes * * ° count : number of results we want * ° page : page to start at (0 is the first) * * Shortcuts : * * ° producer : synonym for 'producerCode' (see above) * ° genre : synonym for 'genreCode' (see above) * * Deprecated, but still in use on mobile apps : * * ° category : synonym for 'genre' (see above) * * Return value : * * The return value start with two keys : * * ° 'count' : which is the total number of available results * ° 'facets' : which contains all other relevent information (facets, spellchecking, * highlighting, etc). This name is used for compatibility reasons. * * Then, the books come right after. * * @param string $values JSON encoded object * @return array * @throws WebException */ public function NewSearch($values) { // we need the client to perform some query field replacement $this->CheckSession(); $queryArray = json_decode($values, true); if(! is_array($queryArray)) { throw new WebException("CallArg", "Argument must be valid JSON.", -42); } // shortcuts and the iOS app still uses 'category' $compatibility = array( 'genre' => 'genreCode', 'category' => 'genreCode', 'producer' => 'producerCode' ); foreach($compatibility as $old => $new) { if(isset($queryArray[$old])) { $queryArray[$new] = $queryArray[$old]; unset($queryArray[$old]); } } $bs = new BookSearch(); if (isset($queryArray['queryText']) && strlen($queryArray['queryText']) > 0) { $type = isset($queryArray['queryType']) ? $queryArray['queryType'] : null; if($this->client != 'website' && in_array($type, array('title', 'author', 'reader'))) { // we don't want an exact search on mobile apps $type = $type.'_fr'; } else if($type == 'text') { // The field 'text' is still used by the Android app but does not exists anymore // We use the default search fields in this case. $type = null; } $bs->addQuery($queryArray['queryText'], $type); } if(isset($queryArray['genreCode']) && is_array($queryArray['genreCode'])) { // Jeunesse is a particular genre with it's own way of being searched if(($key = array_search('J', $queryArray['genreCode'])) !== false) { unset($queryArray['genreCode']['J']); $queryArray['jeunesse'] = array('filtrer' => 'filtrer'); } } if(isset($queryArray['jeunesse']) && $queryArray['jeunesse']['filtrer'] === 'filtrer') { $bs->addFilterQuery(1, 'jeunesse'); } if(isset($queryArray['duration'])) { $bs->addQuery($queryArray['duration'], 'duration'); } else if(isset($queryArray['durationMin']) || isset($queryArray['durationMax'])) { $min = isset($queryArray['durationMin']) ? $queryArray['durationMin'] : '*'; $max = isset($queryArray['durationMax']) ? $queryArray['durationMax'] : '*'; $bs->addRange('duration', $min, $max); } $availableFields = array('producerCode', 'genreCode', 'author', 'reader', 'motsMatieres'); foreach($availableFields as $q) { if(isset($queryArray[$q]) && ( (is_string($queryArray[$q]) && strlen($queryArray[$q]) > 0) || (is_array($queryArray[$q]) && count($queryArray[$q]) > 0) )) { if(is_array($queryArray[$q])) { // Genres cannot overlap, so we use 'OR', otherwise use 'AND' $bs->addCompoundQuery($queryArray[$q], $q, $q == 'genreCode' ? 'OR' : 'AND'); } else { $bs->addQuery($queryArray[$q], $q); } } } // we only want visible books in search results $bs->addFilterQuery(1, 'visible'); $count = isset($queryArray['count']) ? (int) $queryArray['count'] : Configuration::get('solr.result_count'); $start = isset($queryArray['page']) ? $queryArray['page'] * $count : 0; $results = $bs->getResults($start, $count); $data = array( 'count' => $results['count'], 'facets' => $results['facets'], ); return array_merge($data, $this->AddBookData($results['books'])); } /** * This method return books similar to the one given. * * @param int|array $ids One or multiple book ids * @param int number of books * @return array */ public function MoreLikeThis($ids, $number = 5) { $bs = new BookSearch(false); $bs->addOrQuery(is_array($ids) ? $ids : array($ids), 'id'); $bs->setHandler('more'); $results = $bs->getResults(0, $number); return $this->AddBookData($results['books']); } /** * This method returns books similar to the books already * loaned by the current user. * * @return array * @throws AuthenticationException */ public function MoreLikeLoans() { $circulations = $this->getUser()->getLoansData('OldCirculations'); $ids = array_map(function($c) { return $c['NoticeId']; }, $circulations); return $this->MoreLikeThis($ids); } /** * This method return a list of suggested titles for the given search terms * @param string $text * @return array */ public function Suggest($text) { $bs = new BookSearch(); return $bs->suggest($text); } /** * This method returns the list of durations in minutes but with 30 minutes gaps * and the count of books in each group. * * @return array */ public function ListOfDurations() { return BookSearch::GetTermsRange('duration'); } /** * This method returns the list of all volunteer readers that read book * in the database. * @return array */ public function ListOfReaders() { $readers = array(); foreach(BookSearch::GetTerms('reader') as $name => $count) { $parts = explode(" ", $name); $firstname = array_shift($parts); $lastname = implode(" ", $parts); $fullname = trim($lastname.' '.$firstname); $readers[$fullname] = array( 'lastname' => $lastname, 'firstname' => $firstname, 'count' => $count, ); } // sort readers by lastname ksort($readers); return array_values($readers); } /** * This method is called by the website in Drupal to get the list * of available 'Genres'. * @return array */ public function ListOfGenres() { return DBHelper::ListOfGenres(); } /** * This method is called by the Android application to get the list * of available 'Genres'. * @return array */ public function ListOfCategories() { return DBHelper::ListOfGenres(true); } /** * This method is called by the iOS application to get the list * of available 'Genres'. 'Jeunesse' must be a part of them. * @return array */ public function ListOfTypes() { return array_map(function($g) { return $g['text']; }, DBHelper::ListOfGenres(true)); } /** * This method returns the list of all books that are currently * being read by the volunteers. * * @return array */ public function InReadingBooks() { return DBHelper::InReading(); } /** * This method is used by the iOS application to retrieve the last * books for a given Genre. It may receives the genre 'Jeunesse' which * is a boolean value on Solr documents. * * @param string $genre Genre for which we want books (not the code), this can be empty * @param int $number number of books * @return array * @throws WebException */ public function LastBooksByType($genre, $number) { $s = new BookSearch(); if($genre == 'Jeunesse') { $s->addQuery(1, 'jeunesse'); } else if(! empty($genre)) { $s->addQuery($genre, 'genre'); } $s->addSortField('availabilityDate'); // we only want visible books $s->addFilterQuery(1, 'visible'); $results = $s->getResults(0, $number); $books = $this->AddBookData($results['books']); if(empty($genre)) { return $books; } $data = array(); foreach($books as $b) { $data[$genre][] = $b; } return $data; } }