diff --git a/.gitattributes b/.gitattributes index d753b1d..82f3760 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,15 +10,22 @@ *.php text diff=php Dockerfile text -# Do not alter images nor minified scripts +# Do not alter images nor minified scripts nor fonts *.ico binary *.jpg binary *.png binary +*.svg binary +*.otf binary +*.eot binary +*.woff binary +*.woff2 binary +*.ttf binary *.min.css binary *.min.js binary # Exclude from Git archives .gitattributes export-ignore +.github export-ignore .gitignore export-ignore .travis.yml export-ignore doc/**/*.json export-ignore diff --git a/.github/mailmap b/.github/mailmap new file mode 100644 index 0000000..41d91e4 --- /dev/null +++ b/.github/mailmap @@ -0,0 +1,13 @@ +ArthurHoaro +Florian Eula feula +Florian Eula +Nicolas Danelon nicolasm +Nicolas Danelon +Nicolas Danelon +Nicolas Danelon +Sébastien Sauvage +Timo Van Neerden +Timo Van Neerden lehollandaisvolant +VirtualTam +VirtualTam +VirtualTam diff --git a/.gitignore b/.gitignore index 095aade..984d9d1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,11 +13,10 @@ pagecache *.rtpl.php # 3rd-party dependencies -composer.lock vendor/ # Release archives -*.tar +*.tar.gz *.zip # Development and test resources @@ -28,3 +27,8 @@ phpmd.html # User plugin configuration plugins/*/config.php + +# 3rd party themes +tpl/* +!tpl/default +!tpl/vintage diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..66ef8f6 --- /dev/null +++ b/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] diff --git a/.travis.yml b/.travis.yml index 6ff1b20..59b86c0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,11 @@ sudo: false language: php +addons: + apt: + packages: + - locales + - language-pack-de + - language-pack-fr cache: directories: - $HOME/.composer/cache @@ -8,12 +14,10 @@ php: - 7.0 - 5.6 - 5.5 - - 5.4 - - 5.3 install: - composer self-update - composer install --prefer-dist script: - make clean - make check_permissions - - make test + - make all_tests diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..c0e3594 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,41 @@ + 472 ArthurHoaro + 201 VirtualTam + 132 nodiscc + 56 Sébastien Sauvage + 15 Florian Eula + 13 Emilien Klein + 12 Nicolas Danelon + 8 Christophe HENRY + 4 Alexandre Alapetite + 4 David Sferruzza + 3 Teromene + 2 Chris Kuethe + 2 Knah Tsaeb + 2 Mathieu Chabanon + 2 Miloš Jovanović + 2 Qwerty + 2 Timo Van Neerden + 2 julienCXX + 2 kalvn + 1 Adrien Oliva + 1 Alexis J + 1 BoboTiG + 1 Bronco + 1 D Low + 1 Dimtion + 1 Fanch + 1 Felix Bartels + 1 Felix Kästner + 1 Florian Voigt + 1 Gary Marigliano + 1 Guillaume Virlet + 1 Jonathan Druart + 1 Julien Pivotto + 1 Kevin Canévet + 1 Knah Tsaeb + 1 Lionel Martin + 1 Marsup + 1 Sbgodin + 1 TsT + 1 dimtion + 1 philipp-r diff --git a/CHANGELOG.md b/CHANGELOG.md index 1340db5..6109fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,81 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [v0.9.0](https://github.com/shaarli/Shaarli/releases/tag/v0.9.0) - UNPUBLISHED +## [v0.9.0](https://github.com/shaarli/Shaarli/releases/tag/v0.9.0) - 2017-05-07 + +This release introduces the REST API, and requires updating HTTP server +configuration to enable URL rewriting, see: +- https://shaarli.github.io/api-documentation/ +- https://github.com/shaarli/Shaarli/wiki/Server-configuration + +**WARNING**: Shaarli now requires PHP 5.5+. ### Added +- REST API v1 + - [Slim](https://www.slimframework.com/) framework + - [JSON Web Token](https://jwt.io/introduction/) (JWT) authentication + - versioned API endpoints: + - `/api/v1/info`: get general information on the Shaarli instance + - `/api/v1/links`: get a list of shaared links + - `/api/v1/history`: get a list of latest actions +Theming: + - Introduce a new theme + - Allow selecting themes/templates from the configuration page + - New/Edit link form can be submitted using CTRL+Enter in the textarea + - Shaarli version is displayed in the footer when logged in +- Add plugin placeholders to Atom/RSS feed templates +- Add OpenSearch to feed templates +- Add `campaign_` to the URL cleanup pattern list +- Add an AUTHORS file and Makefile target to list authors from Git commit data +- Link imports are now logged in `data/` folder, and can be debug using `dev.debug=true` setting. +- `composer.lock` is now included in git file to allow proper `composer install` +- History mechanism which logs link addition/modification/deletion ### Changed +- Docker: enable nginx URL rewriting for the REST API +- Theming: + - Move `user.css` to the `data` folder + - Move default template files to a subfolder (`default`) + - Rename the legacy theme to `vintage` + - Private only filter is now displayed as a search parameter + - Autocomplete: pre-select the first element + - Display daily date in the page title (browser title) + - Timezone lists are now passed as an array instead of raw HTML +- Move PubSubHub to a dedicated plugin +- Coding style: + - explicit method visibility + - safe boolean comparisons + - remove unused variables +- The updater now keeps custom theme preferences +- Simplify the COPYING information +- Improved client locale detection +- Improved date time display depending on the locale +- Partial namespace support for Shaarli classes +- Shaarli version is now only present in `shaarli_version.php` +- Human readable maximum file size upload + + +### Removed +- PHP < 5.5 compatibility +- ReadItYourself plugin ### Fixed +- Ignore generated release tarballs +- Hide default port when behind a reverse proxy +- Fix a typo in the Markdown plugin description +- Fix the presence of empty tags for private tags and in search results +- Fix a fatal error during the install +- Fix permalink image alignment in daily page +- Fix the delete button in `editlink` +- Fix redirection after link deletion +- Do not access LinkDB links by ID before the Updater applies migrations +- Remove extra spaces in the bookmarklet's name +- Piwik plugin: Piwik URL protocol can now be set (http or https) +- All inline JS has been moved to dedicated JS files +- Keep tags after login redirection + +### Security +- Markdown plugin: escape HTML entities by default ## [v0.8.4](https://github.com/shaarli/Shaarli/releases/tag/v0.8.4) - 2017-03-04 ### Security @@ -30,6 +98,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Editing a link created before the new ID system would change its permalink. +## [v0.8.4](https://github.com/shaarli/Shaarli/releases/tag/v0.8.4) - 2017-03-04 +### Security +- Markdown plugin: escape HTML entities by default + +## [v0.8.3](https://github.com/shaarli/Shaarli/releases/tag/v0.8.3) - 2017-01-20 +### Fixed +- PHP 7.1 compatibility: add ConfigManager parameter to anti-bruteforce function call in login template. + +## [v0.8.2](https://github.com/shaarli/Shaarli/releases/tag/v0.8.2) - 2016-12-15 +### Fixed + +- Editing a link created before the new ID system would change its permalink. + ## [v0.8.1](https://github.com/shaarli/Shaarli/releases/tag/v0.8.1) - 2016-12-12 > Note: this version will create an automatic backup of your database if anything goes wrong. @@ -115,6 +196,10 @@ Please use our release archives, or follow the - XSRF token now generated each time a page is rendered +## [v0.7.1](https://github.com/shaarli/Shaarli/releases/tag/v0.7.1) - 2017-03-08 +### Security +- Markdown plugin: escape HTML entities by default + ## [v0.7.0](https://github.com/shaarli/Shaarli/releases/tag/v0.7.0) - 2016-05-14 ### Added - Adds an option to encode redirector URL parameter diff --git a/COPYING b/COPYING index 2292946..0520215 100644 --- a/COPYING +++ b/COPYING @@ -1,33 +1,7 @@ Files: * License: zlib/libpng Copyright: (c) 2011-2015 Sébastien SAUVAGE - (c) 2011-2015 Alexandre Alapetite - (c) 2011-2015 David Sferruzza - (c) 2011-2015 Christophe HENRY - (c) 2011-2015 Mathieu Chabanon - (c) 2011-2015 BoboTiG - (c) 2011-2015 Bronco - (c) 2011-2015 Emilien Klein - (c) 2011-2015 Knah Tsaeb - (c) 2011-2015 Lionel Martin - (c) 2011-2015 lehollandaisvolant - (c) 2011-2015 timo van neerden - (c) 2011-2015 nodiscc - (c) 2011-2015 Florian Eula - (c) 2011-2015 Arthur Hoaro - (c) 2011-2015 Aurélien "VirtualTam" Tamisier - (c) 2011-2015 qwertygc - (c) 2011-2015 idleman - (c) 2015 Alexis Ju - (c) 2015 dimtion - (c) 2015 Fanch - (c) 2015 Guillaume Virlet - (c) 2015 Felix Bartels - (c) 2015 Marsup - (c) 2015 Miloš Jovanović - (c) 2015 Nicolás Danelón - (c) 2015 TsT - + (c) 2011-2017 The Shaarli Community, see AUTHORS Files: inc/reset.css License: BSD (http://opensource.org/licenses/BSD-3-Clause) @@ -43,7 +17,7 @@ License: CC-BY (http://creativecommons.org/licenses/by/3.0/) Copyright: (c) 2014 Designmodo Source: http://designmodo.com/linecons-free/ -Files: images/floral_left.png, images/floral_right.png, images/squiggle.png, images/squiggle2.png, images/squiggle_closing.png +Files: images/floral_left.png, images/floral_right.png, images/squiggle.png, images/squiggle_closing.png Licence: Public Domain Source: https://openclipart.org/people/j4p4n/j4p4n_ornimental_bookend_-_left.svg @@ -72,6 +46,10 @@ Files: plugins/wallabag/wallabag.png License: MIT License (http://opensource.org/licenses/MIT) Copyright: (C) 2015 Nicolas Lœuillet - https://github.com/wallabag/wallabag +Files: tpl/default/sad_star.png +License: MIT License (http://opensource.org/licenses/MIT) +Copyright: (C) 2015 kalvn - https://github.com/kalvn/Shaarli-Material + ---------------------------------------------------- ZLIB/LIBPNG LICENSE diff --git a/Makefile b/Makefile index 60aec9a..1d8a73a 100644 --- a/Makefile +++ b/Makefile @@ -124,8 +124,20 @@ test: @echo "-------" @echo "PHPUNIT" @echo "-------" - @mkdir -p sandbox - @$(BIN)/phpunit tests + @mkdir -p sandbox coverage + @$(BIN)/phpunit --coverage-php coverage/main.cov --testsuite unit-tests + +locale_test_%: + @UT_LOCALE=$*.utf8 \ + $(BIN)/phpunit \ + --coverage-php coverage/$(firstword $(subst _, ,$*)).cov \ + --bootstrap tests/languages/bootstrap.php \ + --testsuite language-$(firstword $(subst _, ,$*)) + +all_tests: test locale_test_de_DE locale_test_en_US locale_test_fr_FR + @$(BIN)/phpcov merge --html coverage coverage + @# --text doesn't work with phpunit 4.* (v5 requires PHP 5.6) + @#$(BIN)/phpcov merge --text coverage/txt coverage ## # Custom release archive generation @@ -169,6 +181,12 @@ clean: @git clean -df @rm -rf sandbox +### generate the AUTHORS file from Git commit information +authors: + @cp .github/mailmap .mailmap + @git shortlog -sne > AUTHORS + @rm .mailmap + ### generate Doxygen documentation doxygen: clean @rm -rf doxygen @@ -214,4 +232,4 @@ htmlpages: -o doc/$$base.html $$file; \ done; -htmldoc: doc htmlsidebar htmlpages +htmldoc: authors doc htmlsidebar htmlpages diff --git a/README.md b/README.md index 21062b9..d57f520 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,18 @@ _Do you want to share the links you discover?_ _Shaarli is a minimalist delicious clone that you can install on your own server._ _It is designed to be personal (single-user), fast and handy._ -[![](https://img.shields.io/travis/shaarli/Shaarli.svg?label=master)](https://travis-ci.org/shaarli/Shaarli) +[![](https://img.shields.io/badge/stable-v0.7.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.7.1) [![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli) -[![](https://img.shields.io/github/release/shaarli/shaarli.svg)](https://github.com/shaarli/Shaarli/releases/latest/) -[![Docker repository](https://img.shields.io/docker/pulls/shaarli/shaarli.svg)](https://hub.docker.com/r/shaarli/shaarli/) +• +[![](https://img.shields.io/badge/latest-v0.8.4-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.8.4) +[![](https://img.shields.io/travis/shaarli/Shaarli/latest.svg?label=latest)](https://travis-ci.org/shaarli/Shaarli) +• +[![](https://img.shields.io/badge/master-v0.9.x-blue.svg)](https://github.com/shaarli/Shaarli) +[![](https://img.shields.io/travis/shaarli/Shaarli.svg?label=master)](https://travis-ci.org/shaarli/Shaarli) [![Join the chat at https://gitter.im/shaarli/Shaarli](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/shaarli/Shaarli) [![Bountysource](https://www.bountysource.com/badge/team?team_id=19583&style=bounties_received)](https://www.bountysource.com/teams/shaarli/issues) +[![Docker repository](https://img.shields.io/docker/pulls/shaarli/shaarli.svg)](https://hub.docker.com/r/shaarli/shaarli/) ## Quickstart - [Wiki/documentation](https://github.com/shaarli/Shaarli/wiki) @@ -20,7 +25,7 @@ _It is designed to be personal (single-user), fast and handy._ - [Bugs/Feature requests/Discussion](https://github.com/shaarli/Shaarli/issues/) ### Demo -You can use this [public demo instance of Shaarli](http://shaarlidemo.tuxfamily.org/Shaarli). +You can use this [public demo instance of Shaarli](https://demo.shaarli.org). It runs the latest development version of Shaarli and is updated/reset daily. Login: `demo`; Password: `demo` @@ -80,6 +85,12 @@ dailymotion, flickr, imageshack, imgur, vimeo, xkcd, youtube... - URL cleanup: automatic removal of `?utm_source=...`, `fb=...` - discreet pop-up notification when a new release is available +### REST API + +Easily extensible by any client using the REST API exposed by Shaarli. + +See the [API documentation](http://shaarli.github.io/api-documentation/). + ### Other usages Though Shaarli is primarily a bookmarking application, it can serve other purposes (see [usage examples](https://github.com/shaarli/Shaarli/wiki#usage-examples)): diff --git a/application/ApplicationUtils.php b/application/ApplicationUtils.php index 7f963e9..85dcbee 100644 --- a/application/ApplicationUtils.php +++ b/application/ApplicationUtils.php @@ -4,9 +4,13 @@ */ class ApplicationUtils { + /** + * @var string File containing the current version + */ + public static $VERSION_FILE = 'shaarli_version.php'; + private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli'; - private static $GIT_BRANCHES = array('master', 'stable'); - private static $VERSION_FILE = 'shaarli_version.php'; + private static $GIT_BRANCHES = array('latest', 'stable'); private static $VERSION_START_TAG = ''; @@ -29,6 +33,30 @@ class ApplicationUtils return false; } + return $data; + } + + /** + * Retrieve the version from a remote URL or a file. + * + * @param string $remote URL or file to fetch. + * @param int $timeout For URLs fetching. + * + * @return bool|string The version or false if it couldn't be retrieved. + */ + public static function getVersion($remote, $timeout = 2) + { + if (startsWith($remote, 'http')) { + if (($data = static::getLatestGitVersionCode($remote, $timeout)) === false) { + return false; + } + } else { + if (! is_file($remote)) { + return false; + } + $data = file_get_contents($remote); + } + return str_replace( array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL), array('', '', ''), @@ -65,13 +93,10 @@ class ApplicationUtils $isLoggedIn, $branch='stable') { - if (! $isLoggedIn) { - // Do not check versions for visitors - return false; - } - - if (empty($enableCheck)) { - // Do not check if the user doesn't want to + // Do not check versions for visitors + // Do not check if the user doesn't want to + // Do not check with dev version + if (! $isLoggedIn || empty($enableCheck) || $currentVersion === 'dev') { return false; } @@ -93,7 +118,7 @@ class ApplicationUtils // Late Static Binding allows overriding within tests // See http://php.net/manual/en/language.oop5.late-static-bindings.php - $latestVersion = static::getLatestGitVersionCode( + $latestVersion = static::getVersion( self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE ); @@ -150,6 +175,7 @@ class ApplicationUtils 'inc', 'plugins', $conf->get('resource.raintpl_tpl'), + $conf->get('resource.raintpl_tpl').'/'.$conf->get('resource.theme'), ) as $path) { if (! is_readable(realpath($path))) { $errors[] = '"'.$path.'" directory is not readable'; diff --git a/application/Base64Url.php b/application/Base64Url.php new file mode 100644 index 0000000..61590e4 --- /dev/null +++ b/application/Base64Url.php @@ -0,0 +1,34 @@ +cacheDir = $cacheDir; - $this->url = $url; $this->filename = $this->cacheDir.'/'.sha1($url).'.cache'; $this->shouldBeCached = $shouldBeCached; } diff --git a/application/FeedBuilder.php b/application/FeedBuilder.php index fedd90e..a1f4da4 100644 --- a/application/FeedBuilder.php +++ b/application/FeedBuilder.php @@ -62,11 +62,6 @@ class FeedBuilder */ protected $hideDates; - /** - * @var string PubSub hub URL. - */ - protected $pubsubhubUrl; - /** * @var string server locale. */ @@ -120,7 +115,6 @@ class FeedBuilder } $data['language'] = $this->getTypeLanguage(); - $data['pubsubhub_url'] = $this->pubsubhubUrl; $data['last_update'] = $this->getLatestDateFormatted(); $data['show_dates'] = !$this->hideDates || $this->isLoggedIn; // Remove leading slash from REQUEST_URI. @@ -182,16 +176,6 @@ class FeedBuilder return $link; } - /** - * Assign PubSub hub URL. - * - * @param string $pubsubhubUrl PubSub hub url. - */ - public function setPubsubhubUrl($pubsubhubUrl) - { - $this->pubsubhubUrl = $pubsubhubUrl; - } - /** * Set this to true to use permalinks instead of direct links. * diff --git a/application/FileUtils.php b/application/FileUtils.php index 6cac982..a167f64 100644 --- a/application/FileUtils.php +++ b/application/FileUtils.php @@ -1,21 +1,76 @@ '; + + /** + * Write data into a file (Shaarli database format). + * The data is stored in a PHP file, as a comment, in compressed base64 format. + * + * The file will be created if it doesn't exist. + * + * @param string $file File path. + * @param mixed $content Content to write. + * + * @return int|bool Number of bytes written or false if it fails. + * + * @throws IOException The destination file can't be written. + */ + public static function writeFlatDB($file, $content) { - $this->path = $path; - $this->message = empty($message) ? 'Error accessing' : $message; - $this->message .= PHP_EOL . $this->path; + if (is_file($file) && !is_writeable($file)) { + // The datastore exists but is not writeable + throw new IOException($file); + } else if (!is_file($file) && !is_writeable(dirname($file))) { + // The datastore does not exist and its parent directory is not writeable + throw new IOException(dirname($file)); + } + + return file_put_contents( + $file, + self::$phpPrefix.base64_encode(gzdeflate(serialize($content))).self::$phpSuffix + ); + } + + /** + * Read data from a file containing Shaarli database format content. + * If the file isn't readable or doesn't exists, default data will be returned. + * + * @param string $file File path. + * @param mixed $default The default value to return if the file isn't readable. + * + * @return mixed The content unserialized, or default if the file isn't readable, or false if it fails. + */ + public static function readFlatDB($file, $default = null) + { + // Note that gzinflate is faster than gzuncompress. + // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 + if (is_readable($file)) { + return unserialize( + gzinflate( + base64_decode( + substr(file_get_contents($file), strlen(self::$phpPrefix), -strlen(self::$phpSuffix)) + ) + ) + ); + } + + return $default; } } diff --git a/application/History.php b/application/History.php new file mode 100644 index 0000000..116b926 --- /dev/null +++ b/application/History.php @@ -0,0 +1,200 @@ +historyFilePath = $historyFilePath; + if ($retentionTime !== null) { + $this->retentionTime = $retentionTime; + } + } + + /** + * Initialize: read history file. + * + * Allow lazy loading (don't read the file if it isn't necessary). + */ + protected function initialize() + { + $this->check(); + $this->read(); + } + + /** + * Add Event: new link. + * + * @param array $link Link data. + */ + public function addLink($link) + { + $this->addEvent(self::CREATED, $link['id']); + } + + /** + * Add Event: update existing link. + * + * @param array $link Link data. + */ + public function updateLink($link) + { + $this->addEvent(self::UPDATED, $link['id']); + } + + /** + * Add Event: delete existing link. + * + * @param array $link Link data. + */ + public function deleteLink($link) + { + $this->addEvent(self::DELETED, $link['id']); + } + + /** + * Add Event: settings updated. + */ + public function updateSettings() + { + $this->addEvent(self::SETTINGS); + } + + /** + * Save a new event and write it in the history file. + * + * @param string $status Event key, should be defined as constant. + * @param mixed $id Event item identifier (e.g. link ID). + */ + protected function addEvent($status, $id = null) + { + if ($this->history === null) { + $this->initialize(); + } + + $item = [ + 'event' => $status, + 'datetime' => new DateTime(), + 'id' => $id !== null ? $id : '', + ]; + $this->history = array_merge([$item], $this->history); + $this->write(); + } + + /** + * Check that the history file is writable. + * Create the file if it doesn't exist. + * + * @throws Exception if it isn't writable. + */ + protected function check() + { + if (! is_file($this->historyFilePath)) { + FileUtils::writeFlatDB($this->historyFilePath, []); + } + + if (! is_writable($this->historyFilePath)) { + throw new Exception('History file isn\'t readable or writable'); + } + } + + /** + * Read JSON history file. + */ + protected function read() + { + $this->history = FileUtils::readFlatDB($this->historyFilePath, []); + if ($this->history === false) { + throw new Exception('Could not parse history file'); + } + } + + /** + * Write JSON history file and delete old entries. + */ + protected function write() + { + $comparaison = new DateTime('-'. $this->retentionTime . ' seconds'); + foreach ($this->history as $key => $value) { + if ($value['datetime'] < $comparaison) { + unset($this->history[$key]); + } + } + FileUtils::writeFlatDB($this->historyFilePath, array_values($this->history)); + } + + /** + * Get the History. + * + * @return array + */ + public function getHistory() + { + if ($this->history === null) { + $this->initialize(); + } + + return $this->history; + } +} diff --git a/application/HttpUtils.php b/application/HttpUtils.php index e705cfd..a81f905 100644 --- a/application/HttpUtils.php +++ b/application/HttpUtils.php @@ -122,7 +122,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304) $content = substr($response, $headSize); $headers = array(); foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) { - if (empty($line) or ctype_space($line)) { + if (empty($line) || ctype_space($line)) { continue; } $splitLine = explode(': ', $line, 2); @@ -297,9 +297,17 @@ function server_url($server) // Keep forwarded port if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) { $ports = explode(',', $server['HTTP_X_FORWARDED_PORT']); - $port = ':' . trim($ports[0]); + $port = trim($ports[0]); } else { - $port = ':' . $server['HTTP_X_FORWARDED_PORT']; + $port = $server['HTTP_X_FORWARDED_PORT']; + } + + if (($scheme == 'http' && $port != '80') + || ($scheme == 'https' && $port != '443') + ) { + $port = ':' . $port; + } else { + $port = ''; } } diff --git a/application/LinkDB.php b/application/LinkDB.php index 1e13286..0d3c85b 100644 --- a/application/LinkDB.php +++ b/application/LinkDB.php @@ -50,12 +50,6 @@ class LinkDB implements Iterator, Countable, ArrayAccess // Link date storage format const LINK_DATE_FORMAT = 'Ymd_His'; - // Datastore PHP prefix - protected static $phpPrefix = ''; - // List of links (associative array) // - key: link date (e.g. "20110823_124546"), // - value: associative array (keys: title, description...) @@ -144,10 +138,10 @@ class LinkDB implements Iterator, Countable, ArrayAccess if (!isset($value['id']) || empty($value['url'])) { die('Internal Error: A link should always have an id and URL.'); } - if ((! empty($offset) && ! is_int($offset)) || ! is_int($value['id'])) { + if (($offset !== null && ! is_int($offset)) || ! is_int($value['id'])) { die('You must specify an integer as a key.'); } - if (! empty($offset) && $offset !== $value['id']) { + if ($offset !== null && $offset !== $value['id']) { die('Array offset and link ID must be equal.'); } @@ -295,16 +289,7 @@ You use the community supported version of the original Shaarli project, by Seba return; } - // Read data - // Note that gzinflate is faster than gzuncompress. - // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 - $this->links = array(); - - if (file_exists($this->datastore)) { - $this->links = unserialize(gzinflate(base64_decode( - substr(file_get_contents($this->datastore), - strlen(self::$phpPrefix), -strlen(self::$phpSuffix))))); - } + $this->links = FileUtils::readFlatDB($this->datastore, []); $toremove = array(); foreach ($this->links as $key => &$link) { @@ -361,19 +346,7 @@ You use the community supported version of the original Shaarli project, by Seba */ private function write() { - if (is_file($this->datastore) && !is_writeable($this->datastore)) { - // The datastore exists but is not writeable - throw new IOException($this->datastore); - } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) { - // The datastore does not exist and its parent directory is not writeable - throw new IOException(dirname($this->datastore)); - } - - file_put_contents( - $this->datastore, - self::$phpPrefix.base64_encode(gzdeflate(serialize($this->links))).self::$phpSuffix - ); - + FileUtils::writeFlatDB($this->datastore, $this->links); } /** @@ -443,11 +416,11 @@ You use the community supported version of the original Shaarli project, by Seba * - searchtags: list of tags * - searchterm: term search * @param bool $casesensitive Optional: Perform case sensitive filter - * @param bool $privateonly Optional: Returns private links only if true. + * @param string $visibility return only all/private/public links * * @return array filtered links, all links if no suitable filter was provided. */ - public function filterSearch($filterRequest = array(), $casesensitive = false, $privateonly = false) + public function filterSearch($filterRequest = array(), $casesensitive = false, $visibility = 'all') { // Filter link database according to parameters. $searchtags = !empty($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : ''; @@ -475,7 +448,7 @@ You use the community supported version of the original Shaarli project, by Seba } $linkFilter = new LinkFilter($this); - return $linkFilter->filter($type, $request, $casesensitive, $privateonly); + return $linkFilter->filter($type, $request, $casesensitive, $visibility); } /** diff --git a/application/LinkFilter.php b/application/LinkFilter.php index daa6d9c..81832a4 100644 --- a/application/LinkFilter.php +++ b/application/LinkFilter.php @@ -51,12 +51,16 @@ class LinkFilter * @param string $type Type of filter (eg. tags, permalink, etc.). * @param mixed $request Filter content. * @param bool $casesensitive Optional: Perform case sensitive filter if true. - * @param bool $privateonly Optional: Only returns private links if true. + * @param string $visibility Optional: return only all/private/public links * * @return array filtered link list. */ - public function filter($type, $request, $casesensitive = false, $privateonly = false) + public function filter($type, $request, $casesensitive = false, $visibility = 'all') { + if (! in_array($visibility, ['all', 'public', 'private'])) { + $visibility = 'all'; + } + switch($type) { case self::$FILTER_HASH: return $this->filterSmallHash($request); @@ -64,42 +68,44 @@ class LinkFilter if (!empty($request)) { $filtered = $this->links; if (isset($request[0])) { - $filtered = $this->filterTags($request[0], $casesensitive, $privateonly); + $filtered = $this->filterTags($request[0], $casesensitive, $visibility); } if (isset($request[1])) { $lf = new LinkFilter($filtered); - $filtered = $lf->filterFulltext($request[1], $privateonly); + $filtered = $lf->filterFulltext($request[1], $visibility); } return $filtered; } - return $this->noFilter($privateonly); + return $this->noFilter($visibility); case self::$FILTER_TEXT: - return $this->filterFulltext($request, $privateonly); + return $this->filterFulltext($request, $visibility); case self::$FILTER_TAG: - return $this->filterTags($request, $casesensitive, $privateonly); + return $this->filterTags($request, $casesensitive, $visibility); case self::$FILTER_DAY: return $this->filterDay($request); default: - return $this->noFilter($privateonly); + return $this->noFilter($visibility); } } /** * Unknown filter, but handle private only. * - * @param bool $privateonly returns private link only if true. + * @param string $visibility Optional: return only all/private/public links * * @return array filtered links. */ - private function noFilter($privateonly = false) + private function noFilter($visibility = 'all') { - if (! $privateonly) { + if ($visibility === 'all') { return $this->links; } $out = array(); foreach ($this->links as $key => $value) { - if ($value['private']) { + if ($value['private'] && $visibility === 'private') { + $out[$key] = $value; + } else if (! $value['private'] && $visibility === 'public') { $out[$key] = $value; } } @@ -151,14 +157,14 @@ class LinkFilter * - see https://github.com/shaarli/Shaarli/issues/75 for examples * * @param string $searchterms search query. - * @param bool $privateonly return only private links if true. + * @param string $visibility Optional: return only all/private/public links. * * @return array search results. */ - private function filterFulltext($searchterms, $privateonly = false) + private function filterFulltext($searchterms, $visibility = 'all') { if (empty($searchterms)) { - return $this->links; + return $this->noFilter($visibility); } $filtered = array(); @@ -189,8 +195,12 @@ class LinkFilter foreach ($this->links as $id => $link) { // ignore non private links when 'privatonly' is on. - if (! $link['private'] && $privateonly === true) { - continue; + if ($visibility !== 'all') { + if (! $link['private'] && $visibility === 'private') { + continue; + } else if ($link['private'] && $visibility === 'public') { + continue; + } } // Concatenate link fields to search across fields. @@ -235,16 +245,16 @@ class LinkFilter * * @param string $tags list of tags separated by commas or blank spaces. * @param bool $casesensitive ignore case if false. - * @param bool $privateonly returns private links only. + * @param string $visibility Optional: return only all/private/public links. * * @return array filtered links. */ - public function filterTags($tags, $casesensitive = false, $privateonly = false) + public function filterTags($tags, $casesensitive = false, $visibility = 'all') { // Implode if array for clean up. $tags = is_array($tags) ? trim(implode(' ', $tags)) : $tags; if (empty($tags)) { - return $this->links; + return $this->noFilter($visibility); } $searchtags = self::tagsStrToArray($tags, $casesensitive); @@ -255,8 +265,12 @@ class LinkFilter foreach ($this->links as $key => $link) { // ignore non private links when 'privatonly' is on. - if (! $link['private'] && $privateonly === true) { - continue; + if ($visibility !== 'all') { + if (! $link['private'] && $visibility === 'private') { + continue; + } else if ($link['private'] && $visibility === 'public') { + continue; + } } $linktags = self::tagsStrToArray($link['tags'], $casesensitive); @@ -341,14 +355,14 @@ class LinkFilter * @param bool $casesensitive will convert everything to lowercase if false. * * @return array filtered tags string. - */ + */ public static function tagsStrToArray($tags, $casesensitive) { // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); $tagsOut = str_replace(',', ' ', $tagsOut); - return array_values(array_filter(explode(' ', trim($tagsOut)), 'strlen')); + return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); } } diff --git a/application/LinkUtils.php b/application/LinkUtils.php index cf58f80..976474d 100644 --- a/application/LinkUtils.php +++ b/application/LinkUtils.php @@ -89,7 +89,9 @@ function count_private($links) { $cpt = 0; foreach ($links as $link) { - $cpt = $link['private'] == true ? $cpt + 1 : $cpt; + if ($link['private']) { + $cpt += 1; + } } return $cpt; diff --git a/application/NetscapeBookmarkUtils.php b/application/NetscapeBookmarkUtils.php index e7148d0..2a10ff2 100644 --- a/application/NetscapeBookmarkUtils.php +++ b/application/NetscapeBookmarkUtils.php @@ -1,7 +1,13 @@ get('resource.data_dir') // log path, will be overridden ); + $logger = new Logger( + $conf->get('resource.data_dir'), + ! $conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG, + [ + 'prefix' => 'import.', + 'extension' => 'log', + ] + ); + $parser->setLogger($logger); $bookmarks = $parser->parseString($data); $importCount = 0; @@ -163,9 +180,11 @@ class NetscapeBookmarkUtils $newLink['id'] = $existingLink['id']; $newLink['created'] = $existingLink['created']; $newLink['updated'] = new DateTime(); + $newLink['shorturl'] = $existingLink['shorturl']; $linkDb[$existingLink['id']] = $newLink; $importCount++; $overwriteCount++; + $history->updateLink($newLink); continue; } @@ -177,9 +196,10 @@ class NetscapeBookmarkUtils $newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']); $linkDb[$newLink['id']] = $newLink; $importCount++; + $history->addLink($newLink); } - $linkDb->save($pagecache); + $linkDb->save($conf->get('resource.page_cache')); return self::importStatus( $filename, $filesize, diff --git a/application/PageBuilder.php b/application/PageBuilder.php index 32c7f9f..50e3f12 100644 --- a/application/PageBuilder.php +++ b/application/PageBuilder.php @@ -1,5 +1,7 @@ tpl = false; $this->conf = $conf; + $this->linkDB = $linkDB; } /** @@ -75,9 +84,13 @@ class PageBuilder } $this->tpl->assign('shaarlititle', $this->conf->get('general.title', 'Shaarli')); $this->tpl->assign('openshaarli', $this->conf->get('security.open_shaarli', false)); - $this->tpl->assign('showatom', $this->conf->get('feed.show_atom', false)); + $this->tpl->assign('showatom', $this->conf->get('feed.show_atom', true)); + $this->tpl->assign('feed_type', $this->conf->get('feed.show_atom', true) !== false ? 'atom' : 'rss'); $this->tpl->assign('hide_timestamps', $this->conf->get('privacy.hide_timestamps', false)); $this->tpl->assign('token', getToken($this->conf)); + if ($this->linkDB !== null) { + $this->tpl->assign('tags', $this->linkDB->allTags()); + } // To be removed with a proper theme configuration. $this->tpl->assign('conf', $this->conf); } diff --git a/application/Router.php b/application/Router.php index caed4a2..c9a5191 100644 --- a/application/Router.php +++ b/application/Router.php @@ -31,6 +31,8 @@ class Router public static $PAGE_EDITLINK = 'edit_link'; + public static $PAGE_DELETELINK = 'delete_link'; + public static $PAGE_EXPORT = 'export'; public static $PAGE_IMPORT = 'import'; @@ -120,6 +122,10 @@ class Router return self::$PAGE_EDITLINK; } + if (isset($get['delete_link'])) { + return self::$PAGE_DELETELINK; + } + if (startsWith($query, 'do='. self::$PAGE_EXPORT)) { return self::$PAGE_EXPORT; } diff --git a/application/ThemeUtils.php b/application/ThemeUtils.php new file mode 100644 index 0000000..2718ed1 --- /dev/null +++ b/application/ThemeUtils.php @@ -0,0 +1,33 @@ + 'Europe', + * ], + * [ + * ['continent' => 'America', 'city' => 'Toronto'], + * ['continent' => 'Europe', 'city' => 'Paris'], + * 'selected' => 'Paris', + * ], + * ]; * + * Notes: + * - 'UTC/UTC' is mapped to 'UTC' to form a valid option + * - a few timezone cities includes the country/state, such as Argentina/Buenos_Aires + * - these arrays are designed to build timezone selects in template files with any HTML structure + * + * @param array $installedTimeZones List of installed timezones as string * @param string $preselectedTimezone preselected timezone (optional) * - * @return array containing the generated HTML form and Javascript code + * @return array[] continents and cities **/ -function generateTimeZoneForm($preselectedTimezone='') +function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '') { - // Select the server timezone - if ($preselectedTimezone == '') { - $preselectedTimezone = date_default_timezone_get(); - } - if ($preselectedTimezone == 'UTC') { $pcity = $pcontinent = 'UTC'; } else { @@ -27,62 +46,30 @@ function generateTimeZoneForm($preselectedTimezone='') $pcity = substr($preselectedTimezone, $spos+1); } - // The list is in the form 'Europe/Paris', 'America/Argentina/Buenos_Aires' - // We split the list in continents/cities. - $continents = array(); - $cities = array(); - - // TODO: use a template to generate the HTML/Javascript form - - foreach (timezone_identifiers_list() as $tz) { + $continents = []; + $cities = []; + foreach ($installedTimeZones as $tz) { if ($tz == 'UTC') { $tz = 'UTC/UTC'; } $spos = strpos($tz, '/'); - if ($spos !== false) { - $continent = substr($tz, 0, $spos); - $city = substr($tz, $spos+1); - $continents[$continent] = 1; - - if (!isset($cities[$continent])) { - $cities[$continent] = ''; - } - $cities[$continent] .= '