diff options
Diffstat (limited to 'application/front/controller')
30 files changed, 2915 insertions, 0 deletions
diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php new file mode 100644 index 00000000..0ed7ad81 --- /dev/null +++ b/application/front/controller/admin/ConfigureController.php | |||
@@ -0,0 +1,126 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Languages; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Shaarli\Render\ThemeUtils; | ||
10 | use Shaarli\Thumbnailer; | ||
11 | use Slim\Http\Request; | ||
12 | use Slim\Http\Response; | ||
13 | use Throwable; | ||
14 | |||
15 | /** | ||
16 | * Class ConfigureController | ||
17 | * | ||
18 | * Slim controller used to handle Shaarli configuration page (display + save new config). | ||
19 | */ | ||
20 | class ConfigureController extends ShaarliAdminController | ||
21 | { | ||
22 | /** | ||
23 | * GET /admin/configure - Displays the configuration page | ||
24 | */ | ||
25 | public function index(Request $request, Response $response): Response | ||
26 | { | ||
27 | $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli')); | ||
28 | $this->assignView('theme', $this->container->conf->get('resource.theme')); | ||
29 | $this->assignView( | ||
30 | 'theme_available', | ||
31 | ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl')) | ||
32 | ); | ||
33 | $this->assignView('formatter_available', ['default', 'markdown', 'markdownExtra']); | ||
34 | list($continents, $cities) = generateTimeZoneData( | ||
35 | timezone_identifiers_list(), | ||
36 | $this->container->conf->get('general.timezone') | ||
37 | ); | ||
38 | $this->assignView('continents', $continents); | ||
39 | $this->assignView('cities', $cities); | ||
40 | $this->assignView('retrieve_description', $this->container->conf->get('general.retrieve_description', false)); | ||
41 | $this->assignView('private_links_default', $this->container->conf->get('privacy.default_private_links', false)); | ||
42 | $this->assignView( | ||
43 | 'session_protection_disabled', | ||
44 | $this->container->conf->get('security.session_protection_disabled', false) | ||
45 | ); | ||
46 | $this->assignView('enable_rss_permalinks', $this->container->conf->get('feed.rss_permalinks', false)); | ||
47 | $this->assignView('enable_update_check', $this->container->conf->get('updates.check_updates', true)); | ||
48 | $this->assignView('hide_public_links', $this->container->conf->get('privacy.hide_public_links', false)); | ||
49 | $this->assignView('api_enabled', $this->container->conf->get('api.enabled', true)); | ||
50 | $this->assignView('api_secret', $this->container->conf->get('api.secret')); | ||
51 | $this->assignView('languages', Languages::getAvailableLanguages()); | ||
52 | $this->assignView('gd_enabled', extension_loaded('gd')); | ||
53 | $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)); | ||
54 | $this->assignView('pagetitle', t('Configure') .' - '. $this->container->conf->get('general.title', 'Shaarli')); | ||
55 | |||
56 | return $response->write($this->render(TemplatePage::CONFIGURE)); | ||
57 | } | ||
58 | |||
59 | /** | ||
60 | * POST /admin/configure - Update Shaarli's configuration | ||
61 | */ | ||
62 | public function save(Request $request, Response $response): Response | ||
63 | { | ||
64 | $this->checkToken($request); | ||
65 | |||
66 | $continent = $request->getParam('continent'); | ||
67 | $city = $request->getParam('city'); | ||
68 | $tz = 'UTC'; | ||
69 | if (null !== $continent && null !== $city && isTimeZoneValid($continent, $city)) { | ||
70 | $tz = $continent . '/' . $city; | ||
71 | } | ||
72 | |||
73 | $this->container->conf->set('general.timezone', $tz); | ||
74 | $this->container->conf->set('general.title', escape($request->getParam('title'))); | ||
75 | $this->container->conf->set('general.header_link', escape($request->getParam('titleLink'))); | ||
76 | $this->container->conf->set('general.retrieve_description', !empty($request->getParam('retrieveDescription'))); | ||
77 | $this->container->conf->set('resource.theme', escape($request->getParam('theme'))); | ||
78 | $this->container->conf->set( | ||
79 | 'security.session_protection_disabled', | ||
80 | !empty($request->getParam('disablesessionprotection')) | ||
81 | ); | ||
82 | $this->container->conf->set( | ||
83 | 'privacy.default_private_links', | ||
84 | !empty($request->getParam('privateLinkByDefault')) | ||
85 | ); | ||
86 | $this->container->conf->set('feed.rss_permalinks', !empty($request->getParam('enableRssPermalinks'))); | ||
87 | $this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck'))); | ||
88 | $this->container->conf->set('privacy.hide_public_links', !empty($request->getParam('hidePublicLinks'))); | ||
89 | $this->container->conf->set('api.enabled', !empty($request->getParam('enableApi'))); | ||
90 | $this->container->conf->set('api.secret', escape($request->getParam('apiSecret'))); | ||
91 | $this->container->conf->set('formatter', escape($request->getParam('formatter'))); | ||
92 | |||
93 | if (!empty($request->getParam('language'))) { | ||
94 | $this->container->conf->set('translation.language', escape($request->getParam('language'))); | ||
95 | } | ||
96 | |||
97 | $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE; | ||
98 | if ($thumbnailsMode !== Thumbnailer::MODE_NONE | ||
99 | && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) | ||
100 | ) { | ||
101 | $this->saveWarningMessage( | ||
102 | t('You have enabled or changed thumbnails mode.') . | ||
103 | '<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>' | ||
104 | ); | ||
105 | } | ||
106 | $this->container->conf->set('thumbnails.mode', $thumbnailsMode); | ||
107 | |||
108 | try { | ||
109 | $this->container->conf->write($this->container->loginManager->isLoggedIn()); | ||
110 | $this->container->history->updateSettings(); | ||
111 | $this->container->pageCacheManager->invalidateCaches(); | ||
112 | } catch (Throwable $e) { | ||
113 | $this->assignView('message', t('Error while writing config file after configuration update.')); | ||
114 | |||
115 | if ($this->container->conf->get('dev.debug', false)) { | ||
116 | $this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString()); | ||
117 | } | ||
118 | |||
119 | return $response->write($this->render('error')); | ||
120 | } | ||
121 | |||
122 | $this->saveSuccessMessage(t('Configuration was saved.')); | ||
123 | |||
124 | return $this->redirect($response, '/admin/configure'); | ||
125 | } | ||
126 | } | ||
diff --git a/application/front/controller/admin/ExportController.php b/application/front/controller/admin/ExportController.php new file mode 100644 index 00000000..2be957fa --- /dev/null +++ b/application/front/controller/admin/ExportController.php | |||
@@ -0,0 +1,80 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use DateTime; | ||
8 | use Shaarli\Bookmark\Bookmark; | ||
9 | use Shaarli\Render\TemplatePage; | ||
10 | use Slim\Http\Request; | ||
11 | use Slim\Http\Response; | ||
12 | |||
13 | /** | ||
14 | * Class ExportController | ||
15 | * | ||
16 | * Slim controller used to display Shaarli data export page, | ||
17 | * and process the bookmarks export as a Netscape Bookmarks file. | ||
18 | */ | ||
19 | class ExportController extends ShaarliAdminController | ||
20 | { | ||
21 | /** | ||
22 | * GET /admin/export - Display export page | ||
23 | */ | ||
24 | public function index(Request $request, Response $response): Response | ||
25 | { | ||
26 | $this->assignView('pagetitle', t('Export') .' - '. $this->container->conf->get('general.title', 'Shaarli')); | ||
27 | |||
28 | return $response->write($this->render(TemplatePage::EXPORT)); | ||
29 | } | ||
30 | |||
31 | /** | ||
32 | * POST /admin/export - Process export, and serve download file named | ||
33 | * bookmarks_(all|private|public)_datetime.html | ||
34 | */ | ||
35 | public function export(Request $request, Response $response): Response | ||
36 | { | ||
37 | $this->checkToken($request); | ||
38 | |||
39 | $selection = $request->getParam('selection'); | ||
40 | |||
41 | if (empty($selection)) { | ||
42 | $this->saveErrorMessage(t('Please select an export mode.')); | ||
43 | |||
44 | return $this->redirect($response, '/admin/export'); | ||
45 | } | ||
46 | |||
47 | $prependNoteUrl = filter_var($request->getParam('prepend_note_url') ?? false, FILTER_VALIDATE_BOOLEAN); | ||
48 | |||
49 | try { | ||
50 | $formatter = $this->container->formatterFactory->getFormatter('raw'); | ||
51 | |||
52 | $this->assignView( | ||
53 | 'links', | ||
54 | $this->container->netscapeBookmarkUtils->filterAndFormat( | ||
55 | $formatter, | ||
56 | $selection, | ||
57 | $prependNoteUrl, | ||
58 | index_url($this->container->environment) | ||
59 | ) | ||
60 | ); | ||
61 | } catch (\Exception $exc) { | ||
62 | $this->saveErrorMessage($exc->getMessage()); | ||
63 | |||
64 | return $this->redirect($response, '/admin/export'); | ||
65 | } | ||
66 | |||
67 | $now = new DateTime(); | ||
68 | $response = $response->withHeader('Content-Type', 'text/html; charset=utf-8'); | ||
69 | $response = $response->withHeader( | ||
70 | 'Content-disposition', | ||
71 | 'attachment; filename=bookmarks_'.$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html' | ||
72 | ); | ||
73 | |||
74 | $this->assignView('date', $now->format(DateTime::RFC822)); | ||
75 | $this->assignView('eol', PHP_EOL); | ||
76 | $this->assignView('selection', $selection); | ||
77 | |||
78 | return $response->write($this->render(TemplatePage::NETSCAPE_EXPORT_BOOKMARKS)); | ||
79 | } | ||
80 | } | ||
diff --git a/application/front/controller/admin/ImportController.php b/application/front/controller/admin/ImportController.php new file mode 100644 index 00000000..758d5ef9 --- /dev/null +++ b/application/front/controller/admin/ImportController.php | |||
@@ -0,0 +1,82 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Psr\Http\Message\UploadedFileInterface; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class ImportController | ||
14 | * | ||
15 | * Slim controller used to display Shaarli data import page, | ||
16 | * and import bookmarks from Netscape Bookmarks file. | ||
17 | */ | ||
18 | class ImportController extends ShaarliAdminController | ||
19 | { | ||
20 | /** | ||
21 | * GET /admin/import - Display import page | ||
22 | */ | ||
23 | public function index(Request $request, Response $response): Response | ||
24 | { | ||
25 | $this->assignView( | ||
26 | 'maxfilesize', | ||
27 | get_max_upload_size( | ||
28 | ini_get('post_max_size'), | ||
29 | ini_get('upload_max_filesize'), | ||
30 | false | ||
31 | ) | ||
32 | ); | ||
33 | $this->assignView( | ||
34 | 'maxfilesizeHuman', | ||
35 | get_max_upload_size( | ||
36 | ini_get('post_max_size'), | ||
37 | ini_get('upload_max_filesize'), | ||
38 | true | ||
39 | ) | ||
40 | ); | ||
41 | $this->assignView('pagetitle', t('Import') .' - '. $this->container->conf->get('general.title', 'Shaarli')); | ||
42 | |||
43 | return $response->write($this->render(TemplatePage::IMPORT)); | ||
44 | } | ||
45 | |||
46 | /** | ||
47 | * POST /admin/import - Process import file provided and create bookmarks | ||
48 | */ | ||
49 | public function import(Request $request, Response $response): Response | ||
50 | { | ||
51 | $this->checkToken($request); | ||
52 | |||
53 | $file = ($request->getUploadedFiles() ?? [])['filetoupload'] ?? null; | ||
54 | if (!$file instanceof UploadedFileInterface) { | ||
55 | $this->saveErrorMessage(t('No import file provided.')); | ||
56 | |||
57 | return $this->redirect($response, '/admin/import'); | ||
58 | } | ||
59 | |||
60 | |||
61 | // Import bookmarks from an uploaded file | ||
62 | if (0 === $file->getSize()) { | ||
63 | // The file is too big or some form field may be missing. | ||
64 | $msg = sprintf( | ||
65 | t( | ||
66 | 'The file you are trying to upload is probably bigger than what this webserver can accept' | ||
67 | .' (%s). Please upload in smaller chunks.' | ||
68 | ), | ||
69 | get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize')) | ||
70 | ); | ||
71 | $this->saveErrorMessage($msg); | ||
72 | |||
73 | return $this->redirect($response, '/admin/import'); | ||
74 | } | ||
75 | |||
76 | $status = $this->container->netscapeBookmarkUtils->import($request->getParams(), $file); | ||
77 | |||
78 | $this->saveSuccessMessage($status); | ||
79 | |||
80 | return $this->redirect($response, '/admin/import'); | ||
81 | } | ||
82 | } | ||
diff --git a/application/front/controller/admin/LogoutController.php b/application/front/controller/admin/LogoutController.php new file mode 100644 index 00000000..28165129 --- /dev/null +++ b/application/front/controller/admin/LogoutController.php | |||
@@ -0,0 +1,33 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Security\CookieManager; | ||
8 | use Shaarli\Security\LoginManager; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class LogoutController | ||
14 | * | ||
15 | * Slim controller used to logout the user. | ||
16 | * It invalidates page cache and terminate the user session. Then it redirects to the homepage. | ||
17 | */ | ||
18 | class LogoutController extends ShaarliAdminController | ||
19 | { | ||
20 | public function index(Request $request, Response $response): Response | ||
21 | { | ||
22 | $this->container->pageCacheManager->invalidateCaches(); | ||
23 | $this->container->sessionManager->logout(); | ||
24 | $this->container->cookieManager->setCookieParameter( | ||
25 | CookieManager::STAY_SIGNED_IN, | ||
26 | 'false', | ||
27 | 0, | ||
28 | $this->container->basePath . '/' | ||
29 | ); | ||
30 | |||
31 | return $this->redirect($response, '/'); | ||
32 | } | ||
33 | } | ||
diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php new file mode 100644 index 00000000..2065c3e2 --- /dev/null +++ b/application/front/controller/admin/ManageTagController.php | |||
@@ -0,0 +1,88 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\BookmarkFilter; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class ManageTagController | ||
14 | * | ||
15 | * Slim controller used to handle Shaarli manage tags page (rename and delete tags). | ||
16 | */ | ||
17 | class ManageTagController extends ShaarliAdminController | ||
18 | { | ||
19 | /** | ||
20 | * GET /admin/tags - Displays the manage tags page | ||
21 | */ | ||
22 | public function index(Request $request, Response $response): Response | ||
23 | { | ||
24 | $fromTag = $request->getParam('fromtag') ?? ''; | ||
25 | |||
26 | $this->assignView('fromtag', escape($fromTag)); | ||
27 | $this->assignView( | ||
28 | 'pagetitle', | ||
29 | t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
30 | ); | ||
31 | |||
32 | return $response->write($this->render(TemplatePage::CHANGE_TAG)); | ||
33 | } | ||
34 | |||
35 | /** | ||
36 | * POST /admin/tags - Update or delete provided tag | ||
37 | */ | ||
38 | public function save(Request $request, Response $response): Response | ||
39 | { | ||
40 | $this->checkToken($request); | ||
41 | |||
42 | $isDelete = null !== $request->getParam('deletetag') && null === $request->getParam('renametag'); | ||
43 | |||
44 | $fromTag = trim($request->getParam('fromtag') ?? ''); | ||
45 | $toTag = trim($request->getParam('totag') ?? ''); | ||
46 | |||
47 | if (0 === strlen($fromTag) || false === $isDelete && 0 === strlen($toTag)) { | ||
48 | $this->saveWarningMessage(t('Invalid tags provided.')); | ||
49 | |||
50 | return $this->redirect($response, '/admin/tags'); | ||
51 | } | ||
52 | |||
53 | // TODO: move this to bookmark service | ||
54 | $count = 0; | ||
55 | $bookmarks = $this->container->bookmarkService->search(['searchtags' => $fromTag], BookmarkFilter::$ALL, true); | ||
56 | foreach ($bookmarks as $bookmark) { | ||
57 | if (false === $isDelete) { | ||
58 | $bookmark->renameTag($fromTag, $toTag); | ||
59 | } else { | ||
60 | $bookmark->deleteTag($fromTag); | ||
61 | } | ||
62 | |||
63 | $this->container->bookmarkService->set($bookmark, false); | ||
64 | $this->container->history->updateLink($bookmark); | ||
65 | $count++; | ||
66 | } | ||
67 | |||
68 | $this->container->bookmarkService->save(); | ||
69 | |||
70 | if (true === $isDelete) { | ||
71 | $alert = sprintf( | ||
72 | t('The tag was removed from %d bookmark.', 'The tag was removed from %d bookmarks.', $count), | ||
73 | $count | ||
74 | ); | ||
75 | } else { | ||
76 | $alert = sprintf( | ||
77 | t('The tag was renamed in %d bookmark.', 'The tag was renamed in %d bookmarks.', $count), | ||
78 | $count | ||
79 | ); | ||
80 | } | ||
81 | |||
82 | $this->saveSuccessMessage($alert); | ||
83 | |||
84 | $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags='. urlencode($toTag); | ||
85 | |||
86 | return $this->redirect($response, $redirect); | ||
87 | } | ||
88 | } | ||
diff --git a/application/front/controller/admin/MetadataController.php b/application/front/controller/admin/MetadataController.php new file mode 100644 index 00000000..ff845944 --- /dev/null +++ b/application/front/controller/admin/MetadataController.php | |||
@@ -0,0 +1,29 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Controller used to retrieve/update bookmark's metadata. | ||
12 | */ | ||
13 | class MetadataController extends ShaarliAdminController | ||
14 | { | ||
15 | /** | ||
16 | * GET /admin/metadata/{url} - Attempt to retrieve the bookmark title from provided URL. | ||
17 | */ | ||
18 | public function ajaxRetrieveTitle(Request $request, Response $response): Response | ||
19 | { | ||
20 | $url = $request->getParam('url'); | ||
21 | |||
22 | // Only try to extract metadata from URL with HTTP(s) scheme | ||
23 | if (!empty($url) && strpos(get_url_scheme($url) ?: '', 'http') !== false) { | ||
24 | return $response->withJson($this->container->metadataRetriever->retrieve($url)); | ||
25 | } | ||
26 | |||
27 | return $response->withJson([]); | ||
28 | } | ||
29 | } | ||
diff --git a/application/front/controller/admin/PasswordController.php b/application/front/controller/admin/PasswordController.php new file mode 100644 index 00000000..5ec0d24b --- /dev/null +++ b/application/front/controller/admin/PasswordController.php | |||
@@ -0,0 +1,101 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Container\ShaarliContainer; | ||
8 | use Shaarli\Front\Exception\OpenShaarliPasswordException; | ||
9 | use Shaarli\Front\Exception\ShaarliFrontException; | ||
10 | use Shaarli\Render\TemplatePage; | ||
11 | use Slim\Http\Request; | ||
12 | use Slim\Http\Response; | ||
13 | use Throwable; | ||
14 | |||
15 | /** | ||
16 | * Class PasswordController | ||
17 | * | ||
18 | * Slim controller used to handle passwords update. | ||
19 | */ | ||
20 | class PasswordController extends ShaarliAdminController | ||
21 | { | ||
22 | public function __construct(ShaarliContainer $container) | ||
23 | { | ||
24 | parent::__construct($container); | ||
25 | |||
26 | $this->assignView( | ||
27 | 'pagetitle', | ||
28 | t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
29 | ); | ||
30 | } | ||
31 | |||
32 | /** | ||
33 | * GET /admin/password - Displays the change password template | ||
34 | */ | ||
35 | public function index(Request $request, Response $response): Response | ||
36 | { | ||
37 | return $response->write($this->render(TemplatePage::CHANGE_PASSWORD)); | ||
38 | } | ||
39 | |||
40 | /** | ||
41 | * POST /admin/password - Change admin password - existing and new passwords need to be provided. | ||
42 | */ | ||
43 | public function change(Request $request, Response $response): Response | ||
44 | { | ||
45 | $this->checkToken($request); | ||
46 | |||
47 | if ($this->container->conf->get('security.open_shaarli', false)) { | ||
48 | throw new OpenShaarliPasswordException(); | ||
49 | } | ||
50 | |||
51 | $oldPassword = $request->getParam('oldpassword'); | ||
52 | $newPassword = $request->getParam('setpassword'); | ||
53 | |||
54 | if (empty($newPassword) || empty($oldPassword)) { | ||
55 | $this->saveErrorMessage(t('You must provide the current and new password to change it.')); | ||
56 | |||
57 | return $response | ||
58 | ->withStatus(400) | ||
59 | ->write($this->render(TemplatePage::CHANGE_PASSWORD)) | ||
60 | ; | ||
61 | } | ||
62 | |||
63 | // Make sure old password is correct. | ||
64 | $oldHash = sha1( | ||
65 | $oldPassword . | ||
66 | $this->container->conf->get('credentials.login') . | ||
67 | $this->container->conf->get('credentials.salt') | ||
68 | ); | ||
69 | |||
70 | if ($oldHash !== $this->container->conf->get('credentials.hash')) { | ||
71 | $this->saveErrorMessage(t('The old password is not correct.')); | ||
72 | |||
73 | return $response | ||
74 | ->withStatus(400) | ||
75 | ->write($this->render(TemplatePage::CHANGE_PASSWORD)) | ||
76 | ; | ||
77 | } | ||
78 | |||
79 | // Save new password | ||
80 | // Salt renders rainbow-tables attacks useless. | ||
81 | $this->container->conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand())); | ||
82 | $this->container->conf->set( | ||
83 | 'credentials.hash', | ||
84 | sha1( | ||
85 | $newPassword | ||
86 | . $this->container->conf->get('credentials.login') | ||
87 | . $this->container->conf->get('credentials.salt') | ||
88 | ) | ||
89 | ); | ||
90 | |||
91 | try { | ||
92 | $this->container->conf->write($this->container->loginManager->isLoggedIn()); | ||
93 | } catch (Throwable $e) { | ||
94 | throw new ShaarliFrontException($e->getMessage(), 500, $e); | ||
95 | } | ||
96 | |||
97 | $this->saveSuccessMessage(t('Your password has been changed')); | ||
98 | |||
99 | return $response->write($this->render(TemplatePage::CHANGE_PASSWORD)); | ||
100 | } | ||
101 | } | ||
diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php new file mode 100644 index 00000000..8e059681 --- /dev/null +++ b/application/front/controller/admin/PluginsController.php | |||
@@ -0,0 +1,85 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Exception; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class PluginsController | ||
14 | * | ||
15 | * Slim controller used to handle Shaarli plugins configuration page (display + save new config). | ||
16 | */ | ||
17 | class PluginsController extends ShaarliAdminController | ||
18 | { | ||
19 | /** | ||
20 | * GET /admin/plugins - Displays the configuration page | ||
21 | */ | ||
22 | public function index(Request $request, Response $response): Response | ||
23 | { | ||
24 | $pluginMeta = $this->container->pluginManager->getPluginsMeta(); | ||
25 | |||
26 | // Split plugins into 2 arrays: ordered enabled plugins and disabled. | ||
27 | $enabledPlugins = array_filter($pluginMeta, function ($v) { | ||
28 | return ($v['order'] ?? false) !== false; | ||
29 | }); | ||
30 | $enabledPlugins = load_plugin_parameter_values($enabledPlugins, $this->container->conf->get('plugins', [])); | ||
31 | uasort( | ||
32 | $enabledPlugins, | ||
33 | function ($a, $b) { | ||
34 | return $a['order'] - $b['order']; | ||
35 | } | ||
36 | ); | ||
37 | $disabledPlugins = array_filter($pluginMeta, function ($v) { | ||
38 | return ($v['order'] ?? false) === false; | ||
39 | }); | ||
40 | |||
41 | $this->assignView('enabledPlugins', $enabledPlugins); | ||
42 | $this->assignView('disabledPlugins', $disabledPlugins); | ||
43 | $this->assignView( | ||
44 | 'pagetitle', | ||
45 | t('Plugin Administration') .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
46 | ); | ||
47 | |||
48 | return $response->write($this->render(TemplatePage::PLUGINS_ADMIN)); | ||
49 | } | ||
50 | |||
51 | /** | ||
52 | * POST /admin/plugins - Update Shaarli's configuration | ||
53 | */ | ||
54 | public function save(Request $request, Response $response): Response | ||
55 | { | ||
56 | $this->checkToken($request); | ||
57 | |||
58 | try { | ||
59 | $parameters = $request->getParams() ?? []; | ||
60 | |||
61 | $this->executePageHooks('save_plugin_parameters', $parameters); | ||
62 | |||
63 | if (isset($parameters['parameters_form'])) { | ||
64 | unset($parameters['parameters_form']); | ||
65 | unset($parameters['token']); | ||
66 | foreach ($parameters as $param => $value) { | ||
67 | $this->container->conf->set('plugins.'. $param, escape($value)); | ||
68 | } | ||
69 | } else { | ||
70 | $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters)); | ||
71 | } | ||
72 | |||
73 | $this->container->conf->write($this->container->loginManager->isLoggedIn()); | ||
74 | $this->container->history->updateSettings(); | ||
75 | |||
76 | $this->saveSuccessMessage(t('Setting successfully saved.')); | ||
77 | } catch (Exception $e) { | ||
78 | $this->saveErrorMessage( | ||
79 | t('Error while saving plugin configuration: ') . PHP_EOL . $e->getMessage() | ||
80 | ); | ||
81 | } | ||
82 | |||
83 | return $this->redirect($response, '/admin/plugins'); | ||
84 | } | ||
85 | } | ||
diff --git a/application/front/controller/admin/ServerController.php b/application/front/controller/admin/ServerController.php new file mode 100644 index 00000000..bfc99422 --- /dev/null +++ b/application/front/controller/admin/ServerController.php | |||
@@ -0,0 +1,87 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Helper\ApplicationUtils; | ||
8 | use Shaarli\Helper\FileUtils; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Slim controller used to handle Server administration page, and actions. | ||
14 | */ | ||
15 | class ServerController extends ShaarliAdminController | ||
16 | { | ||
17 | /** @var string Cache type - main - by default pagecache/ and tmp/ */ | ||
18 | protected const CACHE_MAIN = 'main'; | ||
19 | |||
20 | /** @var string Cache type - thumbnails - by default cache/ */ | ||
21 | protected const CACHE_THUMB = 'thumbnails'; | ||
22 | |||
23 | /** | ||
24 | * GET /admin/server - Display page Server administration | ||
25 | */ | ||
26 | public function index(Request $request, Response $response): Response | ||
27 | { | ||
28 | $latestVersion = 'v' . ApplicationUtils::getVersion( | ||
29 | ApplicationUtils::$GIT_RAW_URL . '/latest/' . ApplicationUtils::$VERSION_FILE | ||
30 | ); | ||
31 | $currentVersion = ApplicationUtils::getVersion('./shaarli_version.php'); | ||
32 | $currentVersion = $currentVersion === 'dev' ? $currentVersion : 'v' . $currentVersion; | ||
33 | $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION)); | ||
34 | |||
35 | $this->assignView('php_version', PHP_VERSION); | ||
36 | $this->assignView('php_eol', format_date($phpEol, false)); | ||
37 | $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable()); | ||
38 | $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement()); | ||
39 | $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf)); | ||
40 | $this->assignView('release_url', ApplicationUtils::$GITHUB_URL . '/releases/tag/' . $latestVersion); | ||
41 | $this->assignView('latest_version', $latestVersion); | ||
42 | $this->assignView('current_version', $currentVersion); | ||
43 | $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode')); | ||
44 | $this->assignView('index_url', index_url($this->container->environment)); | ||
45 | $this->assignView('client_ip', client_ip_id($this->container->environment)); | ||
46 | $this->assignView('trusted_proxies', $this->container->conf->get('security.trusted_proxies', [])); | ||
47 | |||
48 | $this->assignView( | ||
49 | 'pagetitle', | ||
50 | t('Server administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli') | ||
51 | ); | ||
52 | |||
53 | return $response->write($this->render('server')); | ||
54 | } | ||
55 | |||
56 | /** | ||
57 | * GET /admin/clear-cache?type={$type} - Action to trigger cache folder clearing (either main or thumbnails). | ||
58 | */ | ||
59 | public function clearCache(Request $request, Response $response): Response | ||
60 | { | ||
61 | $exclude = ['.htaccess']; | ||
62 | |||
63 | if ($request->getQueryParam('type') === static::CACHE_THUMB) { | ||
64 | $folders = [$this->container->conf->get('resource.thumbnails_cache')]; | ||
65 | |||
66 | $this->saveWarningMessage( | ||
67 | t('Thumbnails cache has been cleared.') . ' ' . | ||
68 | '<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>' | ||
69 | ); | ||
70 | } else { | ||
71 | $folders = [ | ||
72 | $this->container->conf->get('resource.page_cache'), | ||
73 | $this->container->conf->get('resource.raintpl_tmp'), | ||
74 | ]; | ||
75 | |||
76 | $this->saveSuccessMessage(t('Shaarli\'s cache folder has been cleared!')); | ||
77 | } | ||
78 | |||
79 | // Make sure that we don't delete root cache folder | ||
80 | $folders = array_map('realpath', array_values(array_filter(array_map('trim', $folders)))); | ||
81 | foreach ($folders as $folder) { | ||
82 | FileUtils::clearFolder($folder, false, $exclude); | ||
83 | } | ||
84 | |||
85 | return $this->redirect($response, '/admin/server'); | ||
86 | } | ||
87 | } | ||
diff --git a/application/front/controller/admin/SessionFilterController.php b/application/front/controller/admin/SessionFilterController.php new file mode 100644 index 00000000..d9a7a2e0 --- /dev/null +++ b/application/front/controller/admin/SessionFilterController.php | |||
@@ -0,0 +1,50 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\BookmarkFilter; | ||
8 | use Shaarli\Security\SessionManager; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class SessionFilterController | ||
14 | * | ||
15 | * Slim controller used to handle filters stored in the user session, such as visibility, etc. | ||
16 | */ | ||
17 | class SessionFilterController extends ShaarliAdminController | ||
18 | { | ||
19 | /** | ||
20 | * GET /admin/visibility: allows to display only public or only private bookmarks in linklist | ||
21 | */ | ||
22 | public function visibility(Request $request, Response $response, array $args): Response | ||
23 | { | ||
24 | if (false === $this->container->loginManager->isLoggedIn()) { | ||
25 | return $this->redirectFromReferer($request, $response, ['visibility']); | ||
26 | } | ||
27 | |||
28 | $newVisibility = $args['visibility'] ?? null; | ||
29 | if (false === in_array($newVisibility, [BookmarkFilter::$PRIVATE, BookmarkFilter::$PUBLIC], true)) { | ||
30 | $newVisibility = null; | ||
31 | } | ||
32 | |||
33 | $currentVisibility = $this->container->sessionManager->getSessionParameter(SessionManager::KEY_VISIBILITY); | ||
34 | |||
35 | // Visibility not set or not already expected value, set expected value, otherwise reset it | ||
36 | if ($newVisibility !== null && (null === $currentVisibility || $currentVisibility !== $newVisibility)) { | ||
37 | // See only public bookmarks | ||
38 | $this->container->sessionManager->setSessionParameter( | ||
39 | SessionManager::KEY_VISIBILITY, | ||
40 | $newVisibility | ||
41 | ); | ||
42 | } else { | ||
43 | $this->container->sessionManager->deleteSessionParameter(SessionManager::KEY_VISIBILITY); | ||
44 | } | ||
45 | |||
46 | return $this->redirectFromReferer($request, $response, ['visibility']); | ||
47 | } | ||
48 | |||
49 | |||
50 | } | ||
diff --git a/application/front/controller/admin/ShaareAddController.php b/application/front/controller/admin/ShaareAddController.php new file mode 100644 index 00000000..8dc386b2 --- /dev/null +++ b/application/front/controller/admin/ShaareAddController.php | |||
@@ -0,0 +1,34 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Formatter\BookmarkMarkdownFormatter; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | class ShaareAddController extends ShaarliAdminController | ||
13 | { | ||
14 | /** | ||
15 | * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL | ||
16 | */ | ||
17 | public function addShaare(Request $request, Response $response): Response | ||
18 | { | ||
19 | $tags = $this->container->bookmarkService->bookmarksCountPerTag(); | ||
20 | if ($this->container->conf->get('formatter') === 'markdown') { | ||
21 | $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1; | ||
22 | } | ||
23 | |||
24 | $this->assignView( | ||
25 | 'pagetitle', | ||
26 | t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
27 | ); | ||
28 | $this->assignView('tags', $tags); | ||
29 | $this->assignView('default_private_links', $this->container->conf->get('privacy.default_private_links', false)); | ||
30 | $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true)); | ||
31 | |||
32 | return $response->write($this->render(TemplatePage::ADDLINK)); | ||
33 | } | ||
34 | } | ||
diff --git a/application/front/controller/admin/ShaareManageController.php b/application/front/controller/admin/ShaareManageController.php new file mode 100644 index 00000000..7ceb8d8a --- /dev/null +++ b/application/front/controller/admin/ShaareManageController.php | |||
@@ -0,0 +1,202 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Class PostBookmarkController | ||
13 | * | ||
14 | * Slim controller used to handle Shaarli create or edit bookmarks. | ||
15 | */ | ||
16 | class ShaareManageController extends ShaarliAdminController | ||
17 | { | ||
18 | /** | ||
19 | * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter). | ||
20 | */ | ||
21 | public function deleteBookmark(Request $request, Response $response): Response | ||
22 | { | ||
23 | $this->checkToken($request); | ||
24 | |||
25 | $ids = escape(trim($request->getParam('id') ?? '')); | ||
26 | if (empty($ids) || strpos($ids, ' ') !== false) { | ||
27 | // multiple, space-separated ids provided | ||
28 | $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit')); | ||
29 | } else { | ||
30 | $ids = [$ids]; | ||
31 | } | ||
32 | |||
33 | // assert at least one id is given | ||
34 | if (0 === count($ids)) { | ||
35 | $this->saveErrorMessage(t('Invalid bookmark ID provided.')); | ||
36 | |||
37 | return $this->redirectFromReferer($request, $response, [], ['delete-shaare']); | ||
38 | } | ||
39 | |||
40 | $formatter = $this->container->formatterFactory->getFormatter('raw'); | ||
41 | $count = 0; | ||
42 | foreach ($ids as $id) { | ||
43 | try { | ||
44 | $bookmark = $this->container->bookmarkService->get((int) $id); | ||
45 | } catch (BookmarkNotFoundException $e) { | ||
46 | $this->saveErrorMessage(sprintf( | ||
47 | t('Bookmark with identifier %s could not be found.'), | ||
48 | $id | ||
49 | )); | ||
50 | |||
51 | continue; | ||
52 | } | ||
53 | |||
54 | $data = $formatter->format($bookmark); | ||
55 | $this->executePageHooks('delete_link', $data); | ||
56 | $this->container->bookmarkService->remove($bookmark, false); | ||
57 | ++ $count; | ||
58 | } | ||
59 | |||
60 | if ($count > 0) { | ||
61 | $this->container->bookmarkService->save(); | ||
62 | } | ||
63 | |||
64 | // If we are called from the bookmarklet, we must close the popup: | ||
65 | if ($request->getParam('source') === 'bookmarklet') { | ||
66 | return $response->write('<script>self.close();</script>'); | ||
67 | } | ||
68 | |||
69 | // Don't redirect to where we were previously because the datastore has changed. | ||
70 | return $this->redirect($response, '/'); | ||
71 | } | ||
72 | |||
73 | /** | ||
74 | * GET /admin/shaare/visibility | ||
75 | * | ||
76 | * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter). | ||
77 | */ | ||
78 | public function changeVisibility(Request $request, Response $response): Response | ||
79 | { | ||
80 | $this->checkToken($request); | ||
81 | |||
82 | $ids = trim(escape($request->getParam('id') ?? '')); | ||
83 | if (empty($ids) || strpos($ids, ' ') !== false) { | ||
84 | // multiple, space-separated ids provided | ||
85 | $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit')); | ||
86 | } else { | ||
87 | // only a single id provided | ||
88 | $ids = [$ids]; | ||
89 | } | ||
90 | |||
91 | // assert at least one id is given | ||
92 | if (0 === count($ids)) { | ||
93 | $this->saveErrorMessage(t('Invalid bookmark ID provided.')); | ||
94 | |||
95 | return $this->redirectFromReferer($request, $response, [], ['change_visibility']); | ||
96 | } | ||
97 | |||
98 | // assert that the visibility is valid | ||
99 | $visibility = $request->getParam('newVisibility'); | ||
100 | if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) { | ||
101 | $this->saveErrorMessage(t('Invalid visibility provided.')); | ||
102 | |||
103 | return $this->redirectFromReferer($request, $response, [], ['change_visibility']); | ||
104 | } else { | ||
105 | $isPrivate = $visibility === 'private'; | ||
106 | } | ||
107 | |||
108 | $formatter = $this->container->formatterFactory->getFormatter('raw'); | ||
109 | $count = 0; | ||
110 | |||
111 | foreach ($ids as $id) { | ||
112 | try { | ||
113 | $bookmark = $this->container->bookmarkService->get((int) $id); | ||
114 | } catch (BookmarkNotFoundException $e) { | ||
115 | $this->saveErrorMessage(sprintf( | ||
116 | t('Bookmark with identifier %s could not be found.'), | ||
117 | $id | ||
118 | )); | ||
119 | |||
120 | continue; | ||
121 | } | ||
122 | |||
123 | $bookmark->setPrivate($isPrivate); | ||
124 | |||
125 | // To preserve backward compatibility with 3rd parties, plugins still use arrays | ||
126 | $data = $formatter->format($bookmark); | ||
127 | $this->executePageHooks('save_link', $data); | ||
128 | $bookmark->fromArray($data); | ||
129 | |||
130 | $this->container->bookmarkService->set($bookmark, false); | ||
131 | ++$count; | ||
132 | } | ||
133 | |||
134 | if ($count > 0) { | ||
135 | $this->container->bookmarkService->save(); | ||
136 | } | ||
137 | |||
138 | return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']); | ||
139 | } | ||
140 | |||
141 | /** | ||
142 | * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark. | ||
143 | */ | ||
144 | public function pinBookmark(Request $request, Response $response, array $args): Response | ||
145 | { | ||
146 | $this->checkToken($request); | ||
147 | |||
148 | $id = $args['id'] ?? ''; | ||
149 | try { | ||
150 | if (false === ctype_digit($id)) { | ||
151 | throw new BookmarkNotFoundException(); | ||
152 | } | ||
153 | $bookmark = $this->container->bookmarkService->get((int) $id); // Read database | ||
154 | } catch (BookmarkNotFoundException $e) { | ||
155 | $this->saveErrorMessage(sprintf( | ||
156 | t('Bookmark with identifier %s could not be found.'), | ||
157 | $id | ||
158 | )); | ||
159 | |||
160 | return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']); | ||
161 | } | ||
162 | |||
163 | $formatter = $this->container->formatterFactory->getFormatter('raw'); | ||
164 | |||
165 | $bookmark->setSticky(!$bookmark->isSticky()); | ||
166 | |||
167 | // To preserve backward compatibility with 3rd parties, plugins still use arrays | ||
168 | $data = $formatter->format($bookmark); | ||
169 | $this->executePageHooks('save_link', $data); | ||
170 | $bookmark->fromArray($data); | ||
171 | |||
172 | $this->container->bookmarkService->set($bookmark); | ||
173 | |||
174 | return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']); | ||
175 | } | ||
176 | |||
177 | /** | ||
178 | * GET /admin/shaare/private/{hash} - Attach a private key to given bookmark, then redirect to the sharing URL. | ||
179 | */ | ||
180 | public function sharePrivate(Request $request, Response $response, array $args): Response | ||
181 | { | ||
182 | $this->checkToken($request); | ||
183 | |||
184 | $hash = $args['hash'] ?? ''; | ||
185 | $bookmark = $this->container->bookmarkService->findByHash($hash); | ||
186 | |||
187 | if ($bookmark->isPrivate() !== true) { | ||
188 | return $this->redirect($response, '/shaare/' . $hash); | ||
189 | } | ||
190 | |||
191 | if (empty($bookmark->getAdditionalContentEntry('private_key'))) { | ||
192 | $privateKey = bin2hex(random_bytes(16)); | ||
193 | $bookmark->addAdditionalContentEntry('private_key', $privateKey); | ||
194 | $this->container->bookmarkService->set($bookmark); | ||
195 | } | ||
196 | |||
197 | return $this->redirect( | ||
198 | $response, | ||
199 | '/shaare/' . $hash . '?key=' . $bookmark->getAdditionalContentEntry('private_key') | ||
200 | ); | ||
201 | } | ||
202 | } | ||
diff --git a/application/front/controller/admin/ShaarePublishController.php b/application/front/controller/admin/ShaarePublishController.php new file mode 100644 index 00000000..18afc2d1 --- /dev/null +++ b/application/front/controller/admin/ShaarePublishController.php | |||
@@ -0,0 +1,263 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\Bookmark; | ||
8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
9 | use Shaarli\Formatter\BookmarkFormatter; | ||
10 | use Shaarli\Formatter\BookmarkMarkdownFormatter; | ||
11 | use Shaarli\Render\TemplatePage; | ||
12 | use Shaarli\Thumbnailer; | ||
13 | use Slim\Http\Request; | ||
14 | use Slim\Http\Response; | ||
15 | |||
16 | class ShaarePublishController extends ShaarliAdminController | ||
17 | { | ||
18 | /** | ||
19 | * @var BookmarkFormatter[] Statically cached instances of formatters | ||
20 | */ | ||
21 | protected $formatters = []; | ||
22 | |||
23 | /** | ||
24 | * @var array Statically cached bookmark's tags counts | ||
25 | */ | ||
26 | protected $tags; | ||
27 | |||
28 | /** | ||
29 | * GET /admin/shaare - Displays the bookmark form for creation. | ||
30 | * Note that if the URL is found in existing bookmarks, then it will be in edit mode. | ||
31 | */ | ||
32 | public function displayCreateForm(Request $request, Response $response): Response | ||
33 | { | ||
34 | $url = cleanup_url($request->getParam('post')); | ||
35 | $link = $this->buildLinkDataFromUrl($request, $url); | ||
36 | |||
37 | return $this->displayForm($link, $link['linkIsNew'], $request, $response); | ||
38 | } | ||
39 | |||
40 | /** | ||
41 | * POST /admin/shaare-batch - Displays multiple creation/edit forms from bulk add in add-link page. | ||
42 | */ | ||
43 | public function displayCreateBatchForms(Request $request, Response $response): Response | ||
44 | { | ||
45 | $urls = array_map('cleanup_url', explode(PHP_EOL, $request->getParam('urls'))); | ||
46 | |||
47 | $links = []; | ||
48 | foreach ($urls as $url) { | ||
49 | if (empty($url)) { | ||
50 | continue; | ||
51 | } | ||
52 | $link = $this->buildLinkDataFromUrl($request, $url); | ||
53 | $data = $this->buildFormData($link, $link['linkIsNew'], $request); | ||
54 | $data['token'] = $this->container->sessionManager->generateToken(); | ||
55 | $data['source'] = 'batch'; | ||
56 | |||
57 | $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK); | ||
58 | |||
59 | $links[] = $data; | ||
60 | } | ||
61 | |||
62 | $this->assignView('links', $links); | ||
63 | $this->assignView('batch_mode', true); | ||
64 | $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true)); | ||
65 | |||
66 | return $response->write($this->render(TemplatePage::EDIT_LINK_BATCH)); | ||
67 | } | ||
68 | |||
69 | /** | ||
70 | * GET /admin/shaare/{id} - Displays the bookmark form in edition mode. | ||
71 | */ | ||
72 | public function displayEditForm(Request $request, Response $response, array $args): Response | ||
73 | { | ||
74 | $id = $args['id'] ?? ''; | ||
75 | try { | ||
76 | if (false === ctype_digit($id)) { | ||
77 | throw new BookmarkNotFoundException(); | ||
78 | } | ||
79 | $bookmark = $this->container->bookmarkService->get((int) $id); // Read database | ||
80 | } catch (BookmarkNotFoundException $e) { | ||
81 | $this->saveErrorMessage(sprintf( | ||
82 | t('Bookmark with identifier %s could not be found.'), | ||
83 | $id | ||
84 | )); | ||
85 | |||
86 | return $this->redirect($response, '/'); | ||
87 | } | ||
88 | |||
89 | $formatter = $this->getFormatter('raw'); | ||
90 | $link = $formatter->format($bookmark); | ||
91 | |||
92 | return $this->displayForm($link, false, $request, $response); | ||
93 | } | ||
94 | |||
95 | /** | ||
96 | * POST /admin/shaare | ||
97 | */ | ||
98 | public function save(Request $request, Response $response): Response | ||
99 | { | ||
100 | $this->checkToken($request); | ||
101 | |||
102 | // lf_id should only be present if the link exists. | ||
103 | $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null; | ||
104 | if (null !== $id && true === $this->container->bookmarkService->exists($id)) { | ||
105 | // Edit | ||
106 | $bookmark = $this->container->bookmarkService->get($id); | ||
107 | } else { | ||
108 | // New link | ||
109 | $bookmark = new Bookmark(); | ||
110 | } | ||
111 | |||
112 | $bookmark->setTitle($request->getParam('lf_title')); | ||
113 | $bookmark->setDescription($request->getParam('lf_description')); | ||
114 | $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', [])); | ||
115 | $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN)); | ||
116 | $bookmark->setTagsString($request->getParam('lf_tags')); | ||
117 | |||
118 | if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE | ||
119 | && true !== $this->container->conf->get('general.enable_async_metadata', true) | ||
120 | && $bookmark->shouldUpdateThumbnail() | ||
121 | ) { | ||
122 | $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); | ||
123 | } | ||
124 | $this->container->bookmarkService->addOrSet($bookmark, false); | ||
125 | |||
126 | // To preserve backward compatibility with 3rd parties, plugins still use arrays | ||
127 | $formatter = $this->getFormatter('raw'); | ||
128 | $data = $formatter->format($bookmark); | ||
129 | $this->executePageHooks('save_link', $data); | ||
130 | |||
131 | $bookmark->fromArray($data); | ||
132 | $this->container->bookmarkService->set($bookmark); | ||
133 | |||
134 | // If we are called from the bookmarklet, we must close the popup: | ||
135 | if ($request->getParam('source') === 'bookmarklet') { | ||
136 | return $response->write('<script>self.close();</script>'); | ||
137 | } elseif ($request->getParam('source') === 'batch') { | ||
138 | return $response; | ||
139 | } | ||
140 | |||
141 | if (!empty($request->getParam('returnurl'))) { | ||
142 | $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl'); | ||
143 | } | ||
144 | |||
145 | return $this->redirectFromReferer( | ||
146 | $request, | ||
147 | $response, | ||
148 | ['/admin/add-shaare', '/admin/shaare'], ['addlink', 'post', 'edit_link'], | ||
149 | $bookmark->getShortUrl() | ||
150 | ); | ||
151 | } | ||
152 | |||
153 | /** | ||
154 | * Helper function used to display the shaare form whether it's a new or existing bookmark. | ||
155 | * | ||
156 | * @param array $link data used in template, either from parameters or from the data store | ||
157 | */ | ||
158 | protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response | ||
159 | { | ||
160 | $data = $this->buildFormData($link, $isNew, $request); | ||
161 | |||
162 | $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK); | ||
163 | |||
164 | foreach ($data as $key => $value) { | ||
165 | $this->assignView($key, $value); | ||
166 | } | ||
167 | |||
168 | $editLabel = false === $isNew ? t('Edit') .' ' : ''; | ||
169 | $this->assignView( | ||
170 | 'pagetitle', | ||
171 | $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
172 | ); | ||
173 | |||
174 | return $response->write($this->render(TemplatePage::EDIT_LINK)); | ||
175 | } | ||
176 | |||
177 | protected function buildLinkDataFromUrl(Request $request, string $url): array | ||
178 | { | ||
179 | // Check if URL is not already in database (in this case, we will edit the existing link) | ||
180 | $bookmark = $this->container->bookmarkService->findByUrl($url); | ||
181 | if (null === $bookmark) { | ||
182 | // Get shaare data if it was provided in URL (e.g.: by the bookmarklet). | ||
183 | $title = $request->getParam('title'); | ||
184 | $description = $request->getParam('description'); | ||
185 | $tags = $request->getParam('tags'); | ||
186 | if ($request->getParam('private') !== null) { | ||
187 | $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN); | ||
188 | } else { | ||
189 | $private = $this->container->conf->get('privacy.default_private_links', false); | ||
190 | } | ||
191 | |||
192 | // If this is an HTTP(S) link, we try go get the page to extract | ||
193 | // the title (otherwise we will to straight to the edit form.) | ||
194 | if (true !== $this->container->conf->get('general.enable_async_metadata', true) | ||
195 | && empty($title) | ||
196 | && strpos(get_url_scheme($url) ?: '', 'http') !== false | ||
197 | ) { | ||
198 | $metadata = $this->container->metadataRetriever->retrieve($url); | ||
199 | } | ||
200 | |||
201 | if (empty($url)) { | ||
202 | $metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: ')); | ||
203 | } | ||
204 | |||
205 | return [ | ||
206 | 'title' => $title ?? $metadata['title'] ?? '', | ||
207 | 'url' => $url ?? '', | ||
208 | 'description' => $description ?? $metadata['description'] ?? '', | ||
209 | 'tags' => $tags ?? $metadata['tags'] ?? '', | ||
210 | 'private' => $private, | ||
211 | 'linkIsNew' => true, | ||
212 | ]; | ||
213 | } | ||
214 | |||
215 | $formatter = $this->getFormatter('raw'); | ||
216 | $link = $formatter->format($bookmark); | ||
217 | $link['linkIsNew'] = false; | ||
218 | |||
219 | return $link; | ||
220 | } | ||
221 | |||
222 | protected function buildFormData(array $link, bool $isNew, Request $request): array | ||
223 | { | ||
224 | return escape([ | ||
225 | 'link' => $link, | ||
226 | 'link_is_new' => $isNew, | ||
227 | 'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '', | ||
228 | 'source' => $request->getParam('source') ?? '', | ||
229 | 'tags' => $this->getTags(), | ||
230 | 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false), | ||
231 | 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true), | ||
232 | 'retrieve_description' => $this->container->conf->get('general.retrieve_description', false), | ||
233 | ]); | ||
234 | } | ||
235 | |||
236 | /** | ||
237 | * Memoize formatterFactory->getFormatter() calls. | ||
238 | */ | ||
239 | protected function getFormatter(string $type): BookmarkFormatter | ||
240 | { | ||
241 | if (!array_key_exists($type, $this->formatters) || $this->formatters[$type] === null) { | ||
242 | $this->formatters[$type] = $this->container->formatterFactory->getFormatter($type); | ||
243 | } | ||
244 | |||
245 | return $this->formatters[$type]; | ||
246 | } | ||
247 | |||
248 | /** | ||
249 | * Memoize bookmarkService->bookmarksCountPerTag() calls. | ||
250 | */ | ||
251 | protected function getTags(): array | ||
252 | { | ||
253 | if ($this->tags === null) { | ||
254 | $this->tags = $this->container->bookmarkService->bookmarksCountPerTag(); | ||
255 | |||
256 | if ($this->container->conf->get('formatter') === 'markdown') { | ||
257 | $this->tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1; | ||
258 | } | ||
259 | } | ||
260 | |||
261 | return $this->tags; | ||
262 | } | ||
263 | } | ||
diff --git a/application/front/controller/admin/ShaarliAdminController.php b/application/front/controller/admin/ShaarliAdminController.php new file mode 100644 index 00000000..c26c9cbe --- /dev/null +++ b/application/front/controller/admin/ShaarliAdminController.php | |||
@@ -0,0 +1,71 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Front\Controller\Visitor\ShaarliVisitorController; | ||
8 | use Shaarli\Front\Exception\WrongTokenException; | ||
9 | use Shaarli\Security\SessionManager; | ||
10 | use Slim\Http\Request; | ||
11 | |||
12 | /** | ||
13 | * Class ShaarliAdminController | ||
14 | * | ||
15 | * All admin controllers (for logged in users) MUST extend this abstract class. | ||
16 | * It makes sure that the user is properly logged in, and otherwise throw an exception | ||
17 | * which will redirect to the login page. | ||
18 | * | ||
19 | * @package Shaarli\Front\Controller\Admin | ||
20 | */ | ||
21 | abstract class ShaarliAdminController extends ShaarliVisitorController | ||
22 | { | ||
23 | /** | ||
24 | * Any persistent action to the config or data store must check the XSRF token validity. | ||
25 | */ | ||
26 | protected function checkToken(Request $request): bool | ||
27 | { | ||
28 | if (!$this->container->sessionManager->checkToken($request->getParam('token'))) { | ||
29 | throw new WrongTokenException(); | ||
30 | } | ||
31 | |||
32 | return true; | ||
33 | } | ||
34 | |||
35 | /** | ||
36 | * Save a SUCCESS message in user session, which will be displayed on any template page. | ||
37 | */ | ||
38 | protected function saveSuccessMessage(string $message): void | ||
39 | { | ||
40 | $this->saveMessage(SessionManager::KEY_SUCCESS_MESSAGES, $message); | ||
41 | } | ||
42 | |||
43 | /** | ||
44 | * Save a WARNING message in user session, which will be displayed on any template page. | ||
45 | */ | ||
46 | protected function saveWarningMessage(string $message): void | ||
47 | { | ||
48 | $this->saveMessage(SessionManager::KEY_WARNING_MESSAGES, $message); | ||
49 | } | ||
50 | |||
51 | /** | ||
52 | * Save an ERROR message in user session, which will be displayed on any template page. | ||
53 | */ | ||
54 | protected function saveErrorMessage(string $message): void | ||
55 | { | ||
56 | $this->saveMessage(SessionManager::KEY_ERROR_MESSAGES, $message); | ||
57 | } | ||
58 | |||
59 | /** | ||
60 | * Use the sessionManager to save the provided message using the proper type. | ||
61 | * | ||
62 | * @param string $type successed/warnings/errors | ||
63 | */ | ||
64 | protected function saveMessage(string $type, string $message): void | ||
65 | { | ||
66 | $messages = $this->container->sessionManager->getSessionParameter($type) ?? []; | ||
67 | $messages[] = $message; | ||
68 | |||
69 | $this->container->sessionManager->setSessionParameter($type, $messages); | ||
70 | } | ||
71 | } | ||
diff --git a/application/front/controller/admin/ThumbnailsController.php b/application/front/controller/admin/ThumbnailsController.php new file mode 100644 index 00000000..4dc09d38 --- /dev/null +++ b/application/front/controller/admin/ThumbnailsController.php | |||
@@ -0,0 +1,65 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class ToolsController | ||
14 | * | ||
15 | * Slim controller used to handle thumbnails update. | ||
16 | */ | ||
17 | class ThumbnailsController extends ShaarliAdminController | ||
18 | { | ||
19 | /** | ||
20 | * GET /admin/thumbnails - Display thumbnails update page | ||
21 | */ | ||
22 | public function index(Request $request, Response $response): Response | ||
23 | { | ||
24 | $ids = []; | ||
25 | foreach ($this->container->bookmarkService->search() as $bookmark) { | ||
26 | // A note or not HTTP(S) | ||
27 | if ($bookmark->isNote() || !startsWith(strtolower($bookmark->getUrl()), 'http')) { | ||
28 | continue; | ||
29 | } | ||
30 | |||
31 | $ids[] = $bookmark->getId(); | ||
32 | } | ||
33 | |||
34 | $this->assignView('ids', $ids); | ||
35 | $this->assignView( | ||
36 | 'pagetitle', | ||
37 | t('Thumbnails update') .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
38 | ); | ||
39 | |||
40 | return $response->write($this->render(TemplatePage::THUMBNAILS)); | ||
41 | } | ||
42 | |||
43 | /** | ||
44 | * PATCH /admin/shaare/{id}/thumbnail-update - Route for AJAX calls | ||
45 | */ | ||
46 | public function ajaxUpdate(Request $request, Response $response, array $args): Response | ||
47 | { | ||
48 | $id = $args['id'] ?? null; | ||
49 | |||
50 | if (false === ctype_digit($id)) { | ||
51 | return $response->withStatus(400); | ||
52 | } | ||
53 | |||
54 | try { | ||
55 | $bookmark = $this->container->bookmarkService->get((int) $id); | ||
56 | } catch (BookmarkNotFoundException $e) { | ||
57 | return $response->withStatus(404); | ||
58 | } | ||
59 | |||
60 | $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); | ||
61 | $this->container->bookmarkService->set($bookmark); | ||
62 | |||
63 | return $response->withJson($this->container->formatterFactory->getFormatter('raw')->format($bookmark)); | ||
64 | } | ||
65 | } | ||
diff --git a/application/front/controller/admin/TokenController.php b/application/front/controller/admin/TokenController.php new file mode 100644 index 00000000..08d68d0a --- /dev/null +++ b/application/front/controller/admin/TokenController.php | |||
@@ -0,0 +1,26 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Class TokenController | ||
12 | * | ||
13 | * Endpoint used to retrieve a XSRF token. Useful for AJAX requests. | ||
14 | */ | ||
15 | class TokenController extends ShaarliAdminController | ||
16 | { | ||
17 | /** | ||
18 | * GET /admin/token | ||
19 | */ | ||
20 | public function getToken(Request $request, Response $response): Response | ||
21 | { | ||
22 | $response = $response->withHeader('Content-Type', 'text/plain'); | ||
23 | |||
24 | return $response->write($this->container->sessionManager->generateToken()); | ||
25 | } | ||
26 | } | ||
diff --git a/application/front/controller/admin/ToolsController.php b/application/front/controller/admin/ToolsController.php new file mode 100644 index 00000000..a87f20d2 --- /dev/null +++ b/application/front/controller/admin/ToolsController.php | |||
@@ -0,0 +1,35 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Shaarli\Render\TemplatePage; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Class ToolsController | ||
13 | * | ||
14 | * Slim controller used to display the tools page. | ||
15 | */ | ||
16 | class ToolsController extends ShaarliAdminController | ||
17 | { | ||
18 | public function index(Request $request, Response $response): Response | ||
19 | { | ||
20 | $data = [ | ||
21 | 'pageabsaddr' => index_url($this->container->environment), | ||
22 | 'sslenabled' => is_https($this->container->environment), | ||
23 | ]; | ||
24 | |||
25 | $this->executePageHooks('render_tools', $data, TemplatePage::TOOLS); | ||
26 | |||
27 | foreach ($data as $key => $value) { | ||
28 | $this->assignView($key, $value); | ||
29 | } | ||
30 | |||
31 | $this->assignView('pagetitle', t('Tools') .' - '. $this->container->conf->get('general.title', 'Shaarli')); | ||
32 | |||
33 | return $response->write($this->render(TemplatePage::TOOLS)); | ||
34 | } | ||
35 | } | ||
diff --git a/application/front/controller/visitor/BookmarkListController.php b/application/front/controller/visitor/BookmarkListController.php new file mode 100644 index 00000000..78c474c9 --- /dev/null +++ b/application/front/controller/visitor/BookmarkListController.php | |||
@@ -0,0 +1,249 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Bookmark\Bookmark; | ||
8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
9 | use Shaarli\Legacy\LegacyController; | ||
10 | use Shaarli\Legacy\UnknowLegacyRouteException; | ||
11 | use Shaarli\Render\TemplatePage; | ||
12 | use Shaarli\Thumbnailer; | ||
13 | use Slim\Http\Request; | ||
14 | use Slim\Http\Response; | ||
15 | |||
16 | /** | ||
17 | * Class BookmarkListController | ||
18 | * | ||
19 | * Slim controller used to render the bookmark list, the home page of Shaarli. | ||
20 | * It also displays permalinks, and process legacy routes based on GET parameters. | ||
21 | */ | ||
22 | class BookmarkListController extends ShaarliVisitorController | ||
23 | { | ||
24 | /** | ||
25 | * GET / - Displays the bookmark list, with optional filter parameters. | ||
26 | */ | ||
27 | public function index(Request $request, Response $response): Response | ||
28 | { | ||
29 | $legacyResponse = $this->processLegacyController($request, $response); | ||
30 | if (null !== $legacyResponse) { | ||
31 | return $legacyResponse; | ||
32 | } | ||
33 | |||
34 | $formatter = $this->container->formatterFactory->getFormatter(); | ||
35 | $formatter->addContextData('base_path', $this->container->basePath); | ||
36 | |||
37 | $searchTags = normalize_spaces($request->getParam('searchtags') ?? ''); | ||
38 | $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));; | ||
39 | |||
40 | // Filter bookmarks according search parameters. | ||
41 | $visibility = $this->container->sessionManager->getSessionParameter('visibility'); | ||
42 | $search = [ | ||
43 | 'searchtags' => $searchTags, | ||
44 | 'searchterm' => $searchTerm, | ||
45 | ]; | ||
46 | $linksToDisplay = $this->container->bookmarkService->search( | ||
47 | $search, | ||
48 | $visibility, | ||
49 | false, | ||
50 | !!$this->container->sessionManager->getSessionParameter('untaggedonly') | ||
51 | ) ?? []; | ||
52 | |||
53 | // ---- Handle paging. | ||
54 | $keys = []; | ||
55 | foreach ($linksToDisplay as $key => $value) { | ||
56 | $keys[] = $key; | ||
57 | } | ||
58 | |||
59 | $linksPerPage = $this->container->sessionManager->getSessionParameter('LINKS_PER_PAGE', 20) ?: 20; | ||
60 | |||
61 | // Select articles according to paging. | ||
62 | $pageCount = (int) ceil(count($keys) / $linksPerPage) ?: 1; | ||
63 | $page = (int) $request->getParam('page') ?? 1; | ||
64 | $page = $page < 1 ? 1 : $page; | ||
65 | $page = $page > $pageCount ? $pageCount : $page; | ||
66 | |||
67 | // Start index. | ||
68 | $i = ($page - 1) * $linksPerPage; | ||
69 | $end = $i + $linksPerPage; | ||
70 | |||
71 | $linkDisp = []; | ||
72 | $save = false; | ||
73 | while ($i < $end && $i < count($keys)) { | ||
74 | $save = $this->updateThumbnail($linksToDisplay[$keys[$i]], false) || $save; | ||
75 | $link = $formatter->format($linksToDisplay[$keys[$i]]); | ||
76 | |||
77 | $linkDisp[$keys[$i]] = $link; | ||
78 | $i++; | ||
79 | } | ||
80 | |||
81 | if ($save) { | ||
82 | $this->container->bookmarkService->save(); | ||
83 | } | ||
84 | |||
85 | // Compute paging navigation | ||
86 | $searchtagsUrl = $searchTags === '' ? '' : '&searchtags=' . urlencode($searchTags); | ||
87 | $searchtermUrl = $searchTerm === '' ? '' : '&searchterm=' . urlencode($searchTerm); | ||
88 | |||
89 | $previous_page_url = ''; | ||
90 | if ($i !== count($keys)) { | ||
91 | $previous_page_url = '?page=' . ($page + 1) . $searchtermUrl . $searchtagsUrl; | ||
92 | } | ||
93 | $next_page_url = ''; | ||
94 | if ($page > 1) { | ||
95 | $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl; | ||
96 | } | ||
97 | |||
98 | // Fill all template fields. | ||
99 | $data = array_merge( | ||
100 | $this->initializeTemplateVars(), | ||
101 | [ | ||
102 | 'previous_page_url' => $previous_page_url, | ||
103 | 'next_page_url' => $next_page_url, | ||
104 | 'page_current' => $page, | ||
105 | 'page_max' => $pageCount, | ||
106 | 'result_count' => count($linksToDisplay), | ||
107 | 'search_term' => escape($searchTerm), | ||
108 | 'search_tags' => escape($searchTags), | ||
109 | 'search_tags_url' => array_map('urlencode', explode(' ', $searchTags)), | ||
110 | 'visibility' => $visibility, | ||
111 | 'links' => $linkDisp, | ||
112 | ] | ||
113 | ); | ||
114 | |||
115 | if (!empty($searchTerm) || !empty($searchTags)) { | ||
116 | $data['pagetitle'] = t('Search: '); | ||
117 | $data['pagetitle'] .= ! empty($searchTerm) ? $searchTerm . ' ' : ''; | ||
118 | $bracketWrap = function ($tag) { | ||
119 | return '[' . $tag . ']'; | ||
120 | }; | ||
121 | $data['pagetitle'] .= ! empty($searchTags) | ||
122 | ? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' ' | ||
123 | : ''; | ||
124 | $data['pagetitle'] .= '- '; | ||
125 | } | ||
126 | |||
127 | $data['pagetitle'] = ($data['pagetitle'] ?? '') . $this->container->conf->get('general.title', 'Shaarli'); | ||
128 | |||
129 | $this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST); | ||
130 | $this->assignAllView($data); | ||
131 | |||
132 | return $response->write($this->render(TemplatePage::LINKLIST)); | ||
133 | } | ||
134 | |||
135 | /** | ||
136 | * GET /shaare/{hash} - Display a single shaare | ||
137 | */ | ||
138 | public function permalink(Request $request, Response $response, array $args): Response | ||
139 | { | ||
140 | $privateKey = $request->getParam('key'); | ||
141 | |||
142 | try { | ||
143 | $bookmark = $this->container->bookmarkService->findByHash($args['hash'], $privateKey); | ||
144 | } catch (BookmarkNotFoundException $e) { | ||
145 | $this->assignView('error_message', $e->getMessage()); | ||
146 | |||
147 | return $response->write($this->render(TemplatePage::ERROR_404)); | ||
148 | } | ||
149 | |||
150 | $this->updateThumbnail($bookmark); | ||
151 | |||
152 | $formatter = $this->container->formatterFactory->getFormatter(); | ||
153 | $formatter->addContextData('base_path', $this->container->basePath); | ||
154 | |||
155 | $data = array_merge( | ||
156 | $this->initializeTemplateVars(), | ||
157 | [ | ||
158 | 'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'), | ||
159 | 'links' => [$formatter->format($bookmark)], | ||
160 | ] | ||
161 | ); | ||
162 | |||
163 | $this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST); | ||
164 | $this->assignAllView($data); | ||
165 | |||
166 | return $response->write($this->render(TemplatePage::LINKLIST)); | ||
167 | } | ||
168 | |||
169 | /** | ||
170 | * Update the thumbnail of a single bookmark if necessary. | ||
171 | */ | ||
172 | protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool | ||
173 | { | ||
174 | if (false === $this->container->loginManager->isLoggedIn()) { | ||
175 | return false; | ||
176 | } | ||
177 | |||
178 | // If thumbnail should be updated, we reset it to null | ||
179 | if ($bookmark->shouldUpdateThumbnail()) { | ||
180 | $bookmark->setThumbnail(null); | ||
181 | |||
182 | // Requires an update, not async retrieval, thumbnails enabled | ||
183 | if ($bookmark->shouldUpdateThumbnail() | ||
184 | && true !== $this->container->conf->get('general.enable_async_metadata', true) | ||
185 | && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE | ||
186 | ) { | ||
187 | $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); | ||
188 | $this->container->bookmarkService->set($bookmark, $writeDatastore); | ||
189 | |||
190 | return true; | ||
191 | } | ||
192 | } | ||
193 | |||
194 | return false; | ||
195 | } | ||
196 | |||
197 | /** | ||
198 | * @return string[] Default template variables without values. | ||
199 | */ | ||
200 | protected function initializeTemplateVars(): array | ||
201 | { | ||
202 | return [ | ||
203 | 'previous_page_url' => '', | ||
204 | 'next_page_url' => '', | ||
205 | 'page_max' => '', | ||
206 | 'search_tags' => '', | ||
207 | 'result_count' => '', | ||
208 | 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true) | ||
209 | ]; | ||
210 | } | ||
211 | |||
212 | /** | ||
213 | * Process legacy routes if necessary. They used query parameters. | ||
214 | * If no legacy routes is passed, return null. | ||
215 | */ | ||
216 | protected function processLegacyController(Request $request, Response $response): ?Response | ||
217 | { | ||
218 | // Legacy smallhash filter | ||
219 | $queryString = $this->container->environment['QUERY_STRING'] ?? null; | ||
220 | if (null !== $queryString && 1 === preg_match('/^([a-zA-Z0-9-_@]{6})($|&|#)/', $queryString, $match)) { | ||
221 | return $this->redirect($response, '/shaare/' . $match[1]); | ||
222 | } | ||
223 | |||
224 | // Legacy controllers (mostly used for redirections) | ||
225 | if (null !== $request->getQueryParam('do')) { | ||
226 | $legacyController = new LegacyController($this->container); | ||
227 | |||
228 | try { | ||
229 | return $legacyController->process($request, $response, $request->getQueryParam('do')); | ||
230 | } catch (UnknowLegacyRouteException $e) { | ||
231 | // We ignore legacy 404 | ||
232 | return null; | ||
233 | } | ||
234 | } | ||
235 | |||
236 | // Legacy GET admin routes | ||
237 | $legacyGetRoutes = array_intersect( | ||
238 | LegacyController::LEGACY_GET_ROUTES, | ||
239 | array_keys($request->getQueryParams() ?? []) | ||
240 | ); | ||
241 | if (1 === count($legacyGetRoutes)) { | ||
242 | $legacyController = new LegacyController($this->container); | ||
243 | |||
244 | return $legacyController->process($request, $response, $legacyGetRoutes[0]); | ||
245 | } | ||
246 | |||
247 | return null; | ||
248 | } | ||
249 | } | ||
diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php new file mode 100644 index 00000000..728bc2d8 --- /dev/null +++ b/application/front/controller/visitor/DailyController.php | |||
@@ -0,0 +1,205 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use DateTime; | ||
8 | use Shaarli\Bookmark\Bookmark; | ||
9 | use Shaarli\Helper\DailyPageHelper; | ||
10 | use Shaarli\Render\TemplatePage; | ||
11 | use Slim\Http\Request; | ||
12 | use Slim\Http\Response; | ||
13 | |||
14 | /** | ||
15 | * Class DailyController | ||
16 | * | ||
17 | * Slim controller used to render the daily page. | ||
18 | */ | ||
19 | class DailyController extends ShaarliVisitorController | ||
20 | { | ||
21 | public static $DAILY_RSS_NB_DAYS = 8; | ||
22 | |||
23 | /** | ||
24 | * Controller displaying all bookmarks published in a single day. | ||
25 | * It take a `day` date query parameter (format YYYYMMDD). | ||
26 | */ | ||
27 | public function index(Request $request, Response $response): Response | ||
28 | { | ||
29 | $type = DailyPageHelper::extractRequestedType($request); | ||
30 | $format = DailyPageHelper::getFormatByType($type); | ||
31 | $latestBookmark = $this->container->bookmarkService->getLatest(); | ||
32 | $dateTime = DailyPageHelper::extractRequestedDateTime($type, $request->getQueryParam($type), $latestBookmark); | ||
33 | $start = DailyPageHelper::getStartDateTimeByType($type, $dateTime); | ||
34 | $end = DailyPageHelper::getEndDateTimeByType($type, $dateTime); | ||
35 | $dailyDesc = DailyPageHelper::getDescriptionByType($type, $dateTime); | ||
36 | |||
37 | $linksToDisplay = $this->container->bookmarkService->findByDate( | ||
38 | $start, | ||
39 | $end, | ||
40 | $previousDay, | ||
41 | $nextDay | ||
42 | ); | ||
43 | |||
44 | $formatter = $this->container->formatterFactory->getFormatter(); | ||
45 | $formatter->addContextData('base_path', $this->container->basePath); | ||
46 | // We pre-format some fields for proper output. | ||
47 | foreach ($linksToDisplay as $key => $bookmark) { | ||
48 | $linksToDisplay[$key] = $formatter->format($bookmark); | ||
49 | // This page is a bit specific, we need raw description to calculate the length | ||
50 | $linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description']; | ||
51 | $linksToDisplay[$key]['description'] = $bookmark->getDescription(); | ||
52 | } | ||
53 | |||
54 | $data = [ | ||
55 | 'linksToDisplay' => $linksToDisplay, | ||
56 | 'dayDate' => $start, | ||
57 | 'day' => $start->getTimestamp(), | ||
58 | 'previousday' => $previousDay ? $previousDay->format($format) : '', | ||
59 | 'nextday' => $nextDay ? $nextDay->format($format) : '', | ||
60 | 'dayDesc' => $dailyDesc, | ||
61 | 'type' => $type, | ||
62 | 'localizedType' => $this->translateType($type), | ||
63 | ]; | ||
64 | |||
65 | // Hooks are called before column construction so that plugins don't have to deal with columns. | ||
66 | $this->executePageHooks('render_daily', $data, TemplatePage::DAILY); | ||
67 | |||
68 | $data['cols'] = $this->calculateColumns($data['linksToDisplay']); | ||
69 | |||
70 | $this->assignAllView($data); | ||
71 | |||
72 | $mainTitle = $this->container->conf->get('general.title', 'Shaarli'); | ||
73 | $this->assignView( | ||
74 | 'pagetitle', | ||
75 | $data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle | ||
76 | ); | ||
77 | |||
78 | return $response->write($this->render(TemplatePage::DAILY)); | ||
79 | } | ||
80 | |||
81 | /** | ||
82 | * Daily RSS feed: 1 RSS entry per day giving all the bookmarks on that day. | ||
83 | * Gives the last 7 days (which have bookmarks). | ||
84 | * This RSS feed cannot be filtered and does not trigger plugins yet. | ||
85 | */ | ||
86 | public function rss(Request $request, Response $response): Response | ||
87 | { | ||
88 | $response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8'); | ||
89 | |||
90 | $pageUrl = page_url($this->container->environment); | ||
91 | $cache = $this->container->pageCacheManager->getCachePage($pageUrl); | ||
92 | |||
93 | $cached = $cache->cachedVersion(); | ||
94 | if (!empty($cached)) { | ||
95 | return $response->write($cached); | ||
96 | } | ||
97 | |||
98 | $days = []; | ||
99 | $type = DailyPageHelper::extractRequestedType($request); | ||
100 | $format = DailyPageHelper::getFormatByType($type); | ||
101 | $length = DailyPageHelper::getRssLengthByType($type); | ||
102 | foreach ($this->container->bookmarkService->search() as $bookmark) { | ||
103 | $day = $bookmark->getCreated()->format($format); | ||
104 | |||
105 | // Stop iterating after DAILY_RSS_NB_DAYS entries | ||
106 | if (count($days) === $length && !isset($days[$day])) { | ||
107 | break; | ||
108 | } | ||
109 | |||
110 | $days[$day][] = $bookmark; | ||
111 | } | ||
112 | |||
113 | // Build the RSS feed. | ||
114 | $indexUrl = escape(index_url($this->container->environment)); | ||
115 | |||
116 | $formatter = $this->container->formatterFactory->getFormatter(); | ||
117 | $formatter->addContextData('index_url', $indexUrl); | ||
118 | |||
119 | $dataPerDay = []; | ||
120 | |||
121 | /** @var Bookmark[] $bookmarks */ | ||
122 | foreach ($days as $day => $bookmarks) { | ||
123 | $dayDateTime = DailyPageHelper::extractRequestedDateTime($type, (string) $day); | ||
124 | $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dayDateTime); | ||
125 | |||
126 | // We only want the RSS entry to be published when the period is over. | ||
127 | if (new DateTime() < $endDateTime) { | ||
128 | continue; | ||
129 | } | ||
130 | |||
131 | $dataPerDay[$day] = [ | ||
132 | 'date' => $endDateTime, | ||
133 | 'date_rss' => $endDateTime->format(DateTime::RSS), | ||
134 | 'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime), | ||
135 | 'absolute_url' => $indexUrl . 'daily?'. $type .'=' . $day, | ||
136 | 'links' => [], | ||
137 | ]; | ||
138 | |||
139 | foreach ($bookmarks as $key => $bookmark) { | ||
140 | $dataPerDay[$day]['links'][$key] = $formatter->format($bookmark); | ||
141 | |||
142 | // Make permalink URL absolute | ||
143 | if ($bookmark->isNote()) { | ||
144 | $dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl(); | ||
145 | } | ||
146 | } | ||
147 | } | ||
148 | |||
149 | $this->assignAllView([ | ||
150 | 'title' => $this->container->conf->get('general.title', 'Shaarli'), | ||
151 | 'index_url' => $indexUrl, | ||
152 | 'page_url' => $pageUrl, | ||
153 | 'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false), | ||
154 | 'days' => $dataPerDay, | ||
155 | 'type' => $type, | ||
156 | 'localizedType' => $this->translateType($type), | ||
157 | ]); | ||
158 | |||
159 | $rssContent = $this->render(TemplatePage::DAILY_RSS); | ||
160 | |||
161 | $cache->cache($rssContent); | ||
162 | |||
163 | return $response->write($rssContent); | ||
164 | } | ||
165 | |||
166 | /** | ||
167 | * We need to spread the articles on 3 columns. | ||
168 | * did not want to use a JavaScript lib like http://masonry.desandro.com/ | ||
169 | * so I manually spread entries with a simple method: I roughly evaluate the | ||
170 | * height of a div according to title and description length. | ||
171 | */ | ||
172 | protected function calculateColumns(array $links): array | ||
173 | { | ||
174 | // Entries to display, for each column. | ||
175 | $columns = [[], [], []]; | ||
176 | // Rough estimate of columns fill. | ||
177 | $fill = [0, 0, 0]; | ||
178 | foreach ($links as $link) { | ||
179 | // Roughly estimate length of entry (by counting characters) | ||
180 | // Title: 30 chars = 1 line. 1 line is 30 pixels height. | ||
181 | // Description: 836 characters gives roughly 342 pixel height. | ||
182 | // This is not perfect, but it's usually OK. | ||
183 | $length = strlen($link['title'] ?? '') + (342 * strlen($link['description'] ?? '')) / 836; | ||
184 | if (! empty($link['thumbnail'])) { | ||
185 | $length += 100; // 1 thumbnails roughly takes 100 pixels height. | ||
186 | } | ||
187 | // Then put in column which is the less filled: | ||
188 | $smallest = min($fill); // find smallest value in array. | ||
189 | $index = array_search($smallest, $fill); // find index of this smallest value. | ||
190 | array_push($columns[$index], $link); // Put entry in this column. | ||
191 | $fill[$index] += $length; | ||
192 | } | ||
193 | |||
194 | return $columns; | ||
195 | } | ||
196 | |||
197 | protected function translateType($type): string | ||
198 | { | ||
199 | return [ | ||
200 | t('day') => t('Daily'), | ||
201 | t('week') => t('Weekly'), | ||
202 | t('month') => t('Monthly'), | ||
203 | ][t($type)] ?? t('Daily'); | ||
204 | } | ||
205 | } | ||
diff --git a/application/front/controller/visitor/ErrorController.php b/application/front/controller/visitor/ErrorController.php new file mode 100644 index 00000000..8da11172 --- /dev/null +++ b/application/front/controller/visitor/ErrorController.php | |||
@@ -0,0 +1,42 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Front\Exception\ShaarliFrontException; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Controller used to render the error page, with a provided exception. | ||
13 | * It is actually used as a Slim error handler. | ||
14 | */ | ||
15 | class ErrorController extends ShaarliVisitorController | ||
16 | { | ||
17 | public function __invoke(Request $request, Response $response, \Throwable $throwable): Response | ||
18 | { | ||
19 | // Unknown error encountered | ||
20 | $this->container->pageBuilder->reset(); | ||
21 | |||
22 | if ($throwable instanceof ShaarliFrontException) { | ||
23 | // Functional error | ||
24 | $this->assignView('message', nl2br($throwable->getMessage())); | ||
25 | |||
26 | $response = $response->withStatus($throwable->getCode()); | ||
27 | } else { | ||
28 | // Internal error (any other Throwable) | ||
29 | if ($this->container->conf->get('dev.debug', false)) { | ||
30 | $this->assignView('message', $throwable->getMessage()); | ||
31 | $this->assignView('stacktrace', exception2text($throwable)); | ||
32 | } else { | ||
33 | $this->assignView('message', t('An unexpected error occurred.')); | ||
34 | } | ||
35 | |||
36 | $response = $response->withStatus(500); | ||
37 | } | ||
38 | |||
39 | |||
40 | return $response->write($this->render('error')); | ||
41 | } | ||
42 | } | ||
diff --git a/application/front/controller/visitor/ErrorNotFoundController.php b/application/front/controller/visitor/ErrorNotFoundController.php new file mode 100644 index 00000000..758dd83b --- /dev/null +++ b/application/front/controller/visitor/ErrorNotFoundController.php | |||
@@ -0,0 +1,29 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Controller used to render the 404 error page. | ||
12 | */ | ||
13 | class ErrorNotFoundController extends ShaarliVisitorController | ||
14 | { | ||
15 | public function __invoke(Request $request, Response $response): Response | ||
16 | { | ||
17 | // Request from the API | ||
18 | if (false !== strpos($request->getRequestTarget(), '/api/v1')) { | ||
19 | return $response->withStatus(404); | ||
20 | } | ||
21 | |||
22 | // This is required because the middleware is ignored if the route is not found. | ||
23 | $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/'); | ||
24 | |||
25 | $this->assignView('error_message', t('Requested page could not be found.')); | ||
26 | |||
27 | return $response->withStatus(404)->write($this->render('404')); | ||
28 | } | ||
29 | } | ||
diff --git a/application/front/controller/visitor/FeedController.php b/application/front/controller/visitor/FeedController.php new file mode 100644 index 00000000..8d8b546a --- /dev/null +++ b/application/front/controller/visitor/FeedController.php | |||
@@ -0,0 +1,58 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Feed\FeedBuilder; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Class FeedController | ||
13 | * | ||
14 | * Slim controller handling ATOM and RSS feed. | ||
15 | */ | ||
16 | class FeedController extends ShaarliVisitorController | ||
17 | { | ||
18 | public function atom(Request $request, Response $response): Response | ||
19 | { | ||
20 | return $this->processRequest(FeedBuilder::$FEED_ATOM, $request, $response); | ||
21 | } | ||
22 | |||
23 | public function rss(Request $request, Response $response): Response | ||
24 | { | ||
25 | return $this->processRequest(FeedBuilder::$FEED_RSS, $request, $response); | ||
26 | } | ||
27 | |||
28 | protected function processRequest(string $feedType, Request $request, Response $response): Response | ||
29 | { | ||
30 | $response = $response->withHeader('Content-Type', 'application/'. $feedType .'+xml; charset=utf-8'); | ||
31 | |||
32 | $pageUrl = page_url($this->container->environment); | ||
33 | $cache = $this->container->pageCacheManager->getCachePage($pageUrl); | ||
34 | |||
35 | $cached = $cache->cachedVersion(); | ||
36 | if (!empty($cached)) { | ||
37 | return $response->write($cached); | ||
38 | } | ||
39 | |||
40 | // Generate data. | ||
41 | $this->container->feedBuilder->setLocale(strtolower(setlocale(LC_COLLATE, 0))); | ||
42 | $this->container->feedBuilder->setHideDates($this->container->conf->get('privacy.hide_timestamps', false)); | ||
43 | $this->container->feedBuilder->setUsePermalinks( | ||
44 | null !== $request->getParam('permalinks') || !$this->container->conf->get('feed.rss_permalinks') | ||
45 | ); | ||
46 | |||
47 | $data = $this->container->feedBuilder->buildData($feedType, $request->getParams()); | ||
48 | |||
49 | $this->executePageHooks('render_feed', $data, 'feed.' . $feedType); | ||
50 | $this->assignAllView($data); | ||
51 | |||
52 | $content = $this->render('feed.' . $feedType); | ||
53 | |||
54 | $cache->cache($content); | ||
55 | |||
56 | return $response->write($content); | ||
57 | } | ||
58 | } | ||
diff --git a/application/front/controller/visitor/InstallController.php b/application/front/controller/visitor/InstallController.php new file mode 100644 index 00000000..22329294 --- /dev/null +++ b/application/front/controller/visitor/InstallController.php | |||
@@ -0,0 +1,175 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Container\ShaarliContainer; | ||
8 | use Shaarli\Front\Exception\AlreadyInstalledException; | ||
9 | use Shaarli\Front\Exception\ResourcePermissionException; | ||
10 | use Shaarli\Helper\ApplicationUtils; | ||
11 | use Shaarli\Languages; | ||
12 | use Shaarli\Security\SessionManager; | ||
13 | use Slim\Http\Request; | ||
14 | use Slim\Http\Response; | ||
15 | |||
16 | /** | ||
17 | * Slim controller used to render install page, and create initial configuration file. | ||
18 | */ | ||
19 | class InstallController extends ShaarliVisitorController | ||
20 | { | ||
21 | public const SESSION_TEST_KEY = 'session_tested'; | ||
22 | public const SESSION_TEST_VALUE = 'Working'; | ||
23 | |||
24 | public function __construct(ShaarliContainer $container) | ||
25 | { | ||
26 | parent::__construct($container); | ||
27 | |||
28 | if (is_file($this->container->conf->getConfigFileExt())) { | ||
29 | throw new AlreadyInstalledException(); | ||
30 | } | ||
31 | } | ||
32 | |||
33 | /** | ||
34 | * Display the install template page. | ||
35 | * Also test file permissions and sessions beforehand. | ||
36 | */ | ||
37 | public function index(Request $request, Response $response): Response | ||
38 | { | ||
39 | // Before installation, we'll make sure that permissions are set properly, and sessions are working. | ||
40 | $this->checkPermissions(); | ||
41 | |||
42 | if (static::SESSION_TEST_VALUE | ||
43 | !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) | ||
44 | ) { | ||
45 | $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE); | ||
46 | |||
47 | return $this->redirect($response, '/install/session-test'); | ||
48 | } | ||
49 | |||
50 | [$continents, $cities] = generateTimeZoneData(timezone_identifiers_list(), date_default_timezone_get()); | ||
51 | |||
52 | $this->assignView('continents', $continents); | ||
53 | $this->assignView('cities', $cities); | ||
54 | $this->assignView('languages', Languages::getAvailableLanguages()); | ||
55 | |||
56 | $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION)); | ||
57 | |||
58 | $this->assignView('php_version', PHP_VERSION); | ||
59 | $this->assignView('php_eol', format_date($phpEol, false)); | ||
60 | $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable()); | ||
61 | $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement()); | ||
62 | $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf)); | ||
63 | |||
64 | $this->assignView('pagetitle', t('Install Shaarli')); | ||
65 | |||
66 | return $response->write($this->render('install')); | ||
67 | } | ||
68 | |||
69 | /** | ||
70 | * Route checking that the session parameter has been properly saved between two distinct requests. | ||
71 | * If the session parameter is preserved, redirect to install template page, otherwise displays error. | ||
72 | */ | ||
73 | public function sessionTest(Request $request, Response $response): Response | ||
74 | { | ||
75 | // This part makes sure sessions works correctly. | ||
76 | // (Because on some hosts, session.save_path may not be set correctly, | ||
77 | // or we may not have write access to it.) | ||
78 | if (static::SESSION_TEST_VALUE | ||
79 | !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) | ||
80 | ) { | ||
81 | // Step 2: Check if data in session is correct. | ||
82 | $msg = t( | ||
83 | '<pre>Sessions do not seem to work correctly on your server.<br>'. | ||
84 | 'Make sure the variable "session.save_path" is set correctly in your PHP config, '. | ||
85 | 'and that you have write access to it.<br>'. | ||
86 | 'It currently points to %s.<br>'. | ||
87 | 'On some browsers, accessing your server via a hostname like \'localhost\' '. | ||
88 | 'or any custom hostname without a dot causes cookie storage to fail. '. | ||
89 | 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>' | ||
90 | ); | ||
91 | $msg = sprintf($msg, $this->container->sessionManager->getSavePath()); | ||
92 | |||
93 | $this->assignView('message', $msg); | ||
94 | |||
95 | return $response->write($this->render('error')); | ||
96 | } | ||
97 | |||
98 | return $this->redirect($response, '/install'); | ||
99 | } | ||
100 | |||
101 | /** | ||
102 | * Save installation form and initialize config file and datastore if necessary. | ||
103 | */ | ||
104 | public function save(Request $request, Response $response): Response | ||
105 | { | ||
106 | $timezone = 'UTC'; | ||
107 | if (!empty($request->getParam('continent')) | ||
108 | && !empty($request->getParam('city')) | ||
109 | && isTimeZoneValid($request->getParam('continent'), $request->getParam('city')) | ||
110 | ) { | ||
111 | $timezone = $request->getParam('continent') . '/' . $request->getParam('city'); | ||
112 | } | ||
113 | $this->container->conf->set('general.timezone', $timezone); | ||
114 | |||
115 | $login = $request->getParam('setlogin'); | ||
116 | $this->container->conf->set('credentials.login', $login); | ||
117 | $salt = sha1(uniqid('', true) .'_'. mt_rand()); | ||
118 | $this->container->conf->set('credentials.salt', $salt); | ||
119 | $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt)); | ||
120 | |||
121 | if (!empty($request->getParam('title'))) { | ||
122 | $this->container->conf->set('general.title', escape($request->getParam('title'))); | ||
123 | } else { | ||
124 | $this->container->conf->set( | ||
125 | 'general.title', | ||
126 | 'Shared bookmarks on '.escape(index_url($this->container->environment)) | ||
127 | ); | ||
128 | } | ||
129 | |||
130 | $this->container->conf->set('translation.language', escape($request->getParam('language'))); | ||
131 | $this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck'))); | ||
132 | $this->container->conf->set('api.enabled', !empty($request->getParam('enableApi'))); | ||
133 | $this->container->conf->set( | ||
134 | 'api.secret', | ||
135 | generate_api_secret( | ||
136 | $this->container->conf->get('credentials.login'), | ||
137 | $this->container->conf->get('credentials.salt') | ||
138 | ) | ||
139 | ); | ||
140 | $this->container->conf->set('general.header_link', $this->container->basePath . '/'); | ||
141 | |||
142 | try { | ||
143 | // Everything is ok, let's create config file. | ||
144 | $this->container->conf->write($this->container->loginManager->isLoggedIn()); | ||
145 | } catch (\Exception $e) { | ||
146 | $this->assignView('message', t('Error while writing config file after configuration update.')); | ||
147 | $this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString()); | ||
148 | |||
149 | return $response->write($this->render('error')); | ||
150 | } | ||
151 | |||
152 | $this->container->sessionManager->setSessionParameter( | ||
153 | SessionManager::KEY_SUCCESS_MESSAGES, | ||
154 | [t('Shaarli is now configured. Please login and start shaaring your bookmarks!')] | ||
155 | ); | ||
156 | |||
157 | return $this->redirect($response, '/login'); | ||
158 | } | ||
159 | |||
160 | protected function checkPermissions(): bool | ||
161 | { | ||
162 | // Ensure Shaarli has proper access to its resources | ||
163 | $errors = ApplicationUtils::checkResourcePermissions($this->container->conf, true); | ||
164 | if (empty($errors)) { | ||
165 | return true; | ||
166 | } | ||
167 | |||
168 | $message = t('Insufficient permissions:') . PHP_EOL; | ||
169 | foreach ($errors as $error) { | ||
170 | $message .= PHP_EOL . $error; | ||
171 | } | ||
172 | |||
173 | throw new ResourcePermissionException($message); | ||
174 | } | ||
175 | } | ||
diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php new file mode 100644 index 00000000..f5038fe3 --- /dev/null +++ b/application/front/controller/visitor/LoginController.php | |||
@@ -0,0 +1,153 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Front\Exception\CantLoginException; | ||
8 | use Shaarli\Front\Exception\LoginBannedException; | ||
9 | use Shaarli\Front\Exception\WrongTokenException; | ||
10 | use Shaarli\Render\TemplatePage; | ||
11 | use Shaarli\Security\CookieManager; | ||
12 | use Shaarli\Security\SessionManager; | ||
13 | use Slim\Http\Request; | ||
14 | use Slim\Http\Response; | ||
15 | |||
16 | /** | ||
17 | * Class LoginController | ||
18 | * | ||
19 | * Slim controller used to render the login page. | ||
20 | * | ||
21 | * The login page is not available if the user is banned | ||
22 | * or if open shaarli setting is enabled. | ||
23 | */ | ||
24 | class LoginController extends ShaarliVisitorController | ||
25 | { | ||
26 | /** | ||
27 | * GET /login - Display the login page. | ||
28 | */ | ||
29 | public function index(Request $request, Response $response): Response | ||
30 | { | ||
31 | try { | ||
32 | $this->checkLoginState(); | ||
33 | } catch (CantLoginException $e) { | ||
34 | return $this->redirect($response, '/'); | ||
35 | } | ||
36 | |||
37 | if ($request->getParam('login') !== null) { | ||
38 | $this->assignView('username', escape($request->getParam('login'))); | ||
39 | } | ||
40 | |||
41 | $returnUrl = $request->getParam('returnurl') ?? $this->container->environment['HTTP_REFERER'] ?? null; | ||
42 | |||
43 | $this | ||
44 | ->assignView('returnurl', escape($returnUrl)) | ||
45 | ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true)) | ||
46 | ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli')) | ||
47 | ; | ||
48 | |||
49 | return $response->write($this->render(TemplatePage::LOGIN)); | ||
50 | } | ||
51 | |||
52 | /** | ||
53 | * POST /login - Process login | ||
54 | */ | ||
55 | public function login(Request $request, Response $response): Response | ||
56 | { | ||
57 | if (!$this->container->sessionManager->checkToken($request->getParam('token'))) { | ||
58 | throw new WrongTokenException(); | ||
59 | } | ||
60 | |||
61 | try { | ||
62 | $this->checkLoginState(); | ||
63 | } catch (CantLoginException $e) { | ||
64 | return $this->redirect($response, '/'); | ||
65 | } | ||
66 | |||
67 | if (!$this->container->loginManager->checkCredentials( | ||
68 | client_ip_id($this->container->environment), | ||
69 | $request->getParam('login'), | ||
70 | $request->getParam('password') | ||
71 | ) | ||
72 | ) { | ||
73 | $this->container->loginManager->handleFailedLogin($this->container->environment); | ||
74 | |||
75 | $this->container->sessionManager->setSessionParameter( | ||
76 | SessionManager::KEY_ERROR_MESSAGES, | ||
77 | [t('Wrong login/password.')] | ||
78 | ); | ||
79 | |||
80 | // Call controller directly instead of unnecessary redirection | ||
81 | return $this->index($request, $response); | ||
82 | } | ||
83 | |||
84 | $this->container->loginManager->handleSuccessfulLogin($this->container->environment); | ||
85 | |||
86 | $cookiePath = $this->container->basePath . '/'; | ||
87 | $expirationTime = $this->saveLongLastingSession($request, $cookiePath); | ||
88 | $this->renewUserSession($cookiePath, $expirationTime); | ||
89 | |||
90 | // Force referer from given return URL | ||
91 | $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl'); | ||
92 | |||
93 | return $this->redirectFromReferer($request, $response, ['login', 'install']); | ||
94 | } | ||
95 | |||
96 | /** | ||
97 | * Make sure that the user is allowed to login and/or displaying the login page: | ||
98 | * - not already logged in | ||
99 | * - not open shaarli | ||
100 | * - not banned | ||
101 | */ | ||
102 | protected function checkLoginState(): bool | ||
103 | { | ||
104 | if ($this->container->loginManager->isLoggedIn() | ||
105 | || $this->container->conf->get('security.open_shaarli', false) | ||
106 | ) { | ||
107 | throw new CantLoginException(); | ||
108 | } | ||
109 | |||
110 | if (true !== $this->container->loginManager->canLogin($this->container->environment)) { | ||
111 | throw new LoginBannedException(); | ||
112 | } | ||
113 | |||
114 | return true; | ||
115 | } | ||
116 | |||
117 | /** | ||
118 | * @return int Session duration in seconds | ||
119 | */ | ||
120 | protected function saveLongLastingSession(Request $request, string $cookiePath): int | ||
121 | { | ||
122 | if (empty($request->getParam('longlastingsession'))) { | ||
123 | // Standard session expiration (=when browser closes) | ||
124 | $expirationTime = 0; | ||
125 | } else { | ||
126 | // Keep the session cookie even after the browser closes | ||
127 | $this->container->sessionManager->setStaySignedIn(true); | ||
128 | $expirationTime = $this->container->sessionManager->extendSession(); | ||
129 | } | ||
130 | |||
131 | $this->container->cookieManager->setCookieParameter( | ||
132 | CookieManager::STAY_SIGNED_IN, | ||
133 | $this->container->loginManager->getStaySignedInToken(), | ||
134 | $expirationTime, | ||
135 | $cookiePath | ||
136 | ); | ||
137 | |||
138 | return $expirationTime; | ||
139 | } | ||
140 | |||
141 | protected function renewUserSession(string $cookiePath, int $expirationTime): void | ||
142 | { | ||
143 | // Send cookie with the new expiration date to the browser | ||
144 | $this->container->sessionManager->destroy(); | ||
145 | $this->container->sessionManager->cookieParameters( | ||
146 | $expirationTime, | ||
147 | $cookiePath, | ||
148 | $this->container->environment['SERVER_NAME'] | ||
149 | ); | ||
150 | $this->container->sessionManager->start(); | ||
151 | $this->container->sessionManager->regenerateId(true); | ||
152 | } | ||
153 | } | ||
diff --git a/application/front/controller/visitor/OpenSearchController.php b/application/front/controller/visitor/OpenSearchController.php new file mode 100644 index 00000000..36d60acf --- /dev/null +++ b/application/front/controller/visitor/OpenSearchController.php | |||
@@ -0,0 +1,27 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Render\TemplatePage; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Class OpenSearchController | ||
13 | * | ||
14 | * Slim controller used to render open search template. | ||
15 | * This allows to add Shaarli as a search engine within the browser. | ||
16 | */ | ||
17 | class OpenSearchController extends ShaarliVisitorController | ||
18 | { | ||
19 | public function index(Request $request, Response $response): Response | ||
20 | { | ||
21 | $response = $response->withHeader('Content-Type', 'application/opensearchdescription+xml; charset=utf-8'); | ||
22 | |||
23 | $this->assignView('serverurl', index_url($this->container->environment)); | ||
24 | |||
25 | return $response->write($this->render(TemplatePage::OPEN_SEARCH)); | ||
26 | } | ||
27 | } | ||
diff --git a/application/front/controller/visitor/PictureWallController.php b/application/front/controller/visitor/PictureWallController.php new file mode 100644 index 00000000..3c57f8dd --- /dev/null +++ b/application/front/controller/visitor/PictureWallController.php | |||
@@ -0,0 +1,54 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Front\Exception\ThumbnailsDisabledException; | ||
8 | use Shaarli\Render\TemplatePage; | ||
9 | use Shaarli\Thumbnailer; | ||
10 | use Slim\Http\Request; | ||
11 | use Slim\Http\Response; | ||
12 | |||
13 | /** | ||
14 | * Class PicturesWallController | ||
15 | * | ||
16 | * Slim controller used to render the pictures wall page. | ||
17 | * If thumbnails mode is set to NONE, we just render the template without any image. | ||
18 | */ | ||
19 | class PictureWallController extends ShaarliVisitorController | ||
20 | { | ||
21 | public function index(Request $request, Response $response): Response | ||
22 | { | ||
23 | if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) === Thumbnailer::MODE_NONE) { | ||
24 | throw new ThumbnailsDisabledException(); | ||
25 | } | ||
26 | |||
27 | $this->assignView( | ||
28 | 'pagetitle', | ||
29 | t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
30 | ); | ||
31 | |||
32 | // Optionally filter the results: | ||
33 | $links = $this->container->bookmarkService->search($request->getQueryParams()); | ||
34 | $linksToDisplay = []; | ||
35 | |||
36 | // Get only bookmarks which have a thumbnail. | ||
37 | // Note: we do not retrieve thumbnails here, the request is too heavy. | ||
38 | $formatter = $this->container->formatterFactory->getFormatter('raw'); | ||
39 | foreach ($links as $key => $link) { | ||
40 | if (!empty($link->getThumbnail())) { | ||
41 | $linksToDisplay[] = $formatter->format($link); | ||
42 | } | ||
43 | } | ||
44 | |||
45 | $data = ['linksToDisplay' => $linksToDisplay]; | ||
46 | $this->executePageHooks('render_picwall', $data, TemplatePage::PICTURE_WALL); | ||
47 | |||
48 | foreach ($data as $key => $value) { | ||
49 | $this->assignView($key, $value); | ||
50 | } | ||
51 | |||
52 | return $response->write($this->render(TemplatePage::PICTURE_WALL)); | ||
53 | } | ||
54 | } | ||
diff --git a/application/front/controller/visitor/PublicSessionFilterController.php b/application/front/controller/visitor/PublicSessionFilterController.php new file mode 100644 index 00000000..1a66362d --- /dev/null +++ b/application/front/controller/visitor/PublicSessionFilterController.php | |||
@@ -0,0 +1,46 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Security\SessionManager; | ||
8 | use Slim\Http\Request; | ||
9 | use Slim\Http\Response; | ||
10 | |||
11 | /** | ||
12 | * Slim controller used to handle filters stored in the visitor session, links per page, etc. | ||
13 | */ | ||
14 | class PublicSessionFilterController extends ShaarliVisitorController | ||
15 | { | ||
16 | /** | ||
17 | * GET /links-per-page: set the number of bookmarks to display per page in homepage | ||
18 | */ | ||
19 | public function linksPerPage(Request $request, Response $response): Response | ||
20 | { | ||
21 | $linksPerPage = $request->getParam('nb') ?? null; | ||
22 | if (null === $linksPerPage || false === is_numeric($linksPerPage)) { | ||
23 | $linksPerPage = $this->container->conf->get('general.links_per_page', 20); | ||
24 | } | ||
25 | |||
26 | $this->container->sessionManager->setSessionParameter( | ||
27 | SessionManager::KEY_LINKS_PER_PAGE, | ||
28 | abs(intval($linksPerPage)) | ||
29 | ); | ||
30 | |||
31 | return $this->redirectFromReferer($request, $response, ['linksperpage'], ['nb']); | ||
32 | } | ||
33 | |||
34 | /** | ||
35 | * GET /untagged-only: allows to display only bookmarks without any tag | ||
36 | */ | ||
37 | public function untaggedOnly(Request $request, Response $response): Response | ||
38 | { | ||
39 | $this->container->sessionManager->setSessionParameter( | ||
40 | SessionManager::KEY_UNTAGGED_ONLY, | ||
41 | empty($this->container->sessionManager->getSessionParameter(SessionManager::KEY_UNTAGGED_ONLY)) | ||
42 | ); | ||
43 | |||
44 | return $this->redirectFromReferer($request, $response, ['untaggedonly', 'untagged-only']); | ||
45 | } | ||
46 | } | ||
diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php new file mode 100644 index 00000000..54f9fe03 --- /dev/null +++ b/application/front/controller/visitor/ShaarliVisitorController.php | |||
@@ -0,0 +1,181 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Shaarli\Bookmark\BookmarkFilter; | ||
8 | use Shaarli\Container\ShaarliContainer; | ||
9 | use Slim\Http\Request; | ||
10 | use Slim\Http\Response; | ||
11 | |||
12 | /** | ||
13 | * Class ShaarliVisitorController | ||
14 | * | ||
15 | * All controllers accessible by visitors (non logged in users) should extend this abstract class. | ||
16 | * Contains a few helper function for template rendering, plugins, etc. | ||
17 | * | ||
18 | * @package Shaarli\Front\Controller\Visitor | ||
19 | */ | ||
20 | abstract class ShaarliVisitorController | ||
21 | { | ||
22 | /** @var ShaarliContainer */ | ||
23 | protected $container; | ||
24 | |||
25 | /** @param ShaarliContainer $container Slim container (extended for attribute completion). */ | ||
26 | public function __construct(ShaarliContainer $container) | ||
27 | { | ||
28 | $this->container = $container; | ||
29 | } | ||
30 | |||
31 | /** | ||
32 | * Assign variables to RainTPL template through the PageBuilder. | ||
33 | * | ||
34 | * @param mixed $value Value to assign to the template | ||
35 | */ | ||
36 | protected function assignView(string $name, $value): self | ||
37 | { | ||
38 | $this->container->pageBuilder->assign($name, $value); | ||
39 | |||
40 | return $this; | ||
41 | } | ||
42 | |||
43 | /** | ||
44 | * Assign variables to RainTPL template through the PageBuilder. | ||
45 | * | ||
46 | * @param mixed $data Values to assign to the template and their keys | ||
47 | */ | ||
48 | protected function assignAllView(array $data): self | ||
49 | { | ||
50 | foreach ($data as $key => $value) { | ||
51 | $this->assignView($key, $value); | ||
52 | } | ||
53 | |||
54 | return $this; | ||
55 | } | ||
56 | |||
57 | protected function render(string $template): string | ||
58 | { | ||
59 | $this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL)); | ||
60 | $this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE)); | ||
61 | |||
62 | $this->executeDefaultHooks($template); | ||
63 | |||
64 | $this->assignView('plugin_errors', $this->container->pluginManager->getErrors()); | ||
65 | |||
66 | return $this->container->pageBuilder->render($template, $this->container->basePath); | ||
67 | } | ||
68 | |||
69 | /** | ||
70 | * Call plugin hooks for header, footer and includes, specifying which page will be rendered. | ||
71 | * Then assign generated data to RainTPL. | ||
72 | */ | ||
73 | protected function executeDefaultHooks(string $template): void | ||
74 | { | ||
75 | $common_hooks = [ | ||
76 | 'includes', | ||
77 | 'header', | ||
78 | 'footer', | ||
79 | ]; | ||
80 | |||
81 | $parameters = $this->buildPluginParameters($template); | ||
82 | |||
83 | foreach ($common_hooks as $name) { | ||
84 | $pluginData = []; | ||
85 | $this->container->pluginManager->executeHooks( | ||
86 | 'render_' . $name, | ||
87 | $pluginData, | ||
88 | $parameters | ||
89 | ); | ||
90 | $this->assignView('plugins_' . $name, $pluginData); | ||
91 | } | ||
92 | } | ||
93 | |||
94 | protected function executePageHooks(string $hook, array &$data, string $template = null): void | ||
95 | { | ||
96 | $this->container->pluginManager->executeHooks( | ||
97 | $hook, | ||
98 | $data, | ||
99 | $this->buildPluginParameters($template) | ||
100 | ); | ||
101 | } | ||
102 | |||
103 | protected function buildPluginParameters(?string $template): array | ||
104 | { | ||
105 | return [ | ||
106 | 'target' => $template, | ||
107 | 'loggedin' => $this->container->loginManager->isLoggedIn(), | ||
108 | 'basePath' => $this->container->basePath, | ||
109 | 'rootPath' => preg_replace('#/index\.php$#', '', $this->container->basePath), | ||
110 | 'bookmarkService' => $this->container->bookmarkService | ||
111 | ]; | ||
112 | } | ||
113 | |||
114 | /** | ||
115 | * Simple helper which prepend the base path to redirect path. | ||
116 | * | ||
117 | * @param Response $response | ||
118 | * @param string $path Absolute path, e.g.: `/`, or `/admin/shaare/123` regardless of install directory | ||
119 | * | ||
120 | * @return Response updated | ||
121 | */ | ||
122 | protected function redirect(Response $response, string $path): Response | ||
123 | { | ||
124 | return $response->withRedirect($this->container->basePath . $path); | ||
125 | } | ||
126 | |||
127 | /** | ||
128 | * Generates a redirection to the previous page, based on the HTTP_REFERER. | ||
129 | * It fails back to the home page. | ||
130 | * | ||
131 | * @param array $loopTerms Terms to remove from path and query string to prevent direction loop. | ||
132 | * @param array $clearParams List of parameter to remove from the query string of the referrer. | ||
133 | */ | ||
134 | protected function redirectFromReferer( | ||
135 | Request $request, | ||
136 | Response $response, | ||
137 | array $loopTerms = [], | ||
138 | array $clearParams = [], | ||
139 | string $anchor = null | ||
140 | ): Response { | ||
141 | $defaultPath = $this->container->basePath . '/'; | ||
142 | $referer = $this->container->environment['HTTP_REFERER'] ?? null; | ||
143 | |||
144 | if (null !== $referer) { | ||
145 | $currentUrl = parse_url($referer); | ||
146 | // If the referer is not related to Shaarli instance, redirect to default | ||
147 | if (isset($currentUrl['host']) | ||
148 | && strpos(index_url($this->container->environment), $currentUrl['host']) === false | ||
149 | ) { | ||
150 | return $response->withRedirect($defaultPath); | ||
151 | } | ||
152 | |||
153 | parse_str($currentUrl['query'] ?? '', $params); | ||
154 | $path = $currentUrl['path'] ?? $defaultPath; | ||
155 | } else { | ||
156 | $params = []; | ||
157 | $path = $defaultPath; | ||
158 | } | ||
159 | |||
160 | // Prevent redirection loop | ||
161 | if (isset($currentUrl)) { | ||
162 | foreach ($clearParams as $value) { | ||
163 | unset($params[$value]); | ||
164 | } | ||
165 | |||
166 | $checkQuery = implode('', array_keys($params)); | ||
167 | foreach ($loopTerms as $value) { | ||
168 | if (strpos($path . $checkQuery, $value) !== false) { | ||
169 | $params = []; | ||
170 | $path = $defaultPath; | ||
171 | break; | ||
172 | } | ||
173 | } | ||
174 | } | ||
175 | |||
176 | $queryString = count($params) > 0 ? '?'. http_build_query($params) : ''; | ||
177 | $anchor = $anchor ? '#' . $anchor : ''; | ||
178 | |||
179 | return $response->withRedirect($path . $queryString . $anchor); | ||
180 | } | ||
181 | } | ||
diff --git a/application/front/controller/visitor/TagCloudController.php b/application/front/controller/visitor/TagCloudController.php new file mode 100644 index 00000000..76ed7690 --- /dev/null +++ b/application/front/controller/visitor/TagCloudController.php | |||
@@ -0,0 +1,121 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Class TagCloud | ||
12 | * | ||
13 | * Slim controller used to render the tag cloud and tag list pages. | ||
14 | */ | ||
15 | class TagCloudController extends ShaarliVisitorController | ||
16 | { | ||
17 | protected const TYPE_CLOUD = 'cloud'; | ||
18 | protected const TYPE_LIST = 'list'; | ||
19 | |||
20 | /** | ||
21 | * Display the tag cloud through the template engine. | ||
22 | * This controller a few filters: | ||
23 | * - Visibility stored in the session for logged in users | ||
24 | * - `searchtags` query parameter: will return tags associated with filter in at least one bookmark | ||
25 | */ | ||
26 | public function cloud(Request $request, Response $response): Response | ||
27 | { | ||
28 | return $this->processRequest(static::TYPE_CLOUD, $request, $response); | ||
29 | } | ||
30 | |||
31 | /** | ||
32 | * Display the tag list through the template engine. | ||
33 | * This controller a few filters: | ||
34 | * - Visibility stored in the session for logged in users | ||
35 | * - `searchtags` query parameter: will return tags associated with filter in at least one bookmark | ||
36 | * - `sort` query parameters: | ||
37 | * + `usage` (default): most used tags first | ||
38 | * + `alpha`: alphabetical order | ||
39 | */ | ||
40 | public function list(Request $request, Response $response): Response | ||
41 | { | ||
42 | return $this->processRequest(static::TYPE_LIST, $request, $response); | ||
43 | } | ||
44 | |||
45 | /** | ||
46 | * Process the request for both tag cloud and tag list endpoints. | ||
47 | */ | ||
48 | protected function processRequest(string $type, Request $request, Response $response): Response | ||
49 | { | ||
50 | if ($this->container->loginManager->isLoggedIn() === true) { | ||
51 | $visibility = $this->container->sessionManager->getSessionParameter('visibility'); | ||
52 | } | ||
53 | |||
54 | $sort = $request->getQueryParam('sort'); | ||
55 | $searchTags = $request->getQueryParam('searchtags'); | ||
56 | $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : []; | ||
57 | |||
58 | $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null); | ||
59 | |||
60 | if (static::TYPE_CLOUD === $type || 'alpha' === $sort) { | ||
61 | // TODO: the sorting should be handled by bookmarkService instead of the controller | ||
62 | alphabetical_sort($tags, false, true); | ||
63 | } | ||
64 | |||
65 | if (static::TYPE_CLOUD === $type) { | ||
66 | $tags = $this->formatTagsForCloud($tags); | ||
67 | } | ||
68 | |||
69 | $tagsUrl = []; | ||
70 | foreach ($tags as $tag => $value) { | ||
71 | $tagsUrl[escape($tag)] = urlencode((string) $tag); | ||
72 | } | ||
73 | |||
74 | $searchTags = implode(' ', escape($filteringTags)); | ||
75 | $searchTagsUrl = urlencode(implode(' ', $filteringTags)); | ||
76 | $data = [ | ||
77 | 'search_tags' => escape($searchTags), | ||
78 | 'search_tags_url' => $searchTagsUrl, | ||
79 | 'tags' => escape($tags), | ||
80 | 'tags_url' => $tagsUrl, | ||
81 | ]; | ||
82 | $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type); | ||
83 | $this->assignAllView($data); | ||
84 | |||
85 | $searchTags = !empty($searchTags) ? $searchTags .' - ' : ''; | ||
86 | $this->assignView( | ||
87 | 'pagetitle', | ||
88 | $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli') | ||
89 | ); | ||
90 | |||
91 | return $response->write($this->render('tag.' . $type)); | ||
92 | } | ||
93 | |||
94 | /** | ||
95 | * Format the tags array for the tag cloud template. | ||
96 | * | ||
97 | * @param array<string, int> $tags List of tags as key with count as value | ||
98 | * | ||
99 | * @return mixed[] List of tags as key, with count and expected font size in a subarray | ||
100 | */ | ||
101 | protected function formatTagsForCloud(array $tags): array | ||
102 | { | ||
103 | // We sort tags alphabetically, then choose a font size according to count. | ||
104 | // First, find max value. | ||
105 | $maxCount = count($tags) > 0 ? max($tags) : 0; | ||
106 | $logMaxCount = $maxCount > 1 ? log($maxCount, 30) : 1; | ||
107 | $tagList = []; | ||
108 | foreach ($tags as $key => $value) { | ||
109 | // Tag font size scaling: | ||
110 | // default 15 and 30 logarithm bases affect scaling, | ||
111 | // 2.2 and 0.8 are arbitrary font sizes in em. | ||
112 | $size = log($value, 15) / $logMaxCount * 2.2 + 0.8; | ||
113 | $tagList[$key] = [ | ||
114 | 'count' => $value, | ||
115 | 'size' => number_format($size, 2, '.', ''), | ||
116 | ]; | ||
117 | } | ||
118 | |||
119 | return $tagList; | ||
120 | } | ||
121 | } | ||
diff --git a/application/front/controller/visitor/TagController.php b/application/front/controller/visitor/TagController.php new file mode 100644 index 00000000..de4e7ea2 --- /dev/null +++ b/application/front/controller/visitor/TagController.php | |||
@@ -0,0 +1,118 @@ | |||
1 | <?php | ||
2 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Visitor; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Class TagController | ||
12 | * | ||
13 | * Slim controller handle tags. | ||
14 | */ | ||
15 | class TagController extends ShaarliVisitorController | ||
16 | { | ||
17 | /** | ||
18 | * Add another tag in the current search through an HTTP redirection. | ||
19 | * | ||
20 | * @param array $args Should contain `newTag` key as tag to add to current search | ||
21 | */ | ||
22 | public function addTag(Request $request, Response $response, array $args): Response | ||
23 | { | ||
24 | $newTag = $args['newTag'] ?? null; | ||
25 | $referer = $this->container->environment['HTTP_REFERER'] ?? null; | ||
26 | |||
27 | // In case browser does not send HTTP_REFERER, we search a single tag | ||
28 | if (null === $referer) { | ||
29 | if (null !== $newTag) { | ||
30 | return $this->redirect($response, '/?searchtags='. urlencode($newTag)); | ||
31 | } | ||
32 | |||
33 | return $this->redirect($response, '/'); | ||
34 | } | ||
35 | |||
36 | $currentUrl = parse_url($referer); | ||
37 | parse_str($currentUrl['query'] ?? '', $params); | ||
38 | |||
39 | if (null === $newTag) { | ||
40 | return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); | ||
41 | } | ||
42 | |||
43 | // Prevent redirection loop | ||
44 | if (isset($params['addtag'])) { | ||
45 | unset($params['addtag']); | ||
46 | } | ||
47 | |||
48 | // Check if this tag is already in the search query and ignore it if it is. | ||
49 | // Each tag is always separated by a space | ||
50 | $currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : []; | ||
51 | |||
52 | $addtag = true; | ||
53 | foreach ($currentTags as $value) { | ||
54 | if ($value === $newTag) { | ||
55 | $addtag = false; | ||
56 | break; | ||
57 | } | ||
58 | } | ||
59 | |||
60 | // Append the tag if necessary | ||
61 | if (true === $addtag) { | ||
62 | $currentTags[] = trim($newTag); | ||
63 | } | ||
64 | |||
65 | $params['searchtags'] = trim(implode(' ', $currentTags)); | ||
66 | |||
67 | // We also remove page (keeping the same page has no sense, since the results are different) | ||
68 | unset($params['page']); | ||
69 | |||
70 | return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); | ||
71 | } | ||
72 | |||
73 | /** | ||
74 | * Remove a tag from the current search through an HTTP redirection. | ||
75 | * | ||
76 | * @param array $args Should contain `tag` key as tag to remove from current search | ||
77 | */ | ||
78 | public function removeTag(Request $request, Response $response, array $args): Response | ||
79 | { | ||
80 | $referer = $this->container->environment['HTTP_REFERER'] ?? null; | ||
81 | |||
82 | // If the referrer is not provided, we can update the search, so we failback on the bookmark list | ||
83 | if (empty($referer)) { | ||
84 | return $this->redirect($response, '/'); | ||
85 | } | ||
86 | |||
87 | $tagToRemove = $args['tag'] ?? null; | ||
88 | $currentUrl = parse_url($referer); | ||
89 | parse_str($currentUrl['query'] ?? '', $params); | ||
90 | |||
91 | if (null === $tagToRemove) { | ||
92 | return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); | ||
93 | } | ||
94 | |||
95 | // Prevent redirection loop | ||
96 | if (isset($params['removetag'])) { | ||
97 | unset($params['removetag']); | ||
98 | } | ||
99 | |||
100 | if (isset($params['searchtags'])) { | ||
101 | $tags = explode(' ', $params['searchtags']); | ||
102 | // Remove value from array $tags. | ||
103 | $tags = array_diff($tags, [$tagToRemove]); | ||
104 | $params['searchtags'] = implode(' ', $tags); | ||
105 | |||
106 | if (empty($params['searchtags'])) { | ||
107 | unset($params['searchtags']); | ||
108 | } | ||
109 | |||
110 | // We also remove page (keeping the same page has no sense, since the results are different) | ||
111 | unset($params['page']); | ||
112 | } | ||
113 | |||
114 | $queryParams = count($params) > 0 ? '?' . http_build_query($params) : ''; | ||
115 | |||
116 | return $response->withRedirect(($currentUrl['path'] ?? './') . $queryParams); | ||
117 | } | ||
118 | } | ||