Updater.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. <?php
  2. use Shaarli\Config\ConfigJson;
  3. use Shaarli\Config\ConfigPhp;
  4. use Shaarli\Config\ConfigManager;
  5. use Shaarli\Thumbnailer;
  6. /**
  7. * Class Updater.
  8. * Used to update stuff when a new Shaarli's version is reached.
  9. * Update methods are ran only once, and the stored in a JSON file.
  10. */
  11. class Updater
  12. {
  13. /**
  14. * @var array Updates which are already done.
  15. */
  16. protected $doneUpdates;
  17. /**
  18. * @var LinkDB instance.
  19. */
  20. protected $linkDB;
  21. /**
  22. * @var ConfigManager $conf Configuration Manager instance.
  23. */
  24. protected $conf;
  25. /**
  26. * @var bool True if the user is logged in, false otherwise.
  27. */
  28. protected $isLoggedIn;
  29. /**
  30. * @var array $_SESSION
  31. */
  32. protected $session;
  33. /**
  34. * @var ReflectionMethod[] List of current class methods.
  35. */
  36. protected $methods;
  37. /**
  38. * Object constructor.
  39. *
  40. * @param array $doneUpdates Updates which are already done.
  41. * @param LinkDB $linkDB LinkDB instance.
  42. * @param ConfigManager $conf Configuration Manager instance.
  43. * @param boolean $isLoggedIn True if the user is logged in.
  44. * @param array $session $_SESSION (by reference)
  45. *
  46. * @throws ReflectionException
  47. */
  48. public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn, &$session = [])
  49. {
  50. $this->doneUpdates = $doneUpdates;
  51. $this->linkDB = $linkDB;
  52. $this->conf = $conf;
  53. $this->isLoggedIn = $isLoggedIn;
  54. $this->session = &$session;
  55. // Retrieve all update methods.
  56. $class = new ReflectionClass($this);
  57. $this->methods = $class->getMethods();
  58. }
  59. /**
  60. * Run all new updates.
  61. * Update methods have to start with 'updateMethod' and return true (on success).
  62. *
  63. * @return array An array containing ran updates.
  64. *
  65. * @throws UpdaterException If something went wrong.
  66. */
  67. public function update()
  68. {
  69. $updatesRan = array();
  70. // If the user isn't logged in, exit without updating.
  71. if ($this->isLoggedIn !== true) {
  72. return $updatesRan;
  73. }
  74. if ($this->methods === null) {
  75. throw new UpdaterException(t('Couldn\'t retrieve Updater class methods.'));
  76. }
  77. foreach ($this->methods as $method) {
  78. // Not an update method or already done, pass.
  79. if (! startsWith($method->getName(), 'updateMethod')
  80. || in_array($method->getName(), $this->doneUpdates)
  81. ) {
  82. continue;
  83. }
  84. try {
  85. $method->setAccessible(true);
  86. $res = $method->invoke($this);
  87. // Update method must return true to be considered processed.
  88. if ($res === true) {
  89. $updatesRan[] = $method->getName();
  90. }
  91. } catch (Exception $e) {
  92. throw new UpdaterException($method, $e);
  93. }
  94. }
  95. $this->doneUpdates = array_merge($this->doneUpdates, $updatesRan);
  96. return $updatesRan;
  97. }
  98. /**
  99. * @return array Updates methods already processed.
  100. */
  101. public function getDoneUpdates()
  102. {
  103. return $this->doneUpdates;
  104. }
  105. /**
  106. * Move deprecated options.php to config.php.
  107. *
  108. * Milestone 0.9 (old versioning) - shaarli/Shaarli#41:
  109. * options.php is not supported anymore.
  110. */
  111. public function updateMethodMergeDeprecatedConfigFile()
  112. {
  113. if (is_file($this->conf->get('resource.data_dir') . '/options.php')) {
  114. include $this->conf->get('resource.data_dir') . '/options.php';
  115. // Load GLOBALS into config
  116. $allowedKeys = array_merge(ConfigPhp::$ROOT_KEYS);
  117. $allowedKeys[] = 'config';
  118. foreach ($GLOBALS as $key => $value) {
  119. if (in_array($key, $allowedKeys)) {
  120. $this->conf->set($key, $value);
  121. }
  122. }
  123. $this->conf->write($this->isLoggedIn);
  124. unlink($this->conf->get('resource.data_dir').'/options.php');
  125. }
  126. return true;
  127. }
  128. /**
  129. * Move old configuration in PHP to the new config system in JSON format.
  130. *
  131. * Will rename 'config.php' into 'config.save.php' and create 'config.json.php'.
  132. * It will also convert legacy setting keys to the new ones.
  133. */
  134. public function updateMethodConfigToJson()
  135. {
  136. // JSON config already exists, nothing to do.
  137. if ($this->conf->getConfigIO() instanceof ConfigJson) {
  138. return true;
  139. }
  140. $configPhp = new ConfigPhp();
  141. $configJson = new ConfigJson();
  142. $oldConfig = $configPhp->read($this->conf->getConfigFile() . '.php');
  143. rename($this->conf->getConfigFileExt(), $this->conf->getConfigFile() . '.save.php');
  144. $this->conf->setConfigIO($configJson);
  145. $this->conf->reload();
  146. $legacyMap = array_flip(ConfigPhp::$LEGACY_KEYS_MAPPING);
  147. foreach (ConfigPhp::$ROOT_KEYS as $key) {
  148. $this->conf->set($legacyMap[$key], $oldConfig[$key]);
  149. }
  150. // Set sub config keys (config and plugins)
  151. $subConfig = array('config', 'plugins');
  152. foreach ($subConfig as $sub) {
  153. foreach ($oldConfig[$sub] as $key => $value) {
  154. if (isset($legacyMap[$sub .'.'. $key])) {
  155. $configKey = $legacyMap[$sub .'.'. $key];
  156. } else {
  157. $configKey = $sub .'.'. $key;
  158. }
  159. $this->conf->set($configKey, $value);
  160. }
  161. }
  162. try {
  163. $this->conf->write($this->isLoggedIn);
  164. return true;
  165. } catch (IOException $e) {
  166. error_log($e->getMessage());
  167. return false;
  168. }
  169. }
  170. /**
  171. * Escape settings which have been manually escaped in every request in previous versions:
  172. * - general.title
  173. * - general.header_link
  174. * - redirector.url
  175. *
  176. * @return bool true if the update is successful, false otherwise.
  177. */
  178. public function updateMethodEscapeUnescapedConfig()
  179. {
  180. try {
  181. $this->conf->set('general.title', escape($this->conf->get('general.title')));
  182. $this->conf->set('general.header_link', escape($this->conf->get('general.header_link')));
  183. $this->conf->set('redirector.url', escape($this->conf->get('redirector.url')));
  184. $this->conf->write($this->isLoggedIn);
  185. } catch (Exception $e) {
  186. error_log($e->getMessage());
  187. return false;
  188. }
  189. return true;
  190. }
  191. /**
  192. * Update the database to use the new ID system, which replaces linkdate primary keys.
  193. * Also, creation and update dates are now DateTime objects (done by LinkDB).
  194. *
  195. * Since this update is very sensitve (changing the whole database), the datastore will be
  196. * automatically backed up into the file datastore.<datetime>.php.
  197. *
  198. * LinkDB also adds the field 'shorturl' with the precedent format (linkdate smallhash),
  199. * which will be saved by this method.
  200. *
  201. * @return bool true if the update is successful, false otherwise.
  202. */
  203. public function updateMethodDatastoreIds()
  204. {
  205. // up to date database
  206. if (isset($this->linkDB[0])) {
  207. return true;
  208. }
  209. $save = $this->conf->get('resource.data_dir') .'/datastore.'. date('YmdHis') .'.php';
  210. copy($this->conf->get('resource.datastore'), $save);
  211. $links = array();
  212. foreach ($this->linkDB as $offset => $value) {
  213. $links[] = $value;
  214. unset($this->linkDB[$offset]);
  215. }
  216. $links = array_reverse($links);
  217. $cpt = 0;
  218. foreach ($links as $l) {
  219. unset($l['linkdate']);
  220. $l['id'] = $cpt;
  221. $this->linkDB[$cpt++] = $l;
  222. }
  223. $this->linkDB->save($this->conf->get('resource.page_cache'));
  224. $this->linkDB->reorder();
  225. return true;
  226. }
  227. /**
  228. * Rename tags starting with a '-' to work with tag exclusion search.
  229. */
  230. public function updateMethodRenameDashTags()
  231. {
  232. $linklist = $this->linkDB->filterSearch();
  233. foreach ($linklist as $key => $link) {
  234. $link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']);
  235. $link['tags'] = implode(' ', array_unique(LinkFilter::tagsStrToArray($link['tags'], true)));
  236. $this->linkDB[$key] = $link;
  237. }
  238. $this->linkDB->save($this->conf->get('resource.page_cache'));
  239. return true;
  240. }
  241. /**
  242. * Initialize API settings:
  243. * - api.enabled: true
  244. * - api.secret: generated secret
  245. */
  246. public function updateMethodApiSettings()
  247. {
  248. if ($this->conf->exists('api.secret')) {
  249. return true;
  250. }
  251. $this->conf->set('api.enabled', true);
  252. $this->conf->set(
  253. 'api.secret',
  254. generate_api_secret(
  255. $this->conf->get('credentials.login'),
  256. $this->conf->get('credentials.salt')
  257. )
  258. );
  259. $this->conf->write($this->isLoggedIn);
  260. return true;
  261. }
  262. /**
  263. * New setting: theme name. If the default theme is used, nothing to do.
  264. *
  265. * If the user uses a custom theme, raintpl_tpl dir is updated to the parent directory,
  266. * and the current theme is set as default in the theme setting.
  267. *
  268. * @return bool true if the update is successful, false otherwise.
  269. */
  270. public function updateMethodDefaultTheme()
  271. {
  272. // raintpl_tpl isn't the root template directory anymore.
  273. // We run the update only if this folder still contains the template files.
  274. $tplDir = $this->conf->get('resource.raintpl_tpl');
  275. $tplFile = $tplDir . '/linklist.html';
  276. if (! file_exists($tplFile)) {
  277. return true;
  278. }
  279. $parent = dirname($tplDir);
  280. $this->conf->set('resource.raintpl_tpl', $parent);
  281. $this->conf->set('resource.theme', trim(str_replace($parent, '', $tplDir), '/'));
  282. $this->conf->write($this->isLoggedIn);
  283. // Dependency injection gore
  284. RainTPL::$tpl_dir = $tplDir;
  285. return true;
  286. }
  287. /**
  288. * Move the file to inc/user.css to data/user.css.
  289. *
  290. * Note: Due to hardcoded paths, it's not unit testable. But one line of code should be fine.
  291. *
  292. * @return bool true if the update is successful, false otherwise.
  293. */
  294. public function updateMethodMoveUserCss()
  295. {
  296. if (! is_file('inc/user.css')) {
  297. return true;
  298. }
  299. return rename('inc/user.css', 'data/user.css');
  300. }
  301. /**
  302. * * `markdown_escape` is a new setting, set to true as default.
  303. *
  304. * If the markdown plugin was already enabled, escaping is disabled to avoid
  305. * breaking existing entries.
  306. */
  307. public function updateMethodEscapeMarkdown()
  308. {
  309. if ($this->conf->exists('security.markdown_escape')) {
  310. return true;
  311. }
  312. if (in_array('markdown', $this->conf->get('general.enabled_plugins'))) {
  313. $this->conf->set('security.markdown_escape', false);
  314. } else {
  315. $this->conf->set('security.markdown_escape', true);
  316. }
  317. $this->conf->write($this->isLoggedIn);
  318. return true;
  319. }
  320. /**
  321. * Add 'http://' to Piwik URL the setting is set.
  322. *
  323. * @return bool true if the update is successful, false otherwise.
  324. */
  325. public function updateMethodPiwikUrl()
  326. {
  327. if (! $this->conf->exists('plugins.PIWIK_URL') || startsWith($this->conf->get('plugins.PIWIK_URL'), 'http')) {
  328. return true;
  329. }
  330. $this->conf->set('plugins.PIWIK_URL', 'http://'. $this->conf->get('plugins.PIWIK_URL'));
  331. $this->conf->write($this->isLoggedIn);
  332. return true;
  333. }
  334. /**
  335. * Use ATOM feed as default.
  336. */
  337. public function updateMethodAtomDefault()
  338. {
  339. if (!$this->conf->exists('feed.show_atom') || $this->conf->get('feed.show_atom') === true) {
  340. return true;
  341. }
  342. $this->conf->set('feed.show_atom', true);
  343. $this->conf->write($this->isLoggedIn);
  344. return true;
  345. }
  346. /**
  347. * Update updates.check_updates_branch setting.
  348. *
  349. * If the current major version digit matches the latest branch
  350. * major version digit, we set the branch to `latest`,
  351. * otherwise we'll check updates on the `stable` branch.
  352. *
  353. * No update required for the dev version.
  354. *
  355. * Note: due to hardcoded URL and lack of dependency injection, this is not unit testable.
  356. *
  357. * FIXME! This needs to be removed when we switch to first digit major version
  358. * instead of the second one since the versionning process will change.
  359. */
  360. public function updateMethodCheckUpdateRemoteBranch()
  361. {
  362. if (SHAARLI_VERSION === 'dev' || $this->conf->get('updates.check_updates_branch') === 'latest') {
  363. return true;
  364. }
  365. // Get latest branch major version digit
  366. $latestVersion = ApplicationUtils::getLatestGitVersionCode(
  367. 'https://raw.githubusercontent.com/shaarli/Shaarli/latest/shaarli_version.php',
  368. 5
  369. );
  370. if (preg_match('/(\d+)\.\d+$/', $latestVersion, $matches) === false) {
  371. return false;
  372. }
  373. $latestMajor = $matches[1];
  374. // Get current major version digit
  375. preg_match('/(\d+)\.\d+$/', SHAARLI_VERSION, $matches);
  376. $currentMajor = $matches[1];
  377. if ($currentMajor === $latestMajor) {
  378. $branch = 'latest';
  379. } else {
  380. $branch = 'stable';
  381. }
  382. $this->conf->set('updates.check_updates_branch', $branch);
  383. $this->conf->write($this->isLoggedIn);
  384. return true;
  385. }
  386. /**
  387. * Reset history store file due to date format change.
  388. */
  389. public function updateMethodResetHistoryFile()
  390. {
  391. if (is_file($this->conf->get('resource.history'))) {
  392. unlink($this->conf->get('resource.history'));
  393. }
  394. return true;
  395. }
  396. /**
  397. * Save the datastore -> the link order is now applied when links are saved.
  398. */
  399. public function updateMethodReorderDatastore()
  400. {
  401. $this->linkDB->save($this->conf->get('resource.page_cache'));
  402. return true;
  403. }
  404. /**
  405. * Change privateonly session key to visibility.
  406. */
  407. public function updateMethodVisibilitySession()
  408. {
  409. if (isset($_SESSION['privateonly'])) {
  410. unset($_SESSION['privateonly']);
  411. $_SESSION['visibility'] = 'private';
  412. }
  413. return true;
  414. }
  415. /**
  416. * Add download size and timeout to the configuration file
  417. *
  418. * @return bool true if the update is successful, false otherwise.
  419. */
  420. public function updateMethodDownloadSizeAndTimeoutConf()
  421. {
  422. if ($this->conf->exists('general.download_max_size')
  423. && $this->conf->exists('general.download_timeout')
  424. ) {
  425. return true;
  426. }
  427. if (! $this->conf->exists('general.download_max_size')) {
  428. $this->conf->set('general.download_max_size', 1024*1024*4);
  429. }
  430. if (! $this->conf->exists('general.download_timeout')) {
  431. $this->conf->set('general.download_timeout', 30);
  432. }
  433. $this->conf->write($this->isLoggedIn);
  434. return true;
  435. }
  436. /**
  437. * * Move thumbnails management to WebThumbnailer, coming with new settings.
  438. */
  439. public function updateMethodWebThumbnailer()
  440. {
  441. if ($this->conf->exists('thumbnails.mode')) {
  442. return true;
  443. }
  444. $thumbnailsEnabled = extension_loaded('gd') && $this->conf->get('thumbnail.enable_thumbnails', true);
  445. $this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE);
  446. $this->conf->set('thumbnails.width', 125);
  447. $this->conf->set('thumbnails.height', 90);
  448. $this->conf->remove('thumbnail');
  449. $this->conf->write(true);
  450. if ($thumbnailsEnabled) {
  451. $this->session['warnings'][] = t(
  452. 'You have enabled or changed thumbnails mode. <a href="?do=thumbs_update">Please synchronize them</a>.'
  453. );
  454. }
  455. return true;
  456. }
  457. /**
  458. * Set sticky = false on all links
  459. *
  460. * @return bool true if the update is successful, false otherwise.
  461. */
  462. public function updateMethodSetSticky()
  463. {
  464. foreach ($this->linkDB as $key => $link) {
  465. if (isset($link['sticky'])) {
  466. return true;
  467. }
  468. $link['sticky'] = false;
  469. $this->linkDB[$key] = $link;
  470. }
  471. $this->linkDB->save($this->conf->get('resource.page_cache'));
  472. return true;
  473. }
  474. }
  475. /**
  476. * Class UpdaterException.
  477. */
  478. class UpdaterException extends Exception
  479. {
  480. /**
  481. * @var string Method where the error occurred.
  482. */
  483. protected $method;
  484. /**
  485. * @var Exception The parent exception.
  486. */
  487. protected $previous;
  488. /**
  489. * Constructor.
  490. *
  491. * @param string $message Force the error message if set.
  492. * @param string $method Method where the error occurred.
  493. * @param Exception|bool $previous Parent exception.
  494. */
  495. public function __construct($message = '', $method = '', $previous = false)
  496. {
  497. $this->method = $method;
  498. $this->previous = $previous;
  499. $this->message = $this->buildMessage($message);
  500. }
  501. /**
  502. * Build the exception error message.
  503. *
  504. * @param string $message Optional given error message.
  505. *
  506. * @return string The built error message.
  507. */
  508. private function buildMessage($message)
  509. {
  510. $out = '';
  511. if (! empty($message)) {
  512. $out .= $message . PHP_EOL;
  513. }
  514. if (! empty($this->method)) {
  515. $out .= t('An error occurred while running the update ') . $this->method . PHP_EOL;
  516. }
  517. if (! empty($this->previous)) {
  518. $out .= ' '. $this->previous->getMessage();
  519. }
  520. return $out;
  521. }
  522. }
  523. /**
  524. * Read the updates file, and return already done updates.
  525. *
  526. * @param string $updatesFilepath Updates file path.
  527. *
  528. * @return array Already done update methods.
  529. */
  530. function read_updates_file($updatesFilepath)
  531. {
  532. if (! empty($updatesFilepath) && is_file($updatesFilepath)) {
  533. $content = file_get_contents($updatesFilepath);
  534. if (! empty($content)) {
  535. return explode(';', $content);
  536. }
  537. }
  538. return array();
  539. }
  540. /**
  541. * Write updates file.
  542. *
  543. * @param string $updatesFilepath Updates file path.
  544. * @param array $updates Updates array to write.
  545. *
  546. * @throws Exception Couldn't write version number.
  547. */
  548. function write_updates_file($updatesFilepath, $updates)
  549. {
  550. if (empty($updatesFilepath)) {
  551. throw new Exception(t('Updates file path is not set, can\'t write updates.'));
  552. }
  553. $res = file_put_contents($updatesFilepath, implode(';', $updates));
  554. if ($res === false) {
  555. throw new Exception(t('Unable to write updates in '. $updatesFilepath . '.'));
  556. }
  557. }