diff --git a/.dev/.eslintrc.js b/.dev/.eslintrc.js new file mode 100644 index 0000000..151b785 --- /dev/null +++ b/.dev/.eslintrc.js @@ -0,0 +1,12 @@ +module.exports = { + "extends": "airbnb-base", + "env": { + "browser": true, + }, + "rules": { + "no-param-reassign": 0, // manipulate DOM style properties + "no-restricted-globals": 0, // currently Shaarli uses alert/confirm, could be be improved later + "no-alert": 0, // currently Shaarli uses alert/confirm, could be be improved later + "no-cond-assign": [2, "except-parens"], // assignment in while loops is readable and avoid assignment duplication + } +}; diff --git a/.dev/.sasslintrc b/.dev/.sasslintrc new file mode 100644 index 0000000..ac406d7 --- /dev/null +++ b/.dev/.sasslintrc @@ -0,0 +1,15 @@ +options: + max-warnings: 0 +rules: + property-sort-order: + - 1 + - + order: 'concentric' + no-important: + - 0 + no-vendor-prefixes: + - 0 # this will be fixed with v2: see https://github.com/sasstools/sass-lint/pull/1137 + nesting-depth: + - 1 + - + max-depth: 4 diff --git a/.dockerignore b/.dockerignore index cdd0a89..96fd31c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,9 @@ .github tests +# Docker Compose resources +docker-compose.yml + # Shaarli runtime resources cache/* data/* @@ -35,10 +38,17 @@ phpmd.html # User plugin configuration plugins/*/config.php -# HTML documentation -doc/html/ - # 3rd party themes tpl/* !tpl/default !tpl/vintage + +# Front end +node_modules +tpl/default/js +tpl/default/css +tpl/default/fonts +tpl/default/img +tpl/vintage/js +tpl/vintage/css +tpl/vintage/img diff --git a/.editorconfig b/.editorconfig index 4a6589a..34bd799 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,7 +10,7 @@ trim_trailing_whitespace = true indent_style = space indent_size = 4 -[*.{htaccess,html,xml}] +[*.{htaccess,html,scss,js,json,xml,yml}] indent_size = 2 [*.php] diff --git a/.gitattributes b/.gitattributes index 0007056..9a92bc3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -25,18 +25,21 @@ Dockerfile text *.mo binary # Exclude from Git archives -.editorconfig export-ignore -.gitattributes export-ignore -.github export-ignore -.gitignore export-ignore -.travis.yml export-ignore -doc/**/*.json export-ignore -doc/**/*.md export-ignore -.docker/ export-ignore -.dockerignore export-ignore -Dockerfile* export-ignore -Doxyfile export-ignore -Makefile export-ignore -mkdocs.yml export-ignore -phpunit.xml export-ignore -tests/ export-ignore +.editorconfig export-ignore +.dev export-ignore +.gitattributes export-ignore +.github export-ignore +.gitignore export-ignore +.travis.yml export-ignore +doc/**/*.json export-ignore +doc/**/*.md export-ignore +.docker/ export-ignore +.dockerignore export-ignore +docker-compose.* export-ignore +Dockerfile* export-ignore +Doxyfile export-ignore +Makefile export-ignore +node_modules/ export-ignore +mkdocs.yml export-ignore +phpunit.xml export-ignore +tests/ export-ignore diff --git a/.gitignore b/.gitignore index dc05c17..ac78042 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,13 @@ tpl/* contact.php formStyle.css + +# Front end +node_modules +tpl/default/js +tpl/default/css +tpl/default/fonts +tpl/default/img +tpl/vintage/js +tpl/vintage/css +tpl/vintage/img diff --git a/.htaccess b/.htaccess index 7ba4744..4c00427 100644 --- a/.htaccess +++ b/.htaccess @@ -14,3 +14,35 @@ RewriteRule .* - [e=HTTP_AUTHORIZATION:%1] RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.php [QSA,L] + + + + = 2.4> + Require all granted + + + Allow from all + Deny from none + + + + + Require all granted + + + + + + = 2.4> + Require all denied + + + Allow from none + Deny from all + + + + + Require all denied + + diff --git a/.travis.yml b/.travis.yml index 322e433..cb81846 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,20 +1,53 @@ sudo: false dist: trusty -language: php + +matrix: + include: + - language: php + php: 7.2 + - language: php + php: 7.1 + - language: php + php: 7.0 + - language: php + php: 5.6 + - language: node_js + node_js: 8 + cache: + yarn: true + directories: + - $HOME/.cache/yarn + + install: + - yarn install + + before_script: + - PATH=${PATH//:\.\/node_modules\/\.bin/} + + script: + - yarn run build # Just to be sure that the build isn't broken + - make eslint + - make sasslint + - language: python + python: 3.6 + cache: + directories: + - $HOME/.cache/pip + install: + - pip install mkdocs + script: + - mkdocs build --clean + cache: directories: - $HOME/.composer/cache -php: - - 7.1 - - 7.0 - - 5.6 - - 5.5 + install: - - composer self-update - composer install --prefer-dist - - locale -a + before_script: - PATH=${PATH//:\.\/node_modules\/\.bin/} + script: - make clean - make check_permissions diff --git a/AUTHORS b/AUTHORS index c0414c0..db23ad3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,6 +1,6 @@ - 588 ArthurHoaro - 283 VirtualTam - 179 nodiscc + 687 ArthurHoaro + 355 VirtualTam + 195 nodiscc 56 Sébastien Sauvage 15 Florian Eula 13 Emilien Klein @@ -9,12 +9,15 @@ 8 Christophe HENRY 6 B. van Berkum 5 Lucas Cimon + 5 Mark Schmitz + 5 kalvn 4 Alexandre Alapetite 4 David Sferruzza 4 Immánuel Fodor - 4 kalvn 3 Teromene + 3 llune 2 Chris Kuethe + 2 Felix Bartels 2 Knah Tsaeb 2 Mathieu Chabanon 2 Miloš Jovanović @@ -23,20 +26,26 @@ 2 Timo Van Neerden 2 julienCXX 2 philipp-r + 2 pips 1 Adrien Oliva + 1 Adrien le Maire + 1 Alexandre G.-Raymond 1 Alexis J + 1 Angristan 1 BoboTiG 1 Bronco + 1 Buster One <37770318+buster-one@users.noreply.github.com> 1 D Low 1 Daniel Jakots + 1 Dennis Verspuij 1 Dimtion 1 Fanch - 1 Felix Bartels 1 Felix Kästner 1 Florian Voigt 1 Franck Kerbiriou 1 Gary Marigliano 1 Guillaume Virlet + 1 Jonathan Amiez 1 Jonathan Druart 1 Julien Pivotto 1 Kevin Canévet @@ -49,3 +58,4 @@ 1 TsT 1 dimtion 1 durcheinandr + 1 lapineige diff --git a/CHANGELOG.md b/CHANGELOG.md index b5fd3f2..67ac59e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,89 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [v0.10.2](https://github.com/shaarli/Shaarli/releases/tag/v0.10.2) - 2018-08-11 + +### Fixed + +- Docker build + +## [v0.10.1](https://github.com/shaarli/Shaarli/releases/tag/v0.10.1) - 2018-08-11 + +### Changed + +- Accessibility: + - Remove alt text on the logo + - Remove redundant title in tools page + +### Fixed + +- Fixed an error on the daily page and daily RSS +- Fixed an issue causing 'You are not authorized to add a link' error while logged out +- Fixed thumbnail path when Shaarli's path uses symbolic links +- Add a `mod_version` check in Shaarli's root `.htaccess` file for Apache 2.2 syntax +- Include assets in the release Makefile target + +### Removed + +- Firefox Social API shaare has been removed + +## [v0.10.0](https://github.com/shaarli/Shaarli/releases/tag/v0.10.0) - 2018-07-28 +**PHP 5.5 compatibility has been dropped.** Shaarli now requires at least PHP 5.6. + +### Added +- Add a filter to display public links only +- Add PHP 7.2 support +- Add German translation +- Resolve front-end dependencies from NPM +- Build front-end bundles with Yarn and Webpack +- Lint Javascript code with ESLint +- Lint SASS code with SASSLint +- Support redirection in cURL download callback +- Introduce multi-stage builds for Docker images +- Use Travis matrix and stages to run Javascript tests in a dedicated environment +- Add tag endpoint in the REST API +- Build the documentation in Travis builds +- Provide a Docker Compose example + +### Changed +- Use web-thumbnailer to retrieve thumbnails (see #687) +- Use a specific page title in all pages +- Daily: run hooks before creating the columns +- Load theme translations files automatically +- Make max download size and timeout configurable +- Make Nginx logs accessible as stdout/stderr for Docker images +- Update buttons used to toggle link visibility filters +- Rewrite Javascript code for ES6 compliance +- Refactor IP ban management +- Refactor user login management +- Refactor server-side session management +- Update Doxygen configuration +- Update Parsedown +- Improve documentation +- Docker: build the images from the local sources +- Docker: bump alpine version to 3.7 +- Docker: expose a volume for the thumbnail cache + +### Removed +- Drop support for PHP 5.5 +- Remove vendored front-end libraries +- Remove environment specific .gitignore entries + +### Fixed +- Ignore the case while checking DOCTYPE during the file import +- Fix removal of on=... attributes from html generated from Markdown +- httpd: always forward the 'Authorization' header +- Ensure user-specific CSS file is loaded +- Fix feed permalink rendering when Markdown escaping is enabled +- Fix order of tags with the same number of occurrences +- Fixed the referrer meta tag in default template +- Disable MkDocs' strict mode for ReadTheDocs builds to pass +- fix and simplify Dockerfile for armhf + +### Security +- Update `.htaccess` to prevent accessing Git metadata when using a Git-based installation + + ## [v0.9.7](https://github.com/shaarli/Shaarli/releases/tag/v0.9.7) - 2018-06-20 ### Changed - Build the Docker images from the local Git sources @@ -240,6 +323,19 @@ Theming: - Editing a link created before the new ID system would change its permalink. +## [v0.8.7](https://github.com/shaarli/Shaarli/releases/tag/v0.8.7) - 2018-06-20 +### Changed +- Build the Docker image from the local Git sources + +### Removed +- Disable PHP 5.3 Travis build (unsupported) + + +## [v0.8.6](https://github.com/shaarli/Shaarli/releases/tag/v0.8.6) - 2018-02-19 +### Changed +- Run version check tests against the 'stable' branch + + ## [v0.8.5](https://github.com/shaarli/Shaarli/releases/tag/v0.8.5) - 2018-01-04 **XSS vulnerability fixed. Please update.** diff --git a/COPYING b/COPYING index 0520215..af13975 100644 --- a/COPYING +++ b/COPYING @@ -1,55 +1,57 @@ Files: * License: zlib/libpng Copyright: (c) 2011-2015 Sébastien SAUVAGE - (c) 2011-2017 The Shaarli Community, see AUTHORS + (c) 2011-2018 The Shaarli Community, see AUTHORS -Files: inc/reset.css +Files: assets/vintage/css/reset.css License: BSD (http://opensource.org/licenses/BSD-3-Clause) Copyright: (c) 2010, Yahoo! Inc. -Files: images/calendar.png, images/edit_icon.png, images/feed-icon-14x14.png, images/private.png, images/private_16x16.png, images/private_16x16_active.png, images/tag_blue.png +Files: assets/vintage/img/calendar.png + assets/vintage/img/edit_icon.png + assets/vintage/img/feed-icon-14x14.png + assets/vintage/img/private.png + assets/vintage/img/private_16x16.png + assets/vintage/img/private_16x16_active.png + assets/vintage/img/tag_blue.png License: CC-BY (http://creativecommons.org/licenses/by/3.0/) Copyright: (c) 2014 Yusuke Kamiyamane Source: http://p.yusukekamiyamane.com/ -Files: images/delete_icon.png +Files: assets/vintage/img/delete_icon.png 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/squiggle_closing.png +Files: assets/vintage/img/floral_left.png + assets/vintage/img/floral_right.png + assets/vintage/img/squiggle.png + assets/vintage/img/squiggle_closing.png Licence: Public Domain Source: https://openclipart.org/people/j4p4n/j4p4n_ornimental_bookend_-_left.svg -Files: images/Paper_texture_v5_by_bashcorpo_w1000.jpg +Files: assets/vintage/img/Paper_texture_v5_by_bashcorpo_w1000.jpg Licence: Public Domain Source: http://bashcorpo.deviantart.com/art/Grungy-paper-texture-v-5-22966998 -Files: images/logo.png +Files: assets/vintage/img/logo.png + assets/vintage/img/logo.png License: zlib/libpng Copyright: (c) 2011-2014 idleman idleman@idleman.fr -Files: inc/blazy*.js +Files: assets/default/img/sad_star.png License: MIT License (http://opensource.org/licenses/MIT) -Copyright: (C) Bjoern Klinggaard - @bklinggaard - http://dinbror.dk/blazy +Copyright: (C) 2015 kalvn - https://github.com/kalvn/Shaarli-Material Files: inc/rain.tpl.class.php +License: LGPL-3+ (https://www.gnu.org/licenses/lgpl-3.0.txt) Copyright: 2011-2012, Federico Ulfo 2011-2012, The Rain Team -License: LGPL-3+ (https://www.gnu.org/licenses/lgpl-3.0.txt) - -Files: inc/awesomplete* -License: MIT License (http://opensource.org/licenses/MIT) -Copyright: (C) 2015 Lea Verou - https://github.com/LeaVerou/awesomplete 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/Dockerfile b/Dockerfile index 93146c5..d8921ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ FROM python:3-alpine as docs ADD . /usr/src/app/shaarli RUN cd /usr/src/app/shaarli \ && pip install --no-cache-dir mkdocs \ - && mkdocs build + && mkdocs build --clean # Stage 2: # - Resolve PHP dependencies with Composer @@ -15,8 +15,17 @@ RUN cd shaarli \ && composer --prefer-dist --no-dev install # Stage 3: +# - Frontend dependencies +FROM node:9.9-alpine as node +COPY --from=composer /app/shaarli shaarli +RUN cd shaarli \ + && yarn install \ + && yarn run build \ + && rm -rf node_modules + +# Stage 4: # - Shaarli image -FROM alpine:3.6 +FROM alpine:3.8 LABEL maintainer="Shaarli Community" RUN apk --update --no-cache add \ @@ -47,12 +56,13 @@ RUN rm -rf /etc/php7/php-fpm.d/www.conf \ WORKDIR /var/www -COPY --from=composer /app/shaarli shaarli +COPY --from=node /shaarli shaarli RUN chown -R nginx:nginx . \ && ln -sf /dev/stdout /var/log/nginx/shaarli.access.log \ && ln -sf /dev/stderr /var/log/nginx/shaarli.error.log +VOLUME /var/www/shaarli/cache VOLUME /var/www/shaarli/data EXPOSE 80 diff --git a/Doxyfile b/Doxyfile index 9a596b5..a7f6e04 100644 --- a/Doxyfile +++ b/Doxyfile @@ -51,7 +51,7 @@ PROJECT_BRIEF = "The personal, minimalist, super-fast, no-database deli # pixels and the maximum width should not exceed 200 pixels. Doxygen will copy # the logo to the output directory. -PROJECT_LOGO = images/logo.png +PROJECT_LOGO = doc/md/images/logo.png # The OUTPUT_DIRECTORY tag is used to specify the (relative or absolute) path # into which the generated documentation will be written. If a relative path is @@ -804,6 +804,7 @@ RECURSIVE = YES # run. EXCLUDE = vendor \ + data \ tpl \ inc \ doc \ diff --git a/Makefile b/Makefile index d659d90..56cf09b 100644 --- a/Makefile +++ b/Makefile @@ -157,21 +157,32 @@ composer_dependencies: clean composer install --no-dev --prefer-dist find vendor/ -name ".git" -type d -exec rm -rf {} + +### download 3rd-party frontend libraries +frontend_dependencies: + yarn install + +### Build frontend dependencies +build_frontend: frontend_dependencies + yarn run build + ### generate a release tarball and include 3rd-party dependencies and translations -release_tar: composer_dependencies htmldoc translate +release_tar: composer_dependencies htmldoc translate build_frontend git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).tar HEAD tar rvf $(ARCHIVE_VERSION).tar --transform "s|^vendor|$(ARCHIVE_PREFIX)vendor|" vendor/ tar rvf $(ARCHIVE_VERSION).tar --transform "s|^doc/html|$(ARCHIVE_PREFIX)doc/html|" doc/html/ + tar rvf $(ARCHIVE_VERSION).tar --transform "s|^tpl|$(ARCHIVE_PREFIX)tpl|" tpl/ gzip $(ARCHIVE_VERSION).tar ### generate a release zip and include 3rd-party dependencies and translations -release_zip: composer_dependencies htmldoc translate +release_zip: composer_dependencies htmldoc translate build_frontend git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).zip -9 HEAD mkdir -p $(ARCHIVE_PREFIX)/{doc,vendor} rsync -a doc/html/ $(ARCHIVE_PREFIX)doc/html/ zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)doc/ rsync -a vendor/ $(ARCHIVE_PREFIX)vendor/ zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)vendor/ + rsync -a tpl/ $(ARCHIVE_PREFIX)tpl/ + zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)tpl/ rm -rf $(ARCHIVE_PREFIX) ## @@ -192,18 +203,27 @@ authors: ### generate Doxygen documentation doxygen: clean @rm -rf doxygen - @( cat Doxyfile ; echo "PROJECT_NUMBER=`git describe`" ) | doxygen - + @doxygen Doxyfile ### generate HTML documentation from Markdown pages with MkDocs htmldoc: python3 -m venv venv/ bash -c 'source venv/bin/activate; \ pip install mkdocs; \ - mkdocs build' + mkdocs build --clean' find doc/html/ -type f -exec chmod a-x '{}' \; rm -r venv ### Generate Shaarli's translation compiled file (.mo) translate: - @find inc/languages/ -name shaarli.po -execdir msgfmt shaarli.po -o shaarli.mo \; \ No newline at end of file + @find inc/languages/ -name shaarli.po -execdir msgfmt shaarli.po -o shaarli.mo \; + +### Run ESLint check against Shaarli's JS files +eslint: + @yarn run eslint -c .dev/.eslintrc.js assets/vintage/js/ + @yarn run eslint -c .dev/.eslintrc.js assets/default/js/ + +### Run CSSLint check against Shaarli's SCSS files +sasslint: + @yarn run sass-lint -c .dev/.sasslintrc 'assets/default/scss/*.scss' -v -q diff --git a/README.md b/README.md index 7744d2f..2ff5460 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ _Do you want to share the links you discover?_ _Shaarli is a minimalist link sharing service that you can install on your own server._ _It is designed to be personal (single-user), fast and handy._ -[![](https://img.shields.io/badge/stable-v0.8.5-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.8.5) +[![](https://img.shields.io/badge/stable-v0.9.7-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.9.7) [![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli) • -[![](https://img.shields.io/badge/latest-v0.9.4-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.9.4) +[![](https://img.shields.io/badge/latest-v0.10.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.10.1) [![](https://img.shields.io/travis/shaarli/Shaarli/latest.svg?label=latest)](https://travis-ci.org/shaarli/Shaarli) • [![](https://img.shields.io/badge/master-v0.10.x-blue.svg)](https://github.com/shaarli/Shaarli) diff --git a/application/FileUtils.php b/application/FileUtils.php index 918cb83..b89ea12 100644 --- a/application/FileUtils.php +++ b/application/FileUtils.php @@ -37,7 +37,7 @@ class FileUtils 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))) { + } elseif (!is_file($file) && !is_writeable(dirname($file))) { // The datastore does not exist and its parent directory is not writeable throw new IOException(dirname($file)); } diff --git a/application/HttpUtils.php b/application/HttpUtils.php index 83a4c5e..e928250 100644 --- a/application/HttpUtils.php +++ b/application/HttpUtils.php @@ -1,7 +1,7 @@ translator->setLanguage($this->language); $this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages'); + // Default extension translation from the current theme + $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $this->conf->get('theme') .'/language'; + if (is_dir($themeTransFolder)) { + $this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false); + } + foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) { if ($domain !== self::DEFAULT_DOMAIN) { $this->translator->loadDomain($domain, $translationPath, false); @@ -116,12 +122,23 @@ class Languages $translations = new Translations(); // Core translations try { - /** @var Translations $translations */ $translations = $translations->addFromPoFile('inc/languages/'. $this->language .'/LC_MESSAGES/shaarli.po'); $translations->setDomain('shaarli'); $this->translator->loadTranslations($translations); } catch (\InvalidArgumentException $e) {} + // Default extension translation from the current theme + $theme = $this->conf->get('theme'); + $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $theme .'/language'; + if (is_dir($themeTransFolder)) { + try { + $translations = Translations::fromPoFile( + $themeTransFolder .'/'. $this->language .'/LC_MESSAGES/'. $theme .'.po' + ); + $translations->setDomain($theme); + $this->translator->loadTranslations($translations); + } catch (\InvalidArgumentException $e) {} + } // Extension translations (plugins, themes, etc.). foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) { @@ -130,7 +147,6 @@ class Languages } try { - /** @var Translations $extension */ $extension = Translations::fromPoFile($translationPath . $this->language .'/LC_MESSAGES/'. $domain .'.po'); $extension->setDomain($domain); $this->translator->loadTranslations($extension); @@ -161,6 +177,7 @@ class Languages 'auto' => t('Automatic'), 'en' => t('English'), 'fr' => t('French'), + 'de' => t('German'), ]; } } diff --git a/application/LinkDB.php b/application/LinkDB.php index c1661d5..cd0f296 100644 --- a/application/LinkDB.php +++ b/application/LinkDB.php @@ -436,15 +436,17 @@ You use the community supported version of the original Shaarli project, by Seba /** * Returns the list tags appearing in the links with the given tags - * @param $filteringTags: tags selecting the links to consider - * @param $visibility: process only all/private/public links - * @return: a tag=>linksCount array + * + * @param array $filteringTags tags selecting the links to consider + * @param string $visibility process only all/private/public links + * + * @return array tag => linksCount */ public function linksCountPerTag($filteringTags = [], $visibility = 'all') { - $links = empty($filteringTags) ? $this->links : $this->filterSearch(['searchtags' => $filteringTags], false, $visibility); - $tags = array(); - $caseMapping = array(); + $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility); + $tags = []; + $caseMapping = []; foreach ($links as $link) { foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) { if (empty($tag)) { @@ -458,8 +460,19 @@ You use the community supported version of the original Shaarli project, by Seba $tags[$caseMapping[strtolower($tag)]]++; } } - // Sort tags by usage (most used tag first) - arsort($tags); + + /* + * Formerly used arsort(), which doesn't define the sort behaviour for equal values. + * Also, this function doesn't produce the same result between PHP 5.6 and 7. + * + * So we now use array_multisort() to sort tags by DESC occurrences, + * then ASC alphabetically for equal values. + * + * @see https://github.com/shaarli/Shaarli/issues/1142 + */ + $keys = array_keys($tags); + $tmpTags = array_combine($keys, $keys); + array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); return $tags; } diff --git a/application/LinkFilter.php b/application/LinkFilter.php index 12376e2..e52239b 100644 --- a/application/LinkFilter.php +++ b/application/LinkFilter.php @@ -117,7 +117,7 @@ class LinkFilter foreach ($this->links as $key => $value) { if ($value['private'] && $visibility === 'private') { $out[$key] = $value; - } else if (! $value['private'] && $visibility === 'public') { + } elseif (! $value['private'] && $visibility === 'public') { $out[$key] = $value; } } @@ -210,7 +210,7 @@ class LinkFilter if ($visibility !== 'all') { if (! $link['private'] && $visibility === 'private') { continue; - } else if ($link['private'] && $visibility === 'public') { + } elseif ($link['private'] && $visibility === 'public') { continue; } } @@ -337,7 +337,7 @@ class LinkFilter if ($visibility !== 'all') { if (! $link['private'] && $visibility === 'private') { continue; - } else if ($link['private'] && $visibility === 'public') { + } elseif ($link['private'] && $visibility === 'public') { continue; } } @@ -380,7 +380,7 @@ class LinkFilter if ($visibility !== 'all') { if (! $link['private'] && $visibility === 'private') { continue; - } else if ($link['private'] && $visibility === 'public') { + } elseif ($link['private'] && $visibility === 'public') { continue; } } diff --git a/application/LinkUtils.php b/application/LinkUtils.php index 3705f7e..4df5c0c 100644 --- a/application/LinkUtils.php +++ b/application/LinkUtils.php @@ -11,6 +11,7 @@ */ function get_curl_download_callback(&$charset, &$title, $curlGetInfo = 'curl_getinfo') { + $isRedirected = false; /** * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download). * @@ -22,16 +23,24 @@ function get_curl_download_callback(&$charset, &$title, $curlGetInfo = 'curl_get * * @return int|bool length of $data or false if we need to stop the download */ - return function(&$ch, $data) use ($curlGetInfo, &$charset, &$title) { + return function(&$ch, $data) use ($curlGetInfo, &$charset, &$title, &$isRedirected) { $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE); - if (!empty($responseCode) && $responseCode != 200) { + if (!empty($responseCode) && in_array($responseCode, [301, 302])) { + $isRedirected = true; + return strlen($data); + } + if (!empty($responseCode) && $responseCode !== 200) { return false; } - $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE); + // After a redirection, the content type will keep the previous request value + // until it finds the next content-type header. + if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) { + $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE); + } if (!empty($contentType) && strpos($contentType, 'text/html') === false) { return false; } - if (empty($charset)) { + if (!empty($contentType) && empty($charset)) { $charset = header_extract_charset($contentType); } if (empty($charset)) { diff --git a/application/NetscapeBookmarkUtils.php b/application/NetscapeBookmarkUtils.php index dd7057f..b4d16d0 100644 --- a/application/NetscapeBookmarkUtils.php +++ b/application/NetscapeBookmarkUtils.php @@ -108,7 +108,7 @@ class NetscapeBookmarkUtils $filesize = $files['filetoupload']['size']; $data = file_get_contents($files['filetoupload']['tmp_name']); - if (strpos($data, '') === false) { + if (preg_match('//i', $data) === 0) { return self::importStatus($filename, $filesize); } @@ -154,13 +154,13 @@ class NetscapeBookmarkUtils if (empty($post['privacy']) || $post['privacy'] == 'default') { // use value from the imported file $private = $bkm['pub'] == '1' ? 0 : 1; - } else if ($post['privacy'] == 'private') { + } elseif ($post['privacy'] == 'private') { // all imported links are private $private = 1; - } else if ($post['privacy'] == 'public') { + } elseif ($post['privacy'] == 'public') { // all imported links are public $private = 0; - } + } $newLink = array( 'title' => $bkm['title'], diff --git a/application/PageBuilder.php b/application/PageBuilder.php index 468f144..b1abe0d 100644 --- a/application/PageBuilder.php +++ b/application/PageBuilder.php @@ -1,6 +1,7 @@ tpl = false; $this->conf = $conf; + $this->session = $session; $this->linkDB = $linkDB; $this->token = $token; + $this->isLoggedIn = $isLoggedIn; } /** @@ -55,7 +73,7 @@ class PageBuilder $this->conf->get('resource.update_check'), $this->conf->get('updates.check_updates_interval'), $this->conf->get('updates.check_updates'), - isLoggedIn(), + $this->isLoggedIn, $this->conf->get('updates.check_updates_branch') ); $this->tpl->assign('newVersion', escape($version)); @@ -67,6 +85,7 @@ class PageBuilder $this->tpl->assign('versionError', escape($exc->getMessage())); } + $this->tpl->assign('is_logged_in', $this->isLoggedIn); $this->tpl->assign('feedurl', escape(index_url($_SERVER))); $searchcrits = ''; // Search criteria if (!empty($_GET['searchtags'])) { @@ -83,7 +102,8 @@ class PageBuilder ApplicationUtils::getVersionHash(SHAARLI_VERSION, $this->conf->get('credentials.salt')) ); $this->tpl->assign('scripturl', index_url($_SERVER)); - $this->tpl->assign('privateonly', !empty($_SESSION['privateonly'])); // Show only private links? + $visibility = ! empty($_SESSION['visibility']) ? $_SESSION['visibility'] : ''; + $this->tpl->assign('visibility', $visibility); $this->tpl->assign('untaggedonly', !empty($_SESSION['untaggedonly'])); $this->tpl->assign('pagetitle', $this->conf->get('general.title', 'Shaarli')); if ($this->conf->exists('general.header_link')) { @@ -99,6 +119,19 @@ class PageBuilder if ($this->linkDB !== null) { $this->tpl->assign('tags', $this->linkDB->linksCountPerTag()); } + + $this->tpl->assign( + 'thumbnails_enabled', + $this->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE + ); + $this->tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width')); + $this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height')); + + if (! empty($_SESSION['warnings'])) { + $this->tpl->assign('global_warnings', $_SESSION['warnings']); + unset($_SESSION['warnings']); + } + // 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 4df0387..bf86b88 100644 --- a/application/Router.php +++ b/application/Router.php @@ -7,6 +7,8 @@ */ class Router { + public static $AJAX_THUMB_UPDATE = 'ajax_thumb_update'; + public static $PAGE_LOGIN = 'login'; public static $PAGE_PICWALL = 'picwall'; @@ -47,6 +49,8 @@ class Router public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin'; + public static $PAGE_THUMBS_UPDATE = 'thumbs_update'; + public static $GET_TOKEN = 'token'; /** @@ -101,6 +105,14 @@ class Router return self::$PAGE_FEED_RSS; } + if (startsWith($query, 'do='. self::$PAGE_THUMBS_UPDATE)) { + return self::$PAGE_THUMBS_UPDATE; + } + + if (startsWith($query, 'do='. self::$AJAX_THUMB_UPDATE)) { + return self::$AJAX_THUMB_UPDATE; + } + // At this point, only loggedin pages. if (!$loggedIn) { return self::$PAGE_LINKLIST; diff --git a/application/SessionManager.php b/application/SessionManager.php deleted file mode 100644 index 71f0b38..0000000 --- a/application/SessionManager.php +++ /dev/null @@ -1,83 +0,0 @@ -session = &$session; - $this->conf = $conf; - } - - /** - * Generates a session token - * - * @return string token - */ - public function generateToken() - { - $token = sha1(uniqid('', true) .'_'. mt_rand() . $this->conf->get('credentials.salt')); - $this->session['tokens'][$token] = 1; - return $token; - } - - /** - * Checks the validity of a session token, and destroys it afterwards - * - * @param string $token The token to check - * - * @return bool true if the token is valid, else false - */ - public function checkToken($token) - { - if (! isset($this->session['tokens'][$token])) { - // the token is wrong, or has already been used - return false; - } - - // destroy the token to prevent future use - unset($this->session['tokens'][$token]); - return true; - } - - /** - * Validate session ID to prevent Full Path Disclosure. - * - * See #298. - * The session ID's format depends on the hash algorithm set in PHP settings - * - * @param string $sessionId Session ID - * - * @return true if valid, false otherwise. - * - * @see http://php.net/manual/en/function.hash-algos.php - * @see http://php.net/manual/en/session.configuration.php - */ - public static function checkId($sessionId) - { - if (empty($sessionId)) { - return false; - } - - if (!$sessionId) { - return false; - } - - if (!preg_match('/^[a-zA-Z0-9,-]{2,128}$/', $sessionId)) { - return false; - } - - return true; - } -} diff --git a/application/Thumbnailer.php b/application/Thumbnailer.php new file mode 100644 index 0000000..7d0d9c3 --- /dev/null +++ b/application/Thumbnailer.php @@ -0,0 +1,127 @@ +conf = $conf; + + if (! $this->checkRequirements()) { + $this->conf->set('thumbnails.enabled', false); + $this->conf->write(true); + // TODO: create a proper error handling system able to catch exceptions... + die(t('php-gd extension must be loaded to use thumbnails. Thumbnails are now disabled. Please reload the page.')); + } + + $this->wt = new WebThumbnailer(); + WTConfigManager::addFile('inc/web-thumbnailer.json'); + $this->wt->maxWidth($this->conf->get('thumbnails.width')) + ->maxHeight($this->conf->get('thumbnails.height')) + ->crop(true) + ->debug($this->conf->get('dev.debug', false)); + } + + /** + * Retrieve a thumbnail for given URL + * + * @param string $url where to look for a thumbnail. + * + * @return bool|string The thumbnail relative cache file path, or false if none has been found. + */ + public function get($url) + { + if ($this->conf->get('thumbnails.mode') === self::MODE_COMMON + && ! $this->isCommonMediaOrImage($url) + ) { + return false; + } + + try { + return $this->wt->thumbnail($url); + } catch (WebThumbnailerException $e) { + // Exceptions are only thrown in debug mode. + error_log(get_class($e) . ': ' . $e->getMessage()); + } + return false; + } + + /** + * We check weather the given URL is from a common media domain, + * or if the file extension is an image. + * + * @param string $url to check + * + * @return bool true if it's an image or from a common media domain, false otherwise. + */ + public function isCommonMediaOrImage($url) + { + foreach (self::COMMON_MEDIA_DOMAINS as $domain) { + if (strpos($url, $domain) !== false) { + return true; + } + } + + if (endsWith($url, '.jpg') || endsWith($url, '.png') || endsWith($url, '.jpeg')) { + return true; + } + + return false; + } + + /** + * Make sure that requirements are match to use thumbnails: + * - php-gd is loaded + */ + protected function checkRequirements() + { + return extension_loaded('gd'); + } +} diff --git a/application/Updater.php b/application/Updater.php index 8d2bd57..480bff8 100644 --- a/application/Updater.php +++ b/application/Updater.php @@ -2,6 +2,7 @@ use Shaarli\Config\ConfigJson; use Shaarli\Config\ConfigPhp; use Shaarli\Config\ConfigManager; +use Shaarli\Thumbnailer; /** * Class Updater. @@ -30,6 +31,11 @@ class Updater */ protected $isLoggedIn; + /** + * @var array $_SESSION + */ + protected $session; + /** * @var ReflectionMethod[] List of current class methods. */ @@ -42,13 +48,17 @@ class Updater * @param LinkDB $linkDB LinkDB instance. * @param ConfigManager $conf Configuration Manager instance. * @param boolean $isLoggedIn True if the user is logged in. + * @param array $session $_SESSION (by reference) + * + * @throws ReflectionException */ - public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn) + public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn, &$session = []) { $this->doneUpdates = $doneUpdates; $this->linkDB = $linkDB; $this->conf = $conf; $this->isLoggedIn = $isLoggedIn; + $this->session = &$session; // Retrieve all update methods. $class = new ReflectionClass($this); @@ -445,6 +455,68 @@ class Updater $this->linkDB->save($this->conf->get('resource.page_cache')); return true; } + + /** + * Change privateonly session key to visibility. + */ + public function updateMethodVisibilitySession() + { + if (isset($_SESSION['privateonly'])) { + unset($_SESSION['privateonly']); + $_SESSION['visibility'] = 'private'; + } + return true; + } + + /** + * Add download size and timeout to the configuration file + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodDownloadSizeAndTimeoutConf() + { + if ($this->conf->exists('general.download_max_size') + && $this->conf->exists('general.download_timeout') + ) { + return true; + } + + if (! $this->conf->exists('general.download_max_size')) { + $this->conf->set('general.download_max_size', 1024*1024*4); + } + + if (! $this->conf->exists('general.download_timeout')) { + $this->conf->set('general.download_timeout', 30); + } + + $this->conf->write($this->isLoggedIn); + return true; + } + + /** + * * Move thumbnails management to WebThumbnailer, coming with new settings. + */ + public function updateMethodWebThumbnailer() + { + if ($this->conf->exists('thumbnails.mode')) { + return true; + } + + $thumbnailsEnabled = extension_loaded('gd') && $this->conf->get('thumbnail.enable_thumbnails', true); + $this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE); + $this->conf->set('thumbnails.width', 125); + $this->conf->set('thumbnails.height', 90); + $this->conf->remove('thumbnail'); + $this->conf->write(true); + + if ($thumbnailsEnabled) { + $this->session['warnings'][] = t( + 'You have enabled or changed thumbnails mode. Please synchronize them.' + ); + } + + return true; + } } /** diff --git a/application/Url.php b/application/Url.php index b375937..6b9870f 100644 --- a/application/Url.php +++ b/application/Url.php @@ -81,7 +81,7 @@ function whitelist_protocols($url, $protocols) // Protocol not allowed: we remove it and replace it with http if ($protocol === 1 && ! in_array($match[1], $protocols)) { $url = str_replace($match[0], 'http://', $url); - } else if ($protocol !== 1) { + } elseif ($protocol !== 1) { $url = 'http://' . $url; } return $url; @@ -260,7 +260,7 @@ class Url if (! function_exists('idn_to_ascii') || ! isset($this->parts['host'])) { return $out; } - $asciiHost = idn_to_ascii($this->parts['host']); + $asciiHost = idn_to_ascii($this->parts['host'], 0, INTL_IDNA_VARIANT_UTS46); return str_replace($this->parts['host'], $asciiHost, $out); } diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php index f154bb5..fc5ecaf 100644 --- a/application/api/ApiUtils.php +++ b/application/api/ApiUtils.php @@ -134,4 +134,20 @@ class ApiUtils return $oldLink; } + + /** + * Format a Tag for the REST API. + * + * @param string $tag Tag name + * @param int $occurrences Number of links using this tag + * + * @return array Link data formatted for the REST API. + */ + public static function formatTag($tag, $occurences) + { + return [ + 'name' => $tag, + 'occurrences' => $occurences, + ]; + } } diff --git a/application/api/controllers/History.php b/application/api/controllers/History.php index 2ff9dea..5cc453b 100644 --- a/application/api/controllers/History.php +++ b/application/api/controllers/History.php @@ -36,7 +36,7 @@ class History extends ApiController if (empty($offset)) { $offset = 0; } - else if (ctype_digit($offset)) { + elseif (ctype_digit($offset)) { $offset = (int) $offset; } else { throw new ApiBadParametersException('Invalid offset'); @@ -46,7 +46,7 @@ class History extends ApiController $limit = $request->getParam('limit'); if (empty($limit)) { $limit = count($history); - } else if (ctype_digit($limit)) { + } elseif (ctype_digit($limit)) { $limit = (int) $limit; } else { throw new ApiBadParametersException('Invalid limit'); diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php index eb78dd2..ffcfd4c 100644 --- a/application/api/controllers/Links.php +++ b/application/api/controllers/Links.php @@ -59,25 +59,25 @@ class Links extends ApiController $limit = $request->getParam('limit'); if (empty($limit)) { $limit = self::$DEFAULT_LIMIT; - } else if (ctype_digit($limit)) { + } elseif (ctype_digit($limit)) { $limit = intval($limit); - } else if ($limit === 'all') { + } elseif ($limit === 'all') { $limit = count($links); } else { throw new ApiBadParametersException('Invalid limit'); } // 'environment' is set by Slim and encapsulate $_SERVER. - $index = index_url($this->ci['environment']); + $indexUrl = index_url($this->ci['environment']); $out = []; - $cpt = 0; + $index = 0; foreach ($links as $link) { if (count($out) >= $limit) { break; } - if ($cpt++ >= $offset) { - $out[] = ApiUtils::formatLink($link, $index); + if ($index++ >= $offset) { + $out[] = ApiUtils::formatLink($link, $indexUrl); } } diff --git a/application/api/controllers/Tags.php b/application/api/controllers/Tags.php new file mode 100644 index 0000000..6dd7875 --- /dev/null +++ b/application/api/controllers/Tags.php @@ -0,0 +1,161 @@ +getParam('visibility'); + $tags = $this->linkDb->linksCountPerTag([], $visibility); + + // Return tags from the {offset}th tag, starting from 0. + $offset = $request->getParam('offset'); + if (! empty($offset) && ! ctype_digit($offset)) { + throw new ApiBadParametersException('Invalid offset'); + } + $offset = ! empty($offset) ? intval($offset) : 0; + if ($offset > count($tags)) { + return $response->withJson([], 200, $this->jsonStyle); + } + + // limit parameter is either a number of links or 'all' for everything. + $limit = $request->getParam('limit'); + if (empty($limit)) { + $limit = self::$DEFAULT_LIMIT; + } + if (ctype_digit($limit)) { + $limit = intval($limit); + } elseif ($limit === 'all') { + $limit = count($tags); + } else { + throw new ApiBadParametersException('Invalid limit'); + } + + $out = []; + $index = 0; + foreach ($tags as $tag => $occurrences) { + if (count($out) >= $limit) { + break; + } + if ($index++ >= $offset) { + $out[] = ApiUtils::formatTag($tag, $occurrences); + } + } + + return $response->withJson($out, 200, $this->jsonStyle); + } + + /** + * Return a single formatted tag by its name. + * + * @param Request $request Slim request. + * @param Response $response Slim response. + * @param array $args Path parameters. including the tag name. + * + * @return Response containing the link array. + * + * @throws ApiTagNotFoundException generating a 404 error. + */ + public function getTag($request, $response, $args) + { + $tags = $this->linkDb->linksCountPerTag(); + if (!isset($tags[$args['tagName']])) { + throw new ApiTagNotFoundException(); + } + $out = ApiUtils::formatTag($args['tagName'], $tags[$args['tagName']]); + + return $response->withJson($out, 200, $this->jsonStyle); + } + + /** + * Rename a tag from the given name. + * If the new name provided matches an existing tag, they will be merged. + * + * @param Request $request Slim request. + * @param Response $response Slim response. + * @param array $args Path parameters. including the tag name. + * + * @return Response response. + * + * @throws ApiTagNotFoundException generating a 404 error. + * @throws ApiBadParametersException new tag name not provided + */ + public function putTag($request, $response, $args) + { + $tags = $this->linkDb->linksCountPerTag(); + if (! isset($tags[$args['tagName']])) { + throw new ApiTagNotFoundException(); + } + + $data = $request->getParsedBody(); + if (empty($data['name'])) { + throw new ApiBadParametersException('New tag name is required in the request body'); + } + + $updated = $this->linkDb->renameTag($args['tagName'], $data['name']); + $this->linkDb->save($this->conf->get('resource.page_cache')); + foreach ($updated as $link) { + $this->history->updateLink($link); + } + + $tags = $this->linkDb->linksCountPerTag(); + $out = ApiUtils::formatTag($data['name'], $tags[$data['name']]); + return $response->withJson($out, 200, $this->jsonStyle); + } + + /** + * Delete an existing tag by its name. + * + * @param Request $request Slim request. + * @param Response $response Slim response. + * @param array $args Path parameters. including the tag name. + * + * @return Response response. + * + * @throws ApiTagNotFoundException generating a 404 error. + */ + public function deleteTag($request, $response, $args) + { + $tags = $this->linkDb->linksCountPerTag(); + if (! isset($tags[$args['tagName']])) { + throw new ApiTagNotFoundException(); + } + $updated = $this->linkDb->renameTag($args['tagName'], null); + $this->linkDb->save($this->conf->get('resource.page_cache')); + foreach ($updated as $link) { + $this->history->updateLink($link); + } + + return $response->withStatus(204); + } +} diff --git a/application/api/exceptions/ApiTagNotFoundException.php b/application/api/exceptions/ApiTagNotFoundException.php new file mode 100644 index 0000000..eed5afa --- /dev/null +++ b/application/api/exceptions/ApiTagNotFoundException.php @@ -0,0 +1,32 @@ +message = 'Tag not found'; + } + + /** + * {@inheritdoc} + */ + public function getApiResponse() + { + return $this->buildApiResponse(404); + } +} diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php index 9e4c9f6..32aaea4 100644 --- a/application/config/ConfigManager.php +++ b/application/config/ConfigManager.php @@ -123,7 +123,7 @@ class ConfigManager * Supports nested settings with dot separated keys. * * @param string $setting Asked setting, keys separated with dots. - * @param string $value Value to set. + * @param mixed $value Value to set. * @param bool $write Write the new setting in the config file, default false. * @param bool $isLoggedIn User login state, default false. * @@ -147,6 +147,33 @@ class ConfigManager } } + /** + * Remove a config element from the config file. + * + * @param string $setting Asked setting, keys separated with dots. + * @param bool $write Write the new setting in the config file, default false. + * @param bool $isLoggedIn User login state, default false. + * + * @throws \Exception Invalid + */ + public function remove($setting, $write = false, $isLoggedIn = false) + { + if (empty($setting) || ! is_string($setting)) { + throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting)); + } + + // During the ConfigIO transition, map legacy settings to the new ones. + if ($this->configIO instanceof ConfigPhp && isset(ConfigPhp::$LEGACY_KEYS_MAPPING[$setting])) { + $setting = ConfigPhp::$LEGACY_KEYS_MAPPING[$setting]; + } + + $settings = explode('.', $setting); + self::removeConfig($settings, $this->loadedConfig); + if ($write) { + $this->write($isLoggedIn); + } + } + /** * Check if a settings exists. * @@ -272,7 +299,7 @@ class ConfigManager * * @param array $settings Ordered array which contains keys to find. * @param mixed $value - * @param array $conf Loaded settings, then sub-array. + * @param array $conf Loaded settings, then sub-array. * * @return mixed Found setting or NOT_FOUND flag. */ @@ -289,6 +316,27 @@ class ConfigManager $conf[$setting] = $value; } + /** + * Recursive function which find asked setting in the loaded config and deletes it. + * + * @param array $settings Ordered array which contains keys to find. + * @param array $conf Loaded settings, then sub-array. + * + * @return mixed Found setting or NOT_FOUND flag. + */ + protected static function removeConfig($settings, &$conf) + { + if (!is_array($settings) || count($settings) == 0) { + return self::$NOT_FOUND; + } + + $setting = array_shift($settings); + if (count($settings) > 0) { + return self::removeConfig($settings, $conf[$setting]); + } + unset($conf[$setting]); + } + /** * Set a bunch of default values allowing Shaarli to start without a config file. */ @@ -333,12 +381,12 @@ class ConfigManager // default state of the 'remember me' checkbox of the login form $this->setEmpty('privacy.remember_user_default', true); - $this->setEmpty('thumbnail.enable_thumbnails', true); - $this->setEmpty('thumbnail.enable_localcache', true); - $this->setEmpty('redirector.url', ''); $this->setEmpty('redirector.encode_url', true); + $this->setEmpty('thumbnails.width', '125'); + $this->setEmpty('thumbnails.height', '90'); + $this->setEmpty('translation.language', 'auto'); $this->setEmpty('translation.mode', 'php'); $this->setEmpty('translation.extensions', []); diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php new file mode 100644 index 0000000..d6784d6 --- /dev/null +++ b/application/security/LoginManager.php @@ -0,0 +1,265 @@ +globals = &$globals; + $this->configManager = $configManager; + $this->sessionManager = $sessionManager; + $this->banFile = $this->configManager->get('resource.ban_file', 'data/ipbans.php'); + $this->readBanFile(); + if ($this->configManager->get('security.open_shaarli') === true) { + $this->openShaarli = true; + } + } + + /** + * Generate a token depending on deployment salt, user password and client IP + * + * @param string $clientIpAddress The remote client IP address + */ + public function generateStaySignedInToken($clientIpAddress) + { + $this->staySignedInToken = sha1( + $this->configManager->get('credentials.hash') + . $clientIpAddress + . $this->configManager->get('credentials.salt') + ); + } + + /** + * Return the user's client stay-signed-in token + * + * @return string User's client stay-signed-in token + */ + public function getStaySignedInToken() + { + return $this->staySignedInToken; + } + + /** + * Check user session state and validity (expiration) + * + * @param array $cookie The $_COOKIE array + * @param string $clientIpId Client IP address identifier + */ + public function checkLoginState($cookie, $clientIpId) + { + if (! $this->configManager->exists('credentials.login')) { + // Shaarli is not configured yet + $this->isLoggedIn = false; + return; + } + + if (isset($cookie[self::$STAY_SIGNED_IN_COOKIE]) + && $cookie[self::$STAY_SIGNED_IN_COOKIE] === $this->staySignedInToken + ) { + // The user client has a valid stay-signed-in cookie + // Session information is updated with the current client information + $this->sessionManager->storeLoginInfo($clientIpId); + + } elseif ($this->sessionManager->hasSessionExpired() + || $this->sessionManager->hasClientIpChanged($clientIpId) + ) { + $this->sessionManager->logout(); + $this->isLoggedIn = false; + return; + } + + $this->isLoggedIn = true; + $this->sessionManager->extendSession(); + } + + /** + * Return whether the user is currently logged in + * + * @return true when the user is logged in, false otherwise + */ + public function isLoggedIn() + { + if ($this->openShaarli) { + return true; + } + return $this->isLoggedIn; + } + + /** + * Check user credentials are valid + * + * @param string $remoteIp Remote client IP address + * @param string $clientIpId Client IP address identifier + * @param string $login Username + * @param string $password Password + * + * @return bool true if the provided credentials are valid, false otherwise + */ + public function checkCredentials($remoteIp, $clientIpId, $login, $password) + { + $hash = sha1($password . $login . $this->configManager->get('credentials.salt')); + + if ($login != $this->configManager->get('credentials.login') + || $hash != $this->configManager->get('credentials.hash') + ) { + logm( + $this->configManager->get('resource.log'), + $remoteIp, + 'Login failed for user ' . $login + ); + return false; + } + + $this->sessionManager->storeLoginInfo($clientIpId); + logm( + $this->configManager->get('resource.log'), + $remoteIp, + 'Login successful' + ); + return true; + } + + /** + * Read a file containing banned IPs + */ + protected function readBanFile() + { + if (! file_exists($this->banFile)) { + return; + } + include $this->banFile; + } + + /** + * Write the banned IPs to a file + */ + protected function writeBanFile() + { + if (! array_key_exists('IPBANS', $this->globals)) { + return; + } + file_put_contents( + $this->banFile, + "globals['IPBANS'], true) . ";\n?>" + ); + } + + /** + * Handle a failed login and ban the IP after too many failed attempts + * + * @param array $server The $_SERVER array + */ + public function handleFailedLogin($server) + { + $ip = $server['REMOTE_ADDR']; + $trusted = $this->configManager->get('security.trusted_proxies', []); + + if (in_array($ip, $trusted)) { + $ip = getIpAddressFromProxy($server, $trusted); + if (! $ip) { + // the IP is behind a trusted forward proxy, but is not forwarded + // in the HTTP headers, so we do nothing + return; + } + } + + // increment the fail count for this IP + if (isset($this->globals['IPBANS']['FAILURES'][$ip])) { + $this->globals['IPBANS']['FAILURES'][$ip]++; + } else { + $this->globals['IPBANS']['FAILURES'][$ip] = 1; + } + + if ($this->globals['IPBANS']['FAILURES'][$ip] >= $this->configManager->get('security.ban_after')) { + $this->globals['IPBANS']['BANS'][$ip] = time() + $this->configManager->get('security.ban_duration', 1800); + logm( + $this->configManager->get('resource.log'), + $server['REMOTE_ADDR'], + 'IP address banned from login' + ); + } + $this->writeBanFile(); + } + + /** + * Handle a successful login + * + * @param array $server The $_SERVER array + */ + public function handleSuccessfulLogin($server) + { + $ip = $server['REMOTE_ADDR']; + // FIXME unban when behind a trusted proxy? + + unset($this->globals['IPBANS']['FAILURES'][$ip]); + unset($this->globals['IPBANS']['BANS'][$ip]); + + $this->writeBanFile(); + } + + /** + * Check if the user can login from this IP + * + * @param array $server The $_SERVER array + * + * @return bool true if the user is allowed to login + */ + public function canLogin($server) + { + $ip = $server['REMOTE_ADDR']; + + if (! isset($this->globals['IPBANS']['BANS'][$ip])) { + // the user is not banned + return true; + } + + if ($this->globals['IPBANS']['BANS'][$ip] > time()) { + // the user is still banned + return false; + } + + // the ban has expired, the user can attempt to log in again + logm($this->configManager->get('resource.log'), $server['REMOTE_ADDR'], 'Ban lifted.'); + unset($this->globals['IPBANS']['FAILURES'][$ip]); + unset($this->globals['IPBANS']['BANS'][$ip]); + + $this->writeBanFile(); + return true; + } +} diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php new file mode 100644 index 0000000..b8b8ab8 --- /dev/null +++ b/application/security/SessionManager.php @@ -0,0 +1,199 @@ +session = &$session; + $this->conf = $conf; + } + + /** + * Define whether the user should stay signed in across browser sessions + * + * @param bool $staySignedIn Keep the user signed in + */ + public function setStaySignedIn($staySignedIn) + { + $this->staySignedIn = $staySignedIn; + } + + /** + * Generates a session token + * + * @return string token + */ + public function generateToken() + { + $token = sha1(uniqid('', true) .'_'. mt_rand() . $this->conf->get('credentials.salt')); + $this->session['tokens'][$token] = 1; + return $token; + } + + /** + * Checks the validity of a session token, and destroys it afterwards + * + * @param string $token The token to check + * + * @return bool true if the token is valid, else false + */ + public function checkToken($token) + { + if (! isset($this->session['tokens'][$token])) { + // the token is wrong, or has already been used + return false; + } + + // destroy the token to prevent future use + unset($this->session['tokens'][$token]); + return true; + } + + /** + * Validate session ID to prevent Full Path Disclosure. + * + * See #298. + * The session ID's format depends on the hash algorithm set in PHP settings + * + * @param string $sessionId Session ID + * + * @return true if valid, false otherwise. + * + * @see http://php.net/manual/en/function.hash-algos.php + * @see http://php.net/manual/en/session.configuration.php + */ + public static function checkId($sessionId) + { + if (empty($sessionId)) { + return false; + } + + if (!$sessionId) { + return false; + } + + if (!preg_match('/^[a-zA-Z0-9,-]{2,128}$/', $sessionId)) { + return false; + } + + return true; + } + + /** + * Store user login information after a successful login + * + * @param string $clientIpId Client IP address identifier + */ + public function storeLoginInfo($clientIpId) + { + $this->session['ip'] = $clientIpId; + $this->session['username'] = $this->conf->get('credentials.login'); + $this->extendTimeValidityBy(self::$SHORT_TIMEOUT); + } + + /** + * Extend session validity + */ + public function extendSession() + { + if ($this->staySignedIn) { + return $this->extendTimeValidityBy(self::$LONG_TIMEOUT); + } + return $this->extendTimeValidityBy(self::$SHORT_TIMEOUT); + } + + /** + * Extend expiration time + * + * @param int $duration Expiration time extension (seconds) + * + * @return int New session expiration time + */ + protected function extendTimeValidityBy($duration) + { + $expirationTime = time() + $duration; + $this->session['expires_on'] = $expirationTime; + return $expirationTime; + } + + /** + * Logout a user by unsetting all login information + * + * See: + * - https://secure.php.net/manual/en/function.setcookie.php + */ + public function logout() + { + if (isset($this->session)) { + unset($this->session['ip']); + unset($this->session['expires_on']); + unset($this->session['username']); + unset($this->session['visibility']); + unset($this->session['untaggedonly']); + } + } + + /** + * Check whether the session has expired + * + * @param string $clientIpId Client IP address identifier + * + * @return bool true if the session has expired, false otherwise + */ + public function hasSessionExpired() + { + if (empty($this->session['expires_on'])) { + return true; + } + if (time() >= $this->session['expires_on']) { + return true; + } + return false; + } + + /** + * Check whether the client IP address has changed + * + * @param string $clientIpId Client IP address identifier + * + * @return bool true if the IP has changed, false if it has not, or + * if session protection has been disabled + */ + public function hasClientIpChanged($clientIpId) + { + if ($this->conf->get('security.session_protection_disabled') === true) { + return false; + } + if (isset($this->session['ip']) && $this->session['ip'] === $clientIpId) { + return false; + } + return true; + } +} diff --git a/assets/.htaccess b/assets/.htaccess new file mode 100644 index 0000000..f601c1e --- /dev/null +++ b/assets/.htaccess @@ -0,0 +1,13 @@ + + = 2.4> + Require all denied + + + Allow from none + Deny from all + + + + + Require all denied + diff --git a/assets/common/js/thumbnails-update.js b/assets/common/js/thumbnails-update.js new file mode 100644 index 0000000..b66ca3a --- /dev/null +++ b/assets/common/js/thumbnails-update.js @@ -0,0 +1,51 @@ +/** + * Script used in the thumbnails update page. + * + * It retrieves the list of link IDs to update, and execute AJAX requests + * to update their thumbnails, while updating the progress bar. + */ + +/** + * Update the thumbnail of the link with the current i index in ids. + * It contains a recursive call to retrieve the thumb of the next link when it succeed. + * It also update the progress bar and other visual feedback elements. + * + * @param {array} ids List of LinkID to update + * @param {int} i Current index in ids + * @param {object} elements List of DOM element to avoid retrieving them at each iteration + */ +function updateThumb(ids, i, elements) { + const xhr = new XMLHttpRequest(); + xhr.open('POST', '?do=ajax_thumb_update'); + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + xhr.responseType = 'json'; + xhr.onload = () => { + if (xhr.status !== 200) { + alert(`An error occurred. Return code: ${xhr.status}`); + } else { + const { response } = xhr; + i += 1; + elements.progressBar.style.width = `${(i * 100) / ids.length}%`; + elements.current.innerHTML = i; + elements.title.innerHTML = response.title; + if (response.thumbnail !== false) { + elements.thumbnail.innerHTML = ``; + } + if (i < ids.length) { + updateThumb(ids, i, elements); + } + } + }; + xhr.send(`id=${ids[i]}`); +} + +(() => { + const ids = document.getElementsByName('ids')[0].value.split(','); + const elements = { + progressBar: document.querySelector('.progressbar > div'), + current: document.querySelector('.progress-current'), + thumbnail: document.querySelector('.thumbnail-placeholder'), + title: document.querySelector('.thumbnail-link-title'), + }; + updateThumb(ids, 0, elements); +})(); diff --git a/assets/common/js/thumbnails.js b/assets/common/js/thumbnails.js new file mode 100644 index 0000000..c28322b --- /dev/null +++ b/assets/common/js/thumbnails.js @@ -0,0 +1,7 @@ +import Blazy from 'blazy'; + +(() => { + // Suppress ESLint error because that's how bLazy works + /* eslint-disable no-new */ + new Blazy(); +})(); diff --git a/tpl/default/fonts/Roboto-Bold.woff b/assets/default/fonts/Roboto-Bold.woff similarity index 100% rename from tpl/default/fonts/Roboto-Bold.woff rename to assets/default/fonts/Roboto-Bold.woff diff --git a/tpl/default/fonts/Roboto-Bold.woff2 b/assets/default/fonts/Roboto-Bold.woff2 similarity index 100% rename from tpl/default/fonts/Roboto-Bold.woff2 rename to assets/default/fonts/Roboto-Bold.woff2 diff --git a/tpl/default/fonts/Roboto-Regular.woff b/assets/default/fonts/Roboto-Regular.woff similarity index 100% rename from tpl/default/fonts/Roboto-Regular.woff rename to assets/default/fonts/Roboto-Regular.woff diff --git a/tpl/default/fonts/Roboto-Regular.woff2 b/assets/default/fonts/Roboto-Regular.woff2 similarity index 100% rename from tpl/default/fonts/Roboto-Regular.woff2 rename to assets/default/fonts/Roboto-Regular.woff2 diff --git a/tpl/default/img/apple-touch-icon.png b/assets/default/img/apple-touch-icon.png similarity index 100% rename from tpl/default/img/apple-touch-icon.png rename to assets/default/img/apple-touch-icon.png diff --git a/tpl/default/img/favicon.png b/assets/default/img/favicon.png similarity index 100% rename from tpl/default/img/favicon.png rename to assets/default/img/favicon.png diff --git a/tpl/default/img/icon.png b/assets/default/img/icon.png similarity index 100% rename from tpl/default/img/icon.png rename to assets/default/img/icon.png diff --git a/tpl/default/img/sad_star.png b/assets/default/img/sad_star.png similarity index 100% rename from tpl/default/img/sad_star.png rename to assets/default/img/sad_star.png diff --git a/assets/default/js/base.js b/assets/default/js/base.js new file mode 100644 index 0000000..8bf79d3 --- /dev/null +++ b/assets/default/js/base.js @@ -0,0 +1,573 @@ +import Awesomplete from 'awesomplete'; + +/** + * Find a parent element according to its tag and its attributes + * + * @param element Element where to start the search + * @param tagName Expected parent tag name + * @param attributes Associative array of expected attributes (name=>value). + * + * @returns Found element or null. + */ +function findParent(element, tagName, attributes) { + const parentMatch = key => attributes[key] !== '' && element.getAttribute(key).indexOf(attributes[key]) !== -1; + while (element) { + if (element.tagName.toLowerCase() === tagName) { + if (Object.keys(attributes).find(parentMatch)) { + return element; + } + } + element = element.parentElement; + } + return null; +} + +/** + * Ajax request to refresh the CSRF token. + */ +function refreshToken() { + const xhr = new XMLHttpRequest(); + xhr.open('GET', '?do=token'); + xhr.onload = () => { + const token = document.getElementById('token'); + token.setAttribute('value', xhr.responseText); + }; + xhr.send(); +} + +function createAwesompleteInstance(element, tags = []) { + const awesome = new Awesomplete(Awesomplete.$(element)); + // Tags are separated by a space + awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]); + // Insert new selected tag in the input + awesome.replace = (text) => { + const before = awesome.input.value.match(/^.+ \s*|/)[0]; + awesome.input.value = `${before}${text} `; + }; + // Highlight found items + awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(/[^ ]*$/)[0]); + // Don't display already selected items + const reg = /(\w+) /g; + let match; + awesome.data = (item, input) => { + while ((match = reg.exec(input))) { + if (item === match[1]) { + return ''; + } + } + return item; + }; + awesome.minChars = 1; + if (tags.length) { + awesome.list = tags; + } + + return awesome; +} + +/** + * Update awesomplete list of tag for all elements matching the given selector + * + * @param selector CSS selector + * @param tags Array of tags + * @param instances List of existing awesomplete instances + */ +function updateAwesompleteList(selector, tags, instances) { + if (instances.length === 0) { + // First load: create Awesomplete instances + const elements = document.querySelectorAll(selector); + [...elements].forEach((element) => { + instances.push(createAwesompleteInstance(element, tags)); + }); + } else { + // Update awesomplete tag list + instances.map((item) => { + item.list = tags; + return item; + }); + } + return instances; +} + +/** + * html_entities in JS + * + * @see http://stackoverflow.com/questions/18749591/encode-html-entities-in-javascript + */ +function htmlEntities(str) { + return str.replace(/[\u00A0-\u9999<>&]/gim, i => `&#${i.charCodeAt(0)};`); +} + +/** + * Add the class 'hidden' to city options not attached to the current selected continent. + * + * @param cities List of