Updater.php 18 KB

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