444 lines
13 KiB
PHP
444 lines
13 KiB
PHP
|
<?php
|
||
|
|
||
|
declare(strict_types=1);
|
||
|
|
||
|
namespace Shaarli\Bookmark;
|
||
|
|
||
|
use DateTime;
|
||
|
use Exception;
|
||
|
use malkusch\lock\mutex\Mutex;
|
||
|
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
|
||
|
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
|
||
|
use Shaarli\Bookmark\Exception\EmptyDataStoreException;
|
||
|
use Shaarli\Config\ConfigManager;
|
||
|
use Shaarli\Formatter\BookmarkMarkdownFormatter;
|
||
|
use Shaarli\History;
|
||
|
use Shaarli\Legacy\LegacyLinkDB;
|
||
|
use Shaarli\Legacy\LegacyUpdater;
|
||
|
use Shaarli\Plugin\PluginManager;
|
||
|
use Shaarli\Render\PageCacheManager;
|
||
|
use Shaarli\Updater\UpdaterUtils;
|
||
|
|
||
|
/**
|
||
|
* Class BookmarksService
|
||
|
*
|
||
|
* This is the entry point to manipulate the bookmark DB.
|
||
|
* It manipulates loads links from a file data store containing all bookmarks.
|
||
|
*
|
||
|
* It also triggers the legacy format (bookmarks as arrays) migration.
|
||
|
*/
|
||
|
class BookmarkFileService implements BookmarkServiceInterface
|
||
|
{
|
||
|
/** @var Bookmark[] instance */
|
||
|
protected $bookmarks;
|
||
|
|
||
|
/** @var BookmarkIO instance */
|
||
|
protected $bookmarksIO;
|
||
|
|
||
|
/** @var BookmarkFilter */
|
||
|
protected $bookmarkFilter;
|
||
|
|
||
|
/** @var ConfigManager instance */
|
||
|
protected $conf;
|
||
|
|
||
|
/** @var PluginManager */
|
||
|
protected $pluginManager;
|
||
|
|
||
|
/** @var History instance */
|
||
|
protected $history;
|
||
|
|
||
|
/** @var PageCacheManager instance */
|
||
|
protected $pageCacheManager;
|
||
|
|
||
|
/** @var bool true for logged in users. Default value to retrieve private bookmarks. */
|
||
|
protected $isLoggedIn;
|
||
|
|
||
|
/** @var Mutex */
|
||
|
protected $mutex;
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function __construct(
|
||
|
ConfigManager $conf,
|
||
|
PluginManager $pluginManager,
|
||
|
History $history,
|
||
|
Mutex $mutex,
|
||
|
bool $isLoggedIn
|
||
|
) {
|
||
|
$this->conf = $conf;
|
||
|
$this->history = $history;
|
||
|
$this->mutex = $mutex;
|
||
|
$this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn);
|
||
|
$this->bookmarksIO = new BookmarkIO($this->conf, $this->mutex);
|
||
|
$this->isLoggedIn = $isLoggedIn;
|
||
|
|
||
|
if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) {
|
||
|
$this->bookmarks = new BookmarkArray();
|
||
|
} else {
|
||
|
try {
|
||
|
$this->bookmarks = $this->bookmarksIO->read();
|
||
|
} catch (EmptyDataStoreException | DatastoreNotInitializedException $e) {
|
||
|
$this->bookmarks = new BookmarkArray();
|
||
|
|
||
|
if ($this->isLoggedIn) {
|
||
|
// Datastore file does not exists, we initialize it with default bookmarks.
|
||
|
if ($e instanceof DatastoreNotInitializedException) {
|
||
|
$this->initialize();
|
||
|
} else {
|
||
|
$this->save();
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (! $this->bookmarks instanceof BookmarkArray) {
|
||
|
$this->migrate();
|
||
|
exit(
|
||
|
'Your data store has been migrated, please reload the page.' . PHP_EOL .
|
||
|
'If this message keeps showing up, please delete data/updates.txt file.'
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$this->pluginManager = $pluginManager;
|
||
|
$this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf, $this->pluginManager);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function findByHash(string $hash, string $privateKey = null): Bookmark
|
||
|
{
|
||
|
$bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
|
||
|
// PHP 7.3 introduced array_key_first() to avoid this hack
|
||
|
$first = reset($bookmark);
|
||
|
if (
|
||
|
!$this->isLoggedIn
|
||
|
&& $first->isPrivate()
|
||
|
&& (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
|
||
|
) {
|
||
|
throw new BookmarkNotFoundException();
|
||
|
}
|
||
|
|
||
|
return $first;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function findByUrl(string $url): ?Bookmark
|
||
|
{
|
||
|
return $this->bookmarks->getByUrl($url);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function search(
|
||
|
array $request = [],
|
||
|
string $visibility = null,
|
||
|
bool $caseSensitive = false,
|
||
|
bool $untaggedOnly = false,
|
||
|
bool $ignoreSticky = false,
|
||
|
array $pagination = []
|
||
|
): SearchResult {
|
||
|
if ($visibility === null) {
|
||
|
$visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
|
||
|
}
|
||
|
|
||
|
// Filter bookmark database according to parameters.
|
||
|
$searchTags = isset($request['searchtags']) ? $request['searchtags'] : '';
|
||
|
$searchTerm = isset($request['searchterm']) ? $request['searchterm'] : '';
|
||
|
|
||
|
if ($ignoreSticky) {
|
||
|
$this->bookmarks->reorder('DESC', true);
|
||
|
}
|
||
|
|
||
|
$bookmarks = $this->bookmarkFilter->filter(
|
||
|
BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT,
|
||
|
[$searchTags, $searchTerm],
|
||
|
$caseSensitive,
|
||
|
$visibility,
|
||
|
$untaggedOnly
|
||
|
);
|
||
|
|
||
|
return SearchResult::getSearchResult(
|
||
|
$bookmarks,
|
||
|
$pagination['offset'] ?? 0,
|
||
|
$pagination['limit'] ?? null,
|
||
|
$pagination['allowOutOfBounds'] ?? false
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function get(int $id, string $visibility = null): Bookmark
|
||
|
{
|
||
|
if (! isset($this->bookmarks[$id])) {
|
||
|
throw new BookmarkNotFoundException();
|
||
|
}
|
||
|
|
||
|
if ($visibility === null) {
|
||
|
$visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
|
||
|
}
|
||
|
|
||
|
$bookmark = $this->bookmarks[$id];
|
||
|
if (
|
||
|
($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
|
||
|
|| (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
|
||
|
) {
|
||
|
throw new Exception('Unauthorized');
|
||
|
}
|
||
|
|
||
|
return $bookmark;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function set(Bookmark $bookmark, bool $save = true): Bookmark
|
||
|
{
|
||
|
if (true !== $this->isLoggedIn) {
|
||
|
throw new Exception(t('You\'re not authorized to alter the datastore'));
|
||
|
}
|
||
|
if (! isset($this->bookmarks[$bookmark->getId()])) {
|
||
|
throw new BookmarkNotFoundException();
|
||
|
}
|
||
|
$bookmark->validate();
|
||
|
|
||
|
$bookmark->setUpdated(new DateTime());
|
||
|
$this->bookmarks[$bookmark->getId()] = $bookmark;
|
||
|
if ($save === true) {
|
||
|
$this->save();
|
||
|
$this->history->updateLink($bookmark);
|
||
|
}
|
||
|
return $this->bookmarks[$bookmark->getId()];
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function add(Bookmark $bookmark, bool $save = true): Bookmark
|
||
|
{
|
||
|
if (true !== $this->isLoggedIn) {
|
||
|
throw new Exception(t('You\'re not authorized to alter the datastore'));
|
||
|
}
|
||
|
if (!empty($bookmark->getId())) {
|
||
|
throw new Exception(t('This bookmarks already exists'));
|
||
|
}
|
||
|
$bookmark->setId($this->bookmarks->getNextId());
|
||
|
$bookmark->validate();
|
||
|
|
||
|
$this->bookmarks[$bookmark->getId()] = $bookmark;
|
||
|
if ($save === true) {
|
||
|
$this->save();
|
||
|
$this->history->addLink($bookmark);
|
||
|
}
|
||
|
return $this->bookmarks[$bookmark->getId()];
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark
|
||
|
{
|
||
|
if (true !== $this->isLoggedIn) {
|
||
|
throw new Exception(t('You\'re not authorized to alter the datastore'));
|
||
|
}
|
||
|
if ($bookmark->getId() === null) {
|
||
|
return $this->add($bookmark, $save);
|
||
|
}
|
||
|
return $this->set($bookmark, $save);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function remove(Bookmark $bookmark, bool $save = true): void
|
||
|
{
|
||
|
if (true !== $this->isLoggedIn) {
|
||
|
throw new Exception(t('You\'re not authorized to alter the datastore'));
|
||
|
}
|
||
|
if (! isset($this->bookmarks[$bookmark->getId()])) {
|
||
|
throw new BookmarkNotFoundException();
|
||
|
}
|
||
|
|
||
|
unset($this->bookmarks[$bookmark->getId()]);
|
||
|
if ($save === true) {
|
||
|
$this->save();
|
||
|
$this->history->deleteLink($bookmark);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function exists(int $id, string $visibility = null): bool
|
||
|
{
|
||
|
if (! isset($this->bookmarks[$id])) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
if ($visibility === null) {
|
||
|
$visibility = $this->isLoggedIn ? 'all' : 'public';
|
||
|
}
|
||
|
|
||
|
$bookmark = $this->bookmarks[$id];
|
||
|
if (
|
||
|
($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
|
||
|
|| (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
|
||
|
) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function count(string $visibility = null): int
|
||
|
{
|
||
|
return $this->search([], $visibility)->getResultCount();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function save(): void
|
||
|
{
|
||
|
if (true !== $this->isLoggedIn) {
|
||
|
// TODO: raise an Exception instead
|
||
|
die('You are not authorized to change the database.');
|
||
|
}
|
||
|
|
||
|
$this->bookmarks->reorder();
|
||
|
$this->bookmarksIO->write($this->bookmarks);
|
||
|
$this->pageCacheManager->invalidateCaches();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function bookmarksCountPerTag(array $filteringTags = [], string $visibility = null): array
|
||
|
{
|
||
|
$searchResult = $this->search(['searchtags' => $filteringTags], $visibility);
|
||
|
$tags = [];
|
||
|
$caseMapping = [];
|
||
|
foreach ($searchResult->getBookmarks() as $bookmark) {
|
||
|
foreach ($bookmark->getTags() as $tag) {
|
||
|
if (
|
||
|
empty($tag)
|
||
|
|| (! $this->isLoggedIn && startsWith($tag, '.'))
|
||
|
|| $tag === BookmarkMarkdownFormatter::NO_MD_TAG
|
||
|
|| in_array($tag, $filteringTags, true)
|
||
|
) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// The first case found will be displayed.
|
||
|
if (!isset($caseMapping[strtolower($tag)])) {
|
||
|
$caseMapping[strtolower($tag)] = $tag;
|
||
|
$tags[$caseMapping[strtolower($tag)]] = 0;
|
||
|
}
|
||
|
$tags[$caseMapping[strtolower($tag)]]++;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
* Formerly used arsort(), which doesn't define the sort behaviour for equal values.
|
||
|
* Also, this function doesn't produce the same result between PHP 5.6 and 7.
|
||
|
*
|
||
|
* So we now use array_multisort() to sort tags by DESC occurrences,
|
||
|
* then ASC alphabetically for equal values.
|
||
|
*
|
||
|
* @see https://github.com/shaarli/Shaarli/issues/1142
|
||
|
*/
|
||
|
$keys = array_keys($tags);
|
||
|
$tmpTags = array_combine($keys, $keys);
|
||
|
array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
|
||
|
|
||
|
return $tags;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function findByDate(
|
||
|
\DateTimeInterface $from,
|
||
|
\DateTimeInterface $to,
|
||
|
?\DateTimeInterface &$previous,
|
||
|
?\DateTimeInterface &$next
|
||
|
): array {
|
||
|
$out = [];
|
||
|
$previous = null;
|
||
|
$next = null;
|
||
|
|
||
|
foreach ($this->search([], null, false, false, true)->getBookmarks() as $bookmark) {
|
||
|
if ($to < $bookmark->getCreated()) {
|
||
|
$next = $bookmark->getCreated();
|
||
|
} elseif ($from < $bookmark->getCreated() && $to > $bookmark->getCreated()) {
|
||
|
$out[] = $bookmark;
|
||
|
} else {
|
||
|
if ($previous !== null) {
|
||
|
break;
|
||
|
}
|
||
|
$previous = $bookmark->getCreated();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return $out;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function getLatest(): ?Bookmark
|
||
|
{
|
||
|
foreach ($this->search([], null, false, false, true)->getBookmarks() as $bookmark) {
|
||
|
return $bookmark;
|
||
|
}
|
||
|
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritDoc
|
||
|
*/
|
||
|
public function initialize(): void
|
||
|
{
|
||
|
$initializer = new BookmarkInitializer($this);
|
||
|
$initializer->initialize();
|
||
|
|
||
|
if (true === $this->isLoggedIn) {
|
||
|
$this->save();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Handles migration to the new database format (BookmarksArray).
|
||
|
*/
|
||
|
protected function migrate(): void
|
||
|
{
|
||
|
$bookmarkDb = new LegacyLinkDB(
|
||
|
$this->conf->get('resource.datastore'),
|
||
|
true,
|
||
|
false
|
||
|
);
|
||
|
$updater = new LegacyUpdater(
|
||
|
UpdaterUtils::readUpdatesFile($this->conf->get('resource.updates')),
|
||
|
$bookmarkDb,
|
||
|
$this->conf,
|
||
|
true
|
||
|
);
|
||
|
$newUpdates = $updater->update();
|
||
|
if (! empty($newUpdates)) {
|
||
|
UpdaterUtils::writeUpdatesFile(
|
||
|
$this->conf->get('resource.updates'),
|
||
|
$updater->getDoneUpdates()
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
}
|