aboutsummaryrefslogtreecommitdiffhomepage
path: root/application/helper
diff options
context:
space:
mode:
Diffstat (limited to 'application/helper')
-rw-r--r--application/helper/ApplicationUtils.php335
-rw-r--r--application/helper/DailyPageHelper.php236
-rw-r--r--application/helper/FileUtils.php140
3 files changed, 711 insertions, 0 deletions
diff --git a/application/helper/ApplicationUtils.php b/application/helper/ApplicationUtils.php
new file mode 100644
index 00000000..a6c03aae
--- /dev/null
+++ b/application/helper/ApplicationUtils.php
@@ -0,0 +1,335 @@
1<?php
2
3namespace Shaarli\Helper;
4
5use Exception;
6use malkusch\lock\exception\LockAcquireException;
7use malkusch\lock\mutex\FlockMutex;
8use Shaarli\Config\ConfigManager;
9
10/**
11 * Shaarli (application) utilities
12 */
13class ApplicationUtils
14{
15 /**
16 * @var string File containing the current version
17 */
18 public static $VERSION_FILE = 'shaarli_version.php';
19
20 public static $GITHUB_URL = 'https://github.com/shaarli/Shaarli';
21 public static $GIT_RAW_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
22 public static $GIT_BRANCHES = ['latest', 'stable'];
23 private static $VERSION_START_TAG = '<?php /* ';
24 private static $VERSION_END_TAG = ' */ ?>';
25
26 /**
27 * Gets the latest version code from the Git repository
28 *
29 * The code is read from the raw content of the version file on the Git server.
30 *
31 * @param string $url URL to reach to get the latest version.
32 * @param int $timeout Timeout to check the URL (in seconds).
33 *
34 * @return mixed the version code from the repository if available, else 'false'
35 */
36 public static function getLatestGitVersionCode($url, $timeout = 2)
37 {
38 list($headers, $data) = get_http_response($url, $timeout);
39
40 if (strpos($headers[0], '200 OK') === false) {
41 error_log('Failed to retrieve ' . $url);
42 return false;
43 }
44
45 return $data;
46 }
47
48 /**
49 * Retrieve the version from a remote URL or a file.
50 *
51 * @param string $remote URL or file to fetch.
52 * @param int $timeout For URLs fetching.
53 *
54 * @return bool|string The version or false if it couldn't be retrieved.
55 */
56 public static function getVersion($remote, $timeout = 2)
57 {
58 if (startsWith($remote, 'http')) {
59 if (($data = static::getLatestGitVersionCode($remote, $timeout)) === false) {
60 return false;
61 }
62 } else {
63 if (!is_file($remote)) {
64 return false;
65 }
66 $data = file_get_contents($remote);
67 }
68
69 return str_replace(
70 [self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL],
71 ['', '', ''],
72 $data
73 );
74 }
75
76 /**
77 * Checks if a new Shaarli version has been published on the Git repository
78 *
79 * Updates checks are run periodically, according to the following criteria:
80 * - the update checks are enabled (install, global config);
81 * - the user is logged in (or this is an open instance);
82 * - the last check is older than a given interval;
83 * - the check is non-blocking if the HTTPS connection to Git fails;
84 * - in case of failure, the update file's modification date is updated,
85 * to avoid intempestive connection attempts.
86 *
87 * @param string $currentVersion the current version code
88 * @param string $updateFile the file where to store the latest version code
89 * @param int $checkInterval the minimum interval between update checks (in seconds
90 * @param bool $enableCheck whether to check for new versions
91 * @param bool $isLoggedIn whether the user is logged in
92 * @param string $branch check update for the given branch
93 *
94 * @throws Exception an invalid branch has been set for update checks
95 *
96 * @return mixed the new version code if available and greater, else 'false'
97 */
98 public static function checkUpdate(
99 $currentVersion,
100 $updateFile,
101 $checkInterval,
102 $enableCheck,
103 $isLoggedIn,
104 $branch = 'stable'
105 ) {
106 // Do not check versions for visitors
107 // Do not check if the user doesn't want to
108 // Do not check with dev version
109 if (!$isLoggedIn || empty($enableCheck) || $currentVersion === 'dev') {
110 return false;
111 }
112
113 if (is_file($updateFile) && (filemtime($updateFile) > time() - $checkInterval)) {
114 // Shaarli has checked for updates recently - skip HTTP query
115 $latestKnownVersion = file_get_contents($updateFile);
116
117 if (version_compare($latestKnownVersion, $currentVersion) == 1) {
118 return $latestKnownVersion;
119 }
120 return false;
121 }
122
123 if (!in_array($branch, self::$GIT_BRANCHES)) {
124 throw new Exception(
125 'Invalid branch selected for updates: "' . $branch . '"'
126 );
127 }
128
129 // Late Static Binding allows overriding within tests
130 // See http://php.net/manual/en/language.oop5.late-static-bindings.php
131 $latestVersion = static::getVersion(
132 self::$GIT_RAW_URL . '/' . $branch . '/' . self::$VERSION_FILE
133 );
134
135 if (!$latestVersion) {
136 // Only update the file's modification date
137 file_put_contents($updateFile, $currentVersion);
138 return false;
139 }
140
141 // Update the file's content and modification date
142 file_put_contents($updateFile, $latestVersion);
143
144 if (version_compare($latestVersion, $currentVersion) == 1) {
145 return $latestVersion;
146 }
147
148 return false;
149 }
150
151 /**
152 * Checks the PHP version to ensure Shaarli can run
153 *
154 * @param string $minVersion minimum PHP required version
155 * @param string $curVersion current PHP version (use PHP_VERSION)
156 *
157 * @return bool true on success
158 *
159 * @throws Exception the PHP version is not supported
160 */
161 public static function checkPHPVersion($minVersion, $curVersion)
162 {
163 if (version_compare($curVersion, $minVersion) < 0) {
164 $msg = t(
165 'Your PHP version is obsolete!'
166 . ' Shaarli requires at least PHP %s, and thus cannot run.'
167 . ' Your PHP version has known security vulnerabilities and should be'
168 . ' updated as soon as possible.'
169 );
170 throw new Exception(sprintf($msg, $minVersion));
171 }
172 return true;
173 }
174
175 /**
176 * Checks Shaarli has the proper access permissions to its resources
177 *
178 * @param ConfigManager $conf Configuration Manager instance.
179 * @param bool $minimalMode In minimal mode we only check permissions to be able to display a template.
180 * Currently we only need to be able to read the theme and write in raintpl cache.
181 *
182 * @return array A list of the detected configuration issues
183 */
184 public static function checkResourcePermissions(ConfigManager $conf, bool $minimalMode = false): array
185 {
186 $errors = [];
187 $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/');
188
189 // Check script and template directories are readable
190 foreach (
191 [
192 'application',
193 'inc',
194 'plugins',
195 $rainTplDir,
196 $rainTplDir . '/' . $conf->get('resource.theme'),
197 ] as $path
198 ) {
199 if (!is_readable(realpath($path))) {
200 $errors[] = '"' . $path . '" ' . t('directory is not readable');
201 }
202 }
203
204 // Check cache and data directories are readable and writable
205 if ($minimalMode) {
206 $folders = [
207 $conf->get('resource.raintpl_tmp'),
208 ];
209 } else {
210 $folders = [
211 $conf->get('resource.thumbnails_cache'),
212 $conf->get('resource.data_dir'),
213 $conf->get('resource.page_cache'),
214 $conf->get('resource.raintpl_tmp'),
215 ];
216 }
217
218 foreach ($folders as $path) {
219 if (!is_readable(realpath($path))) {
220 $errors[] = '"' . $path . '" ' . t('directory is not readable');
221 }
222 if (!is_writable(realpath($path))) {
223 $errors[] = '"' . $path . '" ' . t('directory is not writable');
224 }
225 }
226
227 if ($minimalMode) {
228 return $errors;
229 }
230
231 // Check configuration files are readable and writable
232 foreach (
233 [
234 $conf->getConfigFileExt(),
235 $conf->get('resource.datastore'),
236 $conf->get('resource.ban_file'),
237 $conf->get('resource.log'),
238 $conf->get('resource.update_check'),
239 ] as $path
240 ) {
241 if (!is_file(realpath($path))) {
242 # the file may not exist yet
243 continue;
244 }
245
246 if (!is_readable(realpath($path))) {
247 $errors[] = '"' . $path . '" ' . t('file is not readable');
248 }
249 if (!is_writable(realpath($path))) {
250 $errors[] = '"' . $path . '" ' . t('file is not writable');
251 }
252 }
253
254 return $errors;
255 }
256
257 public static function checkDatastoreMutex(): array
258 {
259 $mutex = new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2);
260 try {
261 $mutex->synchronized(function () {
262 return true;
263 });
264 } catch (LockAcquireException $e) {
265 $errors[] = t('Lock can not be acquired on the datastore. You might encounter concurrent access issues.');
266 }
267
268 return $errors ?? [];
269 }
270
271 /**
272 * Returns a salted hash representing the current Shaarli version.
273 *
274 * Useful for assets browser cache.
275 *
276 * @param string $currentVersion of Shaarli
277 * @param string $salt User personal salt, also used for the authentication
278 *
279 * @return string version hash
280 */
281 public static function getVersionHash($currentVersion, $salt)
282 {
283 return hash_hmac('sha256', $currentVersion, $salt);
284 }
285
286 /**
287 * Get a list of PHP extensions used by Shaarli.
288 *
289 * @return array[] List of extension with following keys:
290 * - name: extension name
291 * - required: whether the extension is required to use Shaarli
292 * - desc: short description of extension usage in Shaarli
293 * - loaded: whether the extension is properly loaded or not
294 */
295 public static function getPhpExtensionsRequirement(): array
296 {
297 $extensions = [
298 ['name' => 'json', 'required' => true, 'desc' => t('Configuration parsing')],
299 ['name' => 'simplexml', 'required' => true, 'desc' => t('Slim Framework (routing, etc.)')],
300 ['name' => 'mbstring', 'required' => true, 'desc' => t('Multibyte (Unicode) string support')],
301 ['name' => 'gd', 'required' => false, 'desc' => t('Required to use thumbnails')],
302 ['name' => 'intl', 'required' => false, 'desc' => t('Localized text sorting (e.g. e->è->f)')],
303 ['name' => 'curl', 'required' => false, 'desc' => t('Better retrieval of bookmark metadata and thumbnail')],
304 ['name' => 'gettext', 'required' => false, 'desc' => t('Use the translation system in gettext mode')],
305 ['name' => 'ldap', 'required' => false, 'desc' => t('Login using LDAP server')],
306 ];
307
308 foreach ($extensions as &$extension) {
309 $extension['loaded'] = extension_loaded($extension['name']);
310 }
311
312 return $extensions;
313 }
314
315 /**
316 * Return the EOL date of given PHP version. If the version is unknown,
317 * we return today + 2 years.
318 *
319 * @param string $fullVersion PHP version, e.g. 7.4.7
320 *
321 * @return string Date format: YYYY-MM-DD
322 */
323 public static function getPhpEol(string $fullVersion): string
324 {
325 preg_match('/(\d+\.\d+)\.\d+/', $fullVersion, $matches);
326
327 return [
328 '7.1' => '2019-12-01',
329 '7.2' => '2020-11-30',
330 '7.3' => '2021-12-06',
331 '7.4' => '2022-11-28',
332 '8.0' => '2023-12-01',
333 ][$matches[1]] ?? (new \DateTime('+2 year'))->format('Y-m-d');
334 }
335}
diff --git a/application/helper/DailyPageHelper.php b/application/helper/DailyPageHelper.php
new file mode 100644
index 00000000..05f95812
--- /dev/null
+++ b/application/helper/DailyPageHelper.php
@@ -0,0 +1,236 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Helper;
6
7use DatePeriod;
8use DateTimeImmutable;
9use Exception;
10use Shaarli\Bookmark\Bookmark;
11use Slim\Http\Request;
12
13class DailyPageHelper
14{
15 public const MONTH = 'month';
16 public const WEEK = 'week';
17 public const DAY = 'day';
18
19 /**
20 * Extracts the type of the daily to display from the HTTP request parameters
21 *
22 * @param Request $request HTTP request
23 *
24 * @return string month/week/day
25 */
26 public static function extractRequestedType(Request $request): string
27 {
28 if ($request->getQueryParam(static::MONTH) !== null) {
29 return static::MONTH;
30 } elseif ($request->getQueryParam(static::WEEK) !== null) {
31 return static::WEEK;
32 }
33
34 return static::DAY;
35 }
36
37 /**
38 * Extracts a DateTimeImmutable from provided HTTP request.
39 * If no parameter is provided, we rely on the creation date of the latest provided created bookmark.
40 * If the datastore is empty or no bookmark is provided, we use the current date.
41 *
42 * @param string $type month/week/day
43 * @param string|null $requestedDate Input string extracted from the request
44 * @param Bookmark|null $latestBookmark Latest bookmark found in the datastore (by date)
45 *
46 * @return DateTimeImmutable from input or latest bookmark.
47 *
48 * @throws Exception Type not supported.
49 */
50 public static function extractRequestedDateTime(
51 string $type,
52 ?string $requestedDate,
53 Bookmark $latestBookmark = null
54 ): DateTimeImmutable {
55 $format = static::getFormatByType($type);
56 if (empty($requestedDate)) {
57 return $latestBookmark instanceof Bookmark
58 ? new DateTimeImmutable($latestBookmark->getCreated()->format(\DateTime::ATOM))
59 : new DateTimeImmutable()
60 ;
61 }
62
63 // W is not supported by createFromFormat...
64 if ($type === static::WEEK) {
65 return (new DateTimeImmutable())
66 ->setISODate((int) substr($requestedDate, 0, 4), (int) substr($requestedDate, 4, 2))
67 ;
68 }
69
70 return DateTimeImmutable::createFromFormat($format, $requestedDate);
71 }
72
73 /**
74 * Get the DateTime format used by provided type
75 * Examples:
76 * - day: 20201016 (<year><month><day>)
77 * - week: 202041 (<year><week number>)
78 * - month: 202010 (<year><month>)
79 *
80 * @param string $type month/week/day
81 *
82 * @return string DateTime compatible format
83 *
84 * @see https://www.php.net/manual/en/datetime.format.php
85 *
86 * @throws Exception Type not supported.
87 */
88 public static function getFormatByType(string $type): string
89 {
90 switch ($type) {
91 case static::MONTH:
92 return 'Ym';
93 case static::WEEK:
94 return 'YW';
95 case static::DAY:
96 return 'Ymd';
97 default:
98 throw new Exception('Unsupported daily format type');
99 }
100 }
101
102 /**
103 * Get the first DateTime of the time period depending on given datetime and type.
104 * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
105 * and we don't want to alter original datetime.
106 *
107 * @param string $type month/week/day
108 * @param DateTimeImmutable $requested DateTime extracted from request input
109 * (should come from extractRequestedDateTime)
110 *
111 * @return \DateTimeInterface First DateTime of the time period
112 *
113 * @throws Exception Type not supported.
114 */
115 public static function getStartDateTimeByType(string $type, DateTimeImmutable $requested): \DateTimeInterface
116 {
117 switch ($type) {
118 case static::MONTH:
119 return $requested->modify('first day of this month midnight');
120 case static::WEEK:
121 return $requested->modify('Monday this week midnight');
122 case static::DAY:
123 return $requested->modify('Today midnight');
124 default:
125 throw new Exception('Unsupported daily format type');
126 }
127 }
128
129 /**
130 * Get the last DateTime of the time period depending on given datetime and type.
131 * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
132 * and we don't want to alter original datetime.
133 *
134 * @param string $type month/week/day
135 * @param DateTimeImmutable $requested DateTime extracted from request input
136 * (should come from extractRequestedDateTime)
137 *
138 * @return \DateTimeInterface Last DateTime of the time period
139 *
140 * @throws Exception Type not supported.
141 */
142 public static function getEndDateTimeByType(string $type, DateTimeImmutable $requested): \DateTimeInterface
143 {
144 switch ($type) {
145 case static::MONTH:
146 return $requested->modify('last day of this month 23:59:59');
147 case static::WEEK:
148 return $requested->modify('Sunday this week 23:59:59');
149 case static::DAY:
150 return $requested->modify('Today 23:59:59');
151 default:
152 throw new Exception('Unsupported daily format type');
153 }
154 }
155
156 /**
157 * Get localized description of the time period depending on given datetime and type.
158 * Example: for a month period, it returns `October, 2020`.
159 *
160 * @param string $type month/week/day
161 * @param \DateTimeImmutable $requested DateTime extracted from request input
162 * (should come from extractRequestedDateTime)
163 * @param bool $includeRelative Include relative date description (today, yesterday, etc.)
164 *
165 * @return string Localized time period description
166 *
167 * @throws Exception Type not supported.
168 */
169 public static function getDescriptionByType(
170 string $type,
171 \DateTimeImmutable $requested,
172 bool $includeRelative = true
173 ): string {
174 switch ($type) {
175 case static::MONTH:
176 return $requested->format('F') . ', ' . $requested->format('Y');
177 case static::WEEK:
178 $requested = $requested->modify('Monday this week');
179 return t('Week') . ' ' . $requested->format('W') . ' (' . format_date($requested, false) . ')';
180 case static::DAY:
181 $out = '';
182 if ($includeRelative && $requested->format('Ymd') === date('Ymd')) {
183 $out = t('Today') . ' - ';
184 } elseif ($includeRelative && $requested->format('Ymd') === date('Ymd', strtotime('-1 days'))) {
185 $out = t('Yesterday') . ' - ';
186 }
187 return $out . format_date($requested, false);
188 default:
189 throw new Exception('Unsupported daily format type');
190 }
191 }
192
193 /**
194 * Get the number of items to display in the RSS feed depending on the given type.
195 *
196 * @param string $type month/week/day
197 *
198 * @return int number of elements
199 *
200 * @throws Exception Type not supported.
201 */
202 public static function getRssLengthByType(string $type): int
203 {
204 switch ($type) {
205 case static::MONTH:
206 return 12; // 1 year
207 case static::WEEK:
208 return 26; // ~6 months
209 case static::DAY:
210 return 30; // ~1 month
211 default:
212 throw new Exception('Unsupported daily format type');
213 }
214 }
215
216 /**
217 * Get the number of items to display in the RSS feed depending on the given type.
218 *
219 * @param string $type month/week/day
220 * @param ?DateTimeImmutable $requested Currently only used for UT
221 *
222 * @return DatePeriod number of elements
223 *
224 * @throws Exception Type not supported.
225 */
226 public static function getCacheDatePeriodByType(string $type, DateTimeImmutable $requested = null): DatePeriod
227 {
228 $requested = $requested ?? new DateTimeImmutable();
229
230 return new DatePeriod(
231 static::getStartDateTimeByType($type, $requested),
232 new \DateInterval('P1D'),
233 static::getEndDateTimeByType($type, $requested)
234 );
235 }
236}
diff --git a/application/helper/FileUtils.php b/application/helper/FileUtils.php
new file mode 100644
index 00000000..e8a2168c
--- /dev/null
+++ b/application/helper/FileUtils.php
@@ -0,0 +1,140 @@
1<?php
2
3namespace Shaarli\Helper;
4
5use Shaarli\Exceptions\IOException;
6
7/**
8 * Class FileUtils
9 *
10 * Utility class for file manipulation.
11 */
12class FileUtils
13{
14 /**
15 * @var string
16 */
17 protected static $phpPrefix = '<?php /* ';
18
19 /**
20 * @var string
21 */
22 protected static $phpSuffix = ' */ ?>';
23
24 /**
25 * Write data into a file (Shaarli database format).
26 * The data is stored in a PHP file, as a comment, in compressed base64 format.
27 *
28 * The file will be created if it doesn't exist.
29 *
30 * @param string $file File path.
31 * @param mixed $content Content to write.
32 *
33 * @return int|bool Number of bytes written or false if it fails.
34 *
35 * @throws IOException The destination file can't be written.
36 */
37 public static function writeFlatDB($file, $content)
38 {
39 if (is_file($file) && !is_writeable($file)) {
40 // The datastore exists but is not writeable
41 throw new IOException($file);
42 } elseif (!is_file($file) && !is_writeable(dirname($file))) {
43 // The datastore does not exist and its parent directory is not writeable
44 throw new IOException(dirname($file));
45 }
46
47 return file_put_contents(
48 $file,
49 self::$phpPrefix . base64_encode(gzdeflate(serialize($content))) . self::$phpSuffix
50 );
51 }
52
53 /**
54 * Read data from a file containing Shaarli database format content.
55 *
56 * If the file isn't readable or doesn't exist, default data will be returned.
57 *
58 * @param string $file File path.
59 * @param mixed $default The default value to return if the file isn't readable.
60 *
61 * @return mixed The content unserialized, or default if the file isn't readable, or false if it fails.
62 */
63 public static function readFlatDB($file, $default = null)
64 {
65 // Note that gzinflate is faster than gzuncompress.
66 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
67 if (!is_readable($file)) {
68 return $default;
69 }
70
71 $data = file_get_contents($file);
72 if ($data == '') {
73 return $default;
74 }
75
76 return unserialize(
77 gzinflate(
78 base64_decode(
79 substr($data, strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
80 )
81 )
82 );
83 }
84
85 /**
86 * Recursively deletes a folder content, and deletes itself optionally.
87 * If an excluded file is found, folders won't be deleted.
88 *
89 * Additional security: raise an exception if it tries to delete a folder outside of Shaarli directory.
90 *
91 * @param string $path
92 * @param bool $selfDelete Delete the provided folder if true, only its content if false.
93 * @param array $exclude
94 */
95 public static function clearFolder(string $path, bool $selfDelete, array $exclude = []): bool
96 {
97 $skipped = false;
98
99 if (!is_dir($path)) {
100 throw new IOException(t('Provided path is not a directory.'));
101 }
102
103 if (!static::isPathInShaarliFolder($path)) {
104 throw new IOException(t('Trying to delete a folder outside of Shaarli path.'));
105 }
106
107 foreach (new \DirectoryIterator($path) as $file) {
108 if ($file->isDot()) {
109 continue;
110 }
111
112 if (in_array($file->getBasename(), $exclude, true)) {
113 $skipped = true;
114 continue;
115 }
116
117 if ($file->isFile()) {
118 unlink($file->getPathname());
119 } elseif ($file->isDir()) {
120 $skipped = static::clearFolder($file->getRealPath(), true, $exclude) || $skipped;
121 }
122 }
123
124 if ($selfDelete && !$skipped) {
125 rmdir($path);
126 }
127
128 return $skipped;
129 }
130
131 /**
132 * Checks that the given path is inside Shaarli directory.
133 */
134 public static function isPathInShaarliFolder(string $path): bool
135 {
136 $rootDirectory = dirname(dirname(dirname(__FILE__)));
137
138 return strpos(realpath($path), $rootDirectory) !== false;
139 }
140}