542 changes: 542 additions & 0 deletions application/bookmark/Bookmark.php

Large diffs are not rendered by default.

264 changes: 264 additions & 0 deletions application/bookmark/BookmarkArray.php
@@ -0,0 +1,264 @@
<?php

declare(strict_types=1);

namespace Shaarli\Bookmark;

use Shaarli\Bookmark\Exception\InvalidBookmarkException;

/**
* Class BookmarkArray
*
* Implementing ArrayAccess, this allows us to use the bookmark list
* as an array and iterate over it.
*
* @package Shaarli\Bookmark
*/
class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
{
/**
* @var Bookmark[]
*/
protected $bookmarks;

/**
* @var array List of all bookmarks IDS mapped with their array offset.
* Map: id->offset.
*/
protected $ids;

/**
* @var int Position in the $this->keys array (for the Iterator interface)
*/
protected $position;

/**
* @var array List of offset keys (for the Iterator interface implementation)
*/
protected $keys;

/**
* @var array List of all recorded URLs (key=url, value=bookmark offset)
* for fast reserve search (url-->bookmark offset)
*/
protected $urls;

public function __construct()
{
$this->ids = [];
$this->bookmarks = [];
$this->keys = [];
$this->urls = [];
$this->position = 0;
}

/**
* Countable - Counts elements of an object
*
* @return int Number of bookmarks
*/
public function count(): int
{
return count($this->bookmarks);
}

/**
* ArrayAccess - Assigns a value to the specified offset
*
* @param int $offset Bookmark ID
* @param Bookmark $value instance
*
* @throws InvalidBookmarkException
*/
public function offsetSet($offset, $value): void
{
if (
! $value instanceof Bookmark
|| $value->getId() === null || empty($value->getUrl())
|| ($offset !== null && ! is_int($offset)) || ! is_int($value->getId())
|| $offset !== null && $offset !== $value->getId()
) {
throw new InvalidBookmarkException($value);
}

// If the bookmark exists, we reuse the real offset, otherwise new entry
if ($offset !== null) {
$existing = $this->getBookmarkOffset($offset);
} else {
$existing = $this->getBookmarkOffset($value->getId());
}

if ($existing !== null) {
$offset = $existing;
} else {
$offset = count($this->bookmarks);
}

$this->bookmarks[$offset] = $value;
$this->urls[$value->getUrl()] = $offset;
$this->ids[$value->getId()] = $offset;
}

/**
* ArrayAccess - Whether or not an offset exists
*
* @param int $offset Bookmark ID
*
* @return bool true if it exists, false otherwise
*/
public function offsetExists($offset): bool
{
return array_key_exists($this->getBookmarkOffset($offset), $this->bookmarks);
}

/**
* ArrayAccess - Unsets an offset
*
* @param int $offset Bookmark ID
*/
public function offsetUnset($offset): void
{
$realOffset = $this->getBookmarkOffset($offset);
$url = $this->bookmarks[$realOffset]->getUrl();
unset($this->urls[$url]);
unset($this->ids[$offset]);
unset($this->bookmarks[$realOffset]);
}

/**
* ArrayAccess - Returns the value at specified offset
*
* @param int $offset Bookmark ID
*
* @return Bookmark|null The Bookmark if found, null otherwise
*/
public function offsetGet($offset): ?Bookmark
{
$realOffset = $this->getBookmarkOffset($offset);
return isset($this->bookmarks[$realOffset]) ? $this->bookmarks[$realOffset] : null;
}

/**
* Iterator - Returns the current element
*
* @return Bookmark corresponding to the current position
*/
public function current(): Bookmark
{
return $this[$this->keys[$this->position]];
}

/**
* Iterator - Returns the key of the current element
*
* @return int Bookmark ID corresponding to the current position
*/
public function key(): int
{
return $this->keys[$this->position];
}

/**
* Iterator - Moves forward to next element
*/
public function next(): void
{
++$this->position;
}

/**
* Iterator - Rewinds the Iterator to the first element
*
* Entries are sorted by date (latest first)
*/
public function rewind(): void
{
$this->keys = array_keys($this->ids);
$this->position = 0;
}

/**
* Iterator - Checks if current position is valid
*
* @return bool true if the current Bookmark ID exists, false otherwise
*/
public function valid(): bool
{
return isset($this->keys[$this->position]);
}

/**
* Returns a bookmark offset in bookmarks array from its unique ID.
*
* @param int|null $id Persistent ID of a bookmark.
*
* @return int Real offset in local array, or null if doesn't exist.
*/
protected function getBookmarkOffset(?int $id): ?int
{
if ($id !== null && isset($this->ids[$id])) {
return $this->ids[$id];
}
return null;
}

/**
* Return the next key for bookmark creation.
* E.g. If the last ID is 597, the next will be 598.
*
* @return int next ID.
*/
public function getNextId(): int
{
if (!empty($this->ids)) {
return max(array_keys($this->ids)) + 1;
}
return 0;
}

/**
* @param string $url
*
* @return Bookmark|null
*/
public function getByUrl(string $url): ?Bookmark
{
if (
! empty($url)
&& isset($this->urls[$url])
&& isset($this->bookmarks[$this->urls[$url]])
) {
return $this->bookmarks[$this->urls[$url]];
}
return null;
}

/**
* Reorder links by creation date (newest first).
*
* Also update the urls and ids mapping arrays.
*
* @param string $order ASC|DESC
* @param bool $ignoreSticky If set to true, sticky bookmarks won't be first
*/
public function reorder(string $order = 'DESC', bool $ignoreSticky = false): void
{
$order = $order === 'ASC' ? -1 : 1;
// Reorder array by dates.
usort($this->bookmarks, function ($a, $b) use ($order, $ignoreSticky) {
/** @var $a Bookmark */
/** @var $b Bookmark */
if (false === $ignoreSticky && $a->isSticky() !== $b->isSticky()) {
return $a->isSticky() ? -1 : 1;
}
return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order;
});

$this->urls = [];
$this->ids = [];
foreach ($this->bookmarks as $key => $bookmark) {
$this->urls[$bookmark->getUrl()] = $key;
$this->ids[$bookmark->getId()] = $key;
}
}
}
443 changes: 443 additions & 0 deletions application/bookmark/BookmarkFileService.php

Large diffs are not rendered by default.

635 changes: 635 additions & 0 deletions application/bookmark/BookmarkFilter.php

Large diffs are not rendered by default.

177 changes: 177 additions & 0 deletions application/bookmark/BookmarkIO.php
@@ -0,0 +1,177 @@
<?php

declare(strict_types=1);

namespace Shaarli\Bookmark;

use malkusch\lock\exception\LockAcquireException;
use malkusch\lock\mutex\Mutex;
use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
use Shaarli\Bookmark\Exception\EmptyDataStoreException;
use Shaarli\Bookmark\Exception\InvalidWritableDataException;
use Shaarli\Bookmark\Exception\NotEnoughSpaceException;
use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
use Shaarli\Config\ConfigManager;

/**
* Class BookmarkIO
*
* This class performs read/write operation to the file data store.
* Used by BookmarkFileService.
*
* @package Shaarli\Bookmark
*/
class BookmarkIO
{
/**
* @var string Datastore file path
*/
protected $datastore;

/**
* @var ConfigManager instance
*/
protected $conf;


/** @var Mutex */
protected $mutex;

/**
* string Datastore PHP prefix
*/
protected static $phpPrefix = '<?php /* ';
/**
* string Datastore PHP suffix
*/
protected static $phpSuffix = ' */ ?>';

/**
* LinksIO constructor.
*
* @param ConfigManager $conf instance
*/
public function __construct(ConfigManager $conf, Mutex $mutex = null)
{
if ($mutex === null) {
// This should only happen with legacy classes
$mutex = new NoMutex();
}
$this->conf = $conf;
$this->datastore = $conf->get('resource.datastore');
$this->mutex = $mutex;
}

/**
* Reads database from disk to memory
*
* @return Bookmark[]
*
* @throws NotWritableDataStoreException Data couldn't be loaded
* @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark
* @throws DatastoreNotInitializedException File does not exists
*/
public function read()
{
if (! file_exists($this->datastore)) {
throw new DatastoreNotInitializedException();
}

if (!is_writable($this->datastore)) {
throw new NotWritableDataStoreException($this->datastore);
}

$content = null;
$this->synchronized(function () use (&$content) {
$content = file_get_contents($this->datastore);
});

// Note that gzinflate is faster than gzuncompress.
// See: http://www.php.net/manual/en/function.gzdeflate.php#96439
$links = unserialize(gzinflate(base64_decode(
substr($content, strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
)));

if (empty($links)) {
if (filesize($this->datastore) > 100) {
throw new NotWritableDataStoreException($this->datastore);
}
throw new EmptyDataStoreException();
}

return $links;
}

/**
* Saves the database from memory to disk
*
* @param Bookmark[] $links
*
* @throws NotWritableDataStoreException the datastore is not writable
* @throws InvalidWritableDataException
*/
public function write($links)
{
if (is_file($this->datastore) && !is_writeable($this->datastore)) {
// The datastore exists but is not writeable
throw new NotWritableDataStoreException($this->datastore);
} elseif (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
// The datastore does not exist and its parent directory is not writeable
throw new NotWritableDataStoreException(dirname($this->datastore));
}

$data = base64_encode(gzdeflate(serialize($links)));

if (empty($data)) {
throw new InvalidWritableDataException();
}

$data = self::$phpPrefix . $data . self::$phpSuffix;

$this->synchronized(function () use ($data) {
if (!$this->checkDiskSpace($data)) {
throw new NotEnoughSpaceException();
}

file_put_contents(
$this->datastore,
$data
);
});
}

/**
* Wrapper applying mutex to provided function.
* If the lock can't be acquired (e.g. some shared hosting provider), we execute the function without mutex.
*
* @see https://github.com/shaarli/Shaarli/issues/1650
*
* @param callable $function
*/
protected function synchronized(callable $function): void
{
try {
$this->mutex->synchronized($function);
} catch (LockAcquireException $exception) {
$function();
}
}

/**
* Make sure that there is enough disk space available to save the current data store.
* We add an arbitrary margin of 500kB.
*
* @param string $data to be saved
*
* @return bool True if data can safely be saved
*/
public function checkDiskSpace(string $data): bool
{
if (function_exists('disk_free_space') === false) {
return true;
}

return disk_free_space(dirname($this->datastore)) > (strlen($data) + 1024 * 500);
}
}
115 changes: 115 additions & 0 deletions application/bookmark/BookmarkInitializer.php
@@ -0,0 +1,115 @@
<?php

declare(strict_types=1);

namespace Shaarli\Bookmark;

/**
* Class BookmarkInitializer
*
* This class is used to initialized default bookmarks after a fresh install of Shaarli.
* It should be only called if the datastore file does not exist(users might want to delete the default bookmarks).
*
* To prevent data corruption, it does not overwrite existing bookmarks,
* even though there should not be any.
*
* We disable this because otherwise it creates indentation issues, and heredoc is not supported by PHP gettext.
* @phpcs:disable Generic.Files.LineLength.TooLong
*
* @package Shaarli\Bookmark
*/
class BookmarkInitializer
{
/** @var BookmarkServiceInterface */
protected $bookmarkService;

/**
* BookmarkInitializer constructor.
*
* @param BookmarkServiceInterface $bookmarkService
*/
public function __construct(BookmarkServiceInterface $bookmarkService)
{
$this->bookmarkService = $bookmarkService;
}

/**
* Initialize the data store with default bookmarks
*/
public function initialize(): void
{
$bookmark = new Bookmark();
$bookmark->setTitle('Calm Jazz Music - YouTube ' . t('(private bookmark with thumbnail demo)'));
$bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c');
$bookmark->setDescription(t(
'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
Explore your new Shaarli instance by trying out controls and menus.
Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
Now you can edit or delete the default shaares.
'
));
$bookmark->setTagsString('shaarli help thumbnail');
$bookmark->setPrivate(true);
$this->bookmarkService->add($bookmark, false);

$bookmark = new Bookmark();
$bookmark->setTitle(t('Note: Shaare descriptions'));
$bookmark->setDescription(t(
'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
This note is private, so you are the only one able to see it while logged in.
You can use this to keep notes, post articles, code snippets, and much more.
The Markdown formatting setting allows you to format your notes and bookmark description:
### Title headings
#### Multiple headings levels
* bullet lists
* _italic_ text
* **bold** text
* ~~strike through~~ text
* `code` blocks
* images
* [links](https://en.wikipedia.org/wiki/Markdown)
Markdown also supports tables:
| Name | Type | Color | Qty |
| ------- | --------- | ------ | ----- |
| Orange | Fruit | Orange | 126 |
| Apple | Fruit | Any | 62 |
| Lemon | Fruit | Yellow | 30 |
| Carrot | Vegetable | Red | 14 |
'
));
$bookmark->setTagsString('shaarli help');
$bookmark->setPrivate(true);
$this->bookmarkService->add($bookmark, false);

$bookmark = new Bookmark();
$bookmark->setTitle(
'Shaarli - ' . t('The personal, minimalist, super fast, database-free, bookmarking service')
);
$bookmark->setDescription(t(
'Welcome to Shaarli!
Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
You can add a description to your bookmarks, such as this one, and tag them.
Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.).
You can easily retrieve your links, even with thousands of them, using the internal search engine, or search through tags (e.g. this Shaare is tagged with `shaarli` and `help`).
Hashtags such as #shaarli #help are also supported.
You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search.
We hope that you will enjoy using Shaarli, maintained with ❤️ by the community!
Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if you have a suggestion or encounter an issue.
'
));
$bookmark->setTagsString('shaarli help');
$this->bookmarkService->add($bookmark, false);
}
}
189 changes: 189 additions & 0 deletions application/bookmark/BookmarkServiceInterface.php
@@ -0,0 +1,189 @@
<?php

declare(strict_types=1);

namespace Shaarli\Bookmark;

use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Bookmark\Exception\NotWritableDataStoreException;

/**
* Class BookmarksService
*
* This is the entry point to manipulate the bookmark DB.
*
* Regarding return types of a list of bookmarks, it can either be an array or an ArrayAccess implementation,
* so until PHP 8.0 is the minimal supported version with union return types it cannot be explicitly added.
*/
interface BookmarkServiceInterface
{
/**
* Find a bookmark by hash
*
* @param string $hash Bookmark's hash
* @param string|null $privateKey Optional key used to access private links while logged out
*
* @return Bookmark
*
* @throws \Exception
*/
public function findByHash(string $hash, string $privateKey = null);

/**
* @param $url
*
* @return Bookmark|null
*/
public function findByUrl(string $url): ?Bookmark;

/**
* Search bookmarks
*
* @param array $request
* @param ?string $visibility
* @param bool $caseSensitive
* @param bool $untaggedOnly
* @param bool $ignoreSticky
* @param array $pagination This array can contain the following keys for pagination: limit, offset.
*
* @return SearchResult
*/
public function search(
array $request = [],
string $visibility = null,
bool $caseSensitive = false,
bool $untaggedOnly = false,
bool $ignoreSticky = false,
array $pagination = []
): SearchResult;

/**
* Get a single bookmark by its ID.
*
* @param int $id Bookmark ID
* @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
* exception
*
* @return Bookmark
*
* @throws BookmarkNotFoundException
* @throws \Exception
*/
public function get(int $id, string $visibility = null);

/**
* Updates an existing bookmark (depending on its ID).
*
* @param Bookmark $bookmark
* @param bool $save Writes to the datastore if set to true
*
* @return Bookmark Updated bookmark
*
* @throws BookmarkNotFoundException
* @throws \Exception
*/
public function set(Bookmark $bookmark, bool $save = true): Bookmark;

/**
* Adds a new bookmark (the ID must be empty).
*
* @param Bookmark $bookmark
* @param bool $save Writes to the datastore if set to true
*
* @return Bookmark new bookmark
*
* @throws \Exception
*/
public function add(Bookmark $bookmark, bool $save = true): Bookmark;

/**
* Adds or updates a bookmark depending on its ID:
* - a Bookmark without ID will be added
* - a Bookmark with an existing ID will be updated
*
* @param Bookmark $bookmark
* @param bool $save
*
* @return Bookmark
*
* @throws \Exception
*/
public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark;

/**
* Deletes a bookmark.
*
* @param Bookmark $bookmark
* @param bool $save
*
* @throws \Exception
*/
public function remove(Bookmark $bookmark, bool $save = true): void;

/**
* Get a single bookmark by its ID.
*
* @param int $id Bookmark ID
* @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
* exception
*
* @return bool
*/
public function exists(int $id, string $visibility = null): bool;

/**
* Return the number of available bookmarks for given visibility.
*
* @param ?string $visibility public|private|all
*
* @return int Number of bookmarks
*/
public function count(string $visibility = null): int;

/**
* Write the datastore.
*
* @throws NotWritableDataStoreException
*/
public function save(): void;

/**
* Returns the list tags appearing in the bookmarks with the given tags
*
* @param array|null $filteringTags tags selecting the bookmarks to consider
* @param string|null $visibility process only all/private/public bookmarks
*
* @return array tag => bookmarksCount
*/
public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;

/**
* Return a list of bookmark matching provided period of time.
* It also update directly previous and next date outside of given period found in the datastore.
*
* @param \DateTimeInterface $from Starting date.
* @param \DateTimeInterface $to Ending date.
* @param \DateTimeInterface|null $previous (by reference) updated with first created date found before $from.
* @param \DateTimeInterface|null $next (by reference) updated with first created date found after $to.
*
* @return array List of bookmarks matching provided period of time.
*/
public function findByDate(
\DateTimeInterface $from,
\DateTimeInterface $to,
?\DateTimeInterface &$previous,
?\DateTimeInterface &$next
): array;

/**
* Returns the latest bookmark by creation date.
*
* @return Bookmark|null Found Bookmark or null if the datastore is empty.
*/
public function getLatest(): ?Bookmark;

/**
* Creates the default database after a fresh install.
*/
public function initialize(): void;
}
253 changes: 253 additions & 0 deletions application/bookmark/LinkUtils.php
@@ -0,0 +1,253 @@
<?php

use Shaarli\Bookmark\Bookmark;
use Shaarli\Formatter\BookmarkDefaultFormatter;

/**
* Extract title from an HTML document.
*
* @param string $html HTML content where to look for a title.
*
* @return bool|string Extracted title if found, false otherwise.
*/
function html_extract_title($html)
{
if (preg_match('!<title.*?>(.*?)</title>!is', $html, $matches)) {
return trim(str_replace("\n", '', $matches[1]));
}
return false;
}

/**
* Extract charset from HTTP header if it's defined.
*
* @param string $header HTTP header Content-Type line.
*
* @return bool|string Charset string if found (lowercase), false otherwise.
*/
function header_extract_charset($header)
{
preg_match('/charset=["\']?([^; "\']+)/i', $header, $match);
if (! empty($match[1])) {
return strtolower(trim($match[1]));
}

return false;
}

/**
* Extract charset HTML content (tag <meta charset>).
*
* @param string $html HTML content where to look for charset.
*
* @return bool|string Charset string if found, false otherwise.
*/
function html_extract_charset($html)
{
// Get encoding specified in HTML header.
preg_match('#<meta .*charset=["\']?([^";\'>/]+)["\']? */?>#Usi', $html, $enc);
if (!empty($enc[1])) {
return strtolower($enc[1]);
}

return false;
}

/**
* Extract meta tag from HTML content in either:
* - OpenGraph: <meta property="og:[tag]" ...>
* - Meta tag: <meta name="[tag]" ...>
*
* @param string $tag Name of the tag to retrieve.
* @param string $html HTML content where to look for charset.
*
* @return bool|string Charset string if found, false otherwise.
*/
function html_extract_tag($tag, $html)
{
$propertiesKey = ['property', 'name', 'itemprop'];
$properties = implode('|', $propertiesKey);
// We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"'
$orCondition = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
// Support quotes in double quoted content, and the other way around
$content = 'content=(["\'])((?:(?!\1).)*)\1';
// Try to retrieve OpenGraph tag.
$ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*' . $content . '.*?>#';
// If the attributes are not in the order property => content (e.g. Github)
// New regex to keep this readable... more or less.
$ogRegexReverse = '#<meta[^>]+' . $content . '[^>]+(?:' . $properties . ')=(?:' . $orCondition . ').*?>#';

if (
preg_match($ogRegex, $html, $matches) > 0
|| preg_match($ogRegexReverse, $html, $matches) > 0
) {
return $matches[2];
}

return false;
}

/**
* In a string, converts URLs to clickable bookmarks.
*
* @param string $text input string.
*
* @return string returns $text with all bookmarks converted to HTML bookmarks.
*
* @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
*/
function text2clickable($text)
{
$regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[a-z0-9\(\)]/?)!si';
$format = function (array $match): string {
return '<a href="' .
str_replace(
BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN,
'',
str_replace(BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE, '', $match[1])
) .
'">' . $match[1] . '</a>'
;
};

return preg_replace_callback($regex, $format, $text);
}

/**
* Auto-link hashtags.
*
* @param string $description Given description.
* @param string $indexUrl Root URL.
*
* @return string Description with auto-linked hashtags.
*/
function hashtag_autolink($description, $indexUrl = '')
{
$tokens = '(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN . ')' .
'(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE . ')'
;
/*
* To support unicode: http://stackoverflow.com/a/35498078/1484919
* \p{Pc} - to match underscore
* \p{N} - numeric character in any script
* \p{L} - letter from any language
* \p{Mn} - any non marking space (accents, umlauts, etc)
*/
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}' . $tokens . ']+)/mui';
$format = function (array $match) use ($indexUrl): string {
$cleanMatch = str_replace(
BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN,
'',
str_replace(BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE, '', $match[2])
);
return $match[1] . '<a href="' . $indexUrl . './add-tag/' . $cleanMatch . '"' .
' title="Hashtag ' . $cleanMatch . '">' .
'#' . $match[2] .
'</a>';
};

return preg_replace_callback($regex, $format, $description);
}

/**
* This function inserts &nbsp; where relevant so that multiple spaces are properly displayed in HTML
* even in the absence of <pre> (This is used in description to keep text formatting).
*
* @param string $text input text.
*
* @return string formatted text.
*/
function space2nbsp($text)
{
return preg_replace('/(^| ) /m', '$1&nbsp;', $text);
}

/**
* Format Shaarli's description
*
* @param string $description shaare's description.
* @param string $indexUrl URL to Shaarli's index.
* @param bool $autolink Turn on/off automatic linkifications of URLs and hashtags
*
* @return string formatted description.
*/
function format_description($description, $indexUrl = '', $autolink = true)
{
if ($autolink) {
$description = hashtag_autolink(text2clickable($description), $indexUrl);
}

return nl2br(space2nbsp($description));
}

/**
* Generate a small hash for a link.
*
* @param DateTime $date Link creation date.
* @param int $id Link ID.
*
* @return string the small hash generated from link data.
*/
function link_small_hash($date, $id)
{
return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id);
}

/**
* Returns whether or not the link is an internal note.
* Its URL starts by `?` because it's actually a permalink.
*
* @param string $linkUrl
*
* @return bool true if internal note, false otherwise.
*/
function is_note($linkUrl)
{
return isset($linkUrl[0]) && $linkUrl[0] === '?';
}

/**
* Extract an array of tags from a given tag string, with provided separator.
*
* @param string|null $tags String containing a list of tags separated by $separator.
* @param string $separator Shaarli's default: ' ' (whitespace)
*
* @return array List of tags
*/
function tags_str2array(?string $tags, string $separator): array
{
// For whitespaces, we use the special \s regex character
$separator = str_replace([' ', '/'], ['\s', '\/'], $separator);

return preg_split('/\s*' . $separator . '+\s*/', trim($tags ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
}

/**
* Return a tag string with provided separator from a list of tags.
* Note that given array is clean up by tags_filter().
*
* @param array|null $tags List of tags
* @param string $separator
*
* @return string
*/
function tags_array2str(?array $tags, string $separator): string
{
return implode($separator, tags_filter($tags, $separator));
}

/**
* Clean an array of tags: trim + remove empty entries
*
* @param array|null $tags List of tags
* @param string $separator
*
* @return array
*/
function tags_filter(?array $tags, string $separator): array
{
$trimDefault = " \t\n\r\0\x0B";
return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string {
return trim($entry, $trimDefault . $separator);
}, $tags ?? [])));
}
136 changes: 136 additions & 0 deletions application/bookmark/SearchResult.php
@@ -0,0 +1,136 @@
<?php

declare(strict_types=1);

namespace Shaarli\Bookmark;

/**
* Read-only class used to represent search result, including pagination.
*/
class SearchResult
{
/** @var Bookmark[] List of result bookmarks with pagination applied */
protected $bookmarks;

/** @var int number of Bookmarks found, with pagination applied */
protected $resultCount;

/** @var int total number of result found */
protected $totalCount;

/** @var int pagination: limit number of result bookmarks */
protected $limit;

/** @var int pagination: offset to apply to complete result list */
protected $offset;

public function __construct(array $bookmarks, int $totalCount, int $offset, ?int $limit)
{
$this->bookmarks = $bookmarks;
$this->resultCount = count($bookmarks);
$this->totalCount = $totalCount;
$this->limit = $limit;
$this->offset = $offset;
}

/**
* Build a SearchResult from provided full result set and pagination settings.
*
* @param Bookmark[] $bookmarks Full set of result which will be filtered
* @param int $offset Start recording results from $offset
* @param int|null $limit End recording results after $limit bookmarks is reached
* @param bool $allowOutOfBounds Set to false to display the last page if the offset is out of bound,
* return empty result set otherwise (default: false)
*
* @return SearchResult
*/
public static function getSearchResult(
$bookmarks,
int $offset = 0,
?int $limit = null,
bool $allowOutOfBounds = false
): self {
$totalCount = count($bookmarks);
if (!$allowOutOfBounds && $offset > $totalCount) {
$offset = $limit === null ? 0 : $limit * -1;
}

if ($bookmarks instanceof BookmarkArray) {
$buffer = [];
foreach ($bookmarks as $key => $value) {
$buffer[$key] = $value;
}
$bookmarks = $buffer;
}

return new static(
array_slice($bookmarks, $offset, $limit, true),
$totalCount,
$offset,
$limit
);
}

/** @return Bookmark[] List of result bookmarks with pagination applied */
public function getBookmarks(): array
{
return $this->bookmarks;
}

/** @return int number of Bookmarks found, with pagination applied */
public function getResultCount(): int
{
return $this->resultCount;
}

/** @return int total number of result found */
public function getTotalCount(): int
{
return $this->totalCount;
}

/** @return int pagination: limit number of result bookmarks */
public function getLimit(): ?int
{
return $this->limit;
}

/** @return int pagination: offset to apply to complete result list */
public function getOffset(): int
{
return $this->offset;
}

/** @return int Current page of result set in complete results */
public function getPage(): int
{
if (empty($this->limit)) {
return $this->offset === 0 ? 1 : 2;
}
$base = $this->offset >= 0 ? $this->offset : $this->totalCount + $this->offset;

return (int) ceil($base / $this->limit) + 1;
}

/** @return int Get the # of the last page */
public function getLastPage(): int
{
if (empty($this->limit)) {
return $this->offset === 0 ? 1 : 2;
}

return (int) ceil($this->totalCount / $this->limit);
}

/** @return bool Either the current page is the last one or not */
public function isLastPage(): bool
{
return $this->getPage() === $this->getLastPage();
}

/** @return bool Either the current page is the first one or not */
public function isFirstPage(): bool
{
return $this->offset === 0;
}
}
16 changes: 16 additions & 0 deletions application/bookmark/exception/BookmarkNotFoundException.php
@@ -0,0 +1,16 @@
<?php

namespace Shaarli\Bookmark\Exception;

use Exception;

class BookmarkNotFoundException extends Exception
{
/**
* LinkNotFoundException constructor.
*/
public function __construct()
{
$this->message = t('The link you are trying to reach does not exist or has been deleted.');
}
}
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Shaarli\Bookmark\Exception;

class DatastoreNotInitializedException extends \Exception
{
}
7 changes: 7 additions & 0 deletions application/bookmark/exception/EmptyDataStoreException.php
@@ -0,0 +1,7 @@
<?php

namespace Shaarli\Bookmark\Exception;

class EmptyDataStoreException extends \Exception
{
}
30 changes: 30 additions & 0 deletions application/bookmark/exception/InvalidBookmarkException.php
@@ -0,0 +1,30 @@
<?php

namespace Shaarli\Bookmark\Exception;

use Shaarli\Bookmark\Bookmark;

class InvalidBookmarkException extends \Exception
{
public function __construct($bookmark)
{
if ($bookmark instanceof Bookmark) {
if ($bookmark->getCreated() instanceof \DateTime) {
$created = $bookmark->getCreated()->format(\DateTime::ATOM);
} elseif (empty($bookmark->getCreated())) {
$created = '';
} else {
$created = 'Not a DateTime object';
}
$this->message = 'This bookmark is not valid' . PHP_EOL;
$this->message .= ' - ID: ' . $bookmark->getId() . PHP_EOL;
$this->message .= ' - Title: ' . $bookmark->getTitle() . PHP_EOL;
$this->message .= ' - Url: ' . $bookmark->getUrl() . PHP_EOL;
$this->message .= ' - ShortUrl: ' . $bookmark->getShortUrl() . PHP_EOL;
$this->message .= ' - Created: ' . $created . PHP_EOL;
} else {
$this->message = 'The provided data is not a bookmark' . PHP_EOL;
$this->message .= var_export($bookmark, true);
}
}
}
14 changes: 14 additions & 0 deletions application/bookmark/exception/InvalidWritableDataException.php
@@ -0,0 +1,14 @@
<?php

namespace Shaarli\Bookmark\Exception;

class InvalidWritableDataException extends \Exception
{
/**
* InvalidWritableDataException constructor.
*/
public function __construct()
{
$this->message = 'Couldn\'t generate bookmark data to store in the datastore. Skipping file writing.';
}
}
14 changes: 14 additions & 0 deletions application/bookmark/exception/NotEnoughSpaceException.php
@@ -0,0 +1,14 @@
<?php

namespace Shaarli\Bookmark\Exception;

class NotEnoughSpaceException extends \Exception
{
/**
* NotEnoughSpaceException constructor.
*/
public function __construct()
{
$this->message = 'Not enough available disk space to save the datastore.';
}
}
17 changes: 17 additions & 0 deletions application/bookmark/exception/NotWritableDataStoreException.php
@@ -0,0 +1,17 @@
<?php

namespace Shaarli\Bookmark\Exception;

class NotWritableDataStoreException extends \Exception
{
/**
* NotReadableDataStore constructor.
*
* @param string $dataStore file path
*/
public function __construct($dataStore)
{
$this->message = 'Couldn\'t load data from the data store file "' . $dataStore . '". ' .
'Your data might be corrupted, or your file isn\'t readable.';
}
}
35 changes: 35 additions & 0 deletions application/config/ConfigIO.php
@@ -0,0 +1,35 @@
<?php

namespace Shaarli\Config;

/**
* Interface ConfigIO
*
* This describes how Config types should store their configuration.
*/
interface ConfigIO
{
/**
* Read configuration.
*
* @param string $filepath Config file absolute path.
*
* @return array All configuration in an array.
*/
public function read($filepath);

/**
* Write configuration.
*
* @param string $filepath Config file absolute path.
* @param array $conf All configuration in an array.
*/
public function write($filepath, $conf);

/**
* Get config file extension according to config type.
*
* @return string Config file extension.
*/
public function getExtension();
}
90 changes: 90 additions & 0 deletions application/config/ConfigJson.php
@@ -0,0 +1,90 @@
<?php
namespace Shaarli\Config;

/**
* Class ConfigJson (ConfigIO implementation)
*
* Handle Shaarli's JSON configuration file.
*/
class ConfigJson implements ConfigIO
{
/**
* @inheritdoc
*/
public function read($filepath)
{
if (! is_readable($filepath)) {
return array();
}
$data = file_get_contents($filepath);
$data = str_replace(self::getPhpHeaders(), '', $data);
$data = str_replace(self::getPhpSuffix(), '', $data);
$data = json_decode(trim($data), true);
if ($data === null) {
$errorCode = json_last_error();
$error = sprintf(
'An error occurred while parsing JSON configuration file (%s): error code #%d',
$filepath,
$errorCode
);
$error .= '<br>âžś <code>' . json_last_error_msg() .'</code>';
if ($errorCode === JSON_ERROR_SYNTAX) {
$error .= '<br>';
$error .= 'Please check your JSON syntax (without PHP comment tags) using a JSON lint tool such as ';
$error .= '<a href="http://jsonlint.com/">jsonlint.com</a>.';
}
throw new \Exception($error);
}
return $data;
}

/**
* @inheritdoc
*/
public function write($filepath, $conf)
{
// JSON_PRETTY_PRINT is available from PHP 5.4.
$print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;
$data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix();
if (empty($filepath) || !file_put_contents($filepath, $data)) {
throw new \Shaarli\Exceptions\IOException(
$filepath,
t('Shaarli could not create the config file. '.
'Please make sure Shaarli has the right to write in the folder is it installed in.')
);
}
}

/**
* @inheritdoc
*/
public function getExtension()
{
return '.json.php';
}

/**
* The JSON data is wrapped in a PHP file for security purpose.
* This way, even if the file is accessible, credentials and configuration won't be exposed.
*
* Note: this isn't a static field because concatenation isn't supported in field declaration before PHP 5.6.
*
* @return string PHP start tag and comment tag.
*/
public static function getPhpHeaders()
{
return '<?php /*';
}

/**
* Get PHP comment closing tags.
*
* Static method for consistency with getPhpHeaders.
*
* @return string PHP comment closing.
*/
public static function getPhpSuffix()
{
return '*/ ?>';
}
}
428 changes: 428 additions & 0 deletions application/config/ConfigManager.php

Large diffs are not rendered by default.

144 changes: 144 additions & 0 deletions application/config/ConfigPhp.php
@@ -0,0 +1,144 @@
<?php

namespace Shaarli\Config;

/**
* Class ConfigPhp (ConfigIO implementation)
*
* Handle Shaarli's legacy PHP configuration file.
* Note: this is only designed to support the transition to JSON configuration.
*/
class ConfigPhp implements ConfigIO
{
/**
* @var array List of config key without group.
*/
public static $ROOT_KEYS = [
'login',
'hash',
'salt',
'timezone',
'title',
'titleLink',
'redirector',
'disablesessionprotection',
'privateLinkByDefault',
];

/**
* Map legacy config keys with the new ones.
* If ConfigPhp is used, getting <newkey> will actually look for <legacykey>.
* The updater will use this array to transform keys when switching to JSON.
*
* @var array current key => legacy key.
*/
public static $LEGACY_KEYS_MAPPING = [
'credentials.login' => 'login',
'credentials.hash' => 'hash',
'credentials.salt' => 'salt',
'resource.data_dir' => 'config.DATADIR',
'resource.config' => 'config.CONFIG_FILE',
'resource.datastore' => 'config.DATASTORE',
'resource.updates' => 'config.UPDATES_FILE',
'resource.log' => 'config.LOG_FILE',
'resource.update_check' => 'config.UPDATECHECK_FILENAME',
'resource.raintpl_tpl' => 'config.RAINTPL_TPL',
'resource.theme' => 'config.theme',
'resource.raintpl_tmp' => 'config.RAINTPL_TMP',
'resource.thumbnails_cache' => 'config.CACHEDIR',
'resource.page_cache' => 'config.PAGECACHE',
'resource.ban_file' => 'config.IPBANS_FILENAME',
'security.session_protection_disabled' => 'disablesessionprotection',
'security.ban_after' => 'config.BAN_AFTER',
'security.ban_duration' => 'config.BAN_DURATION',
'general.title' => 'title',
'general.timezone' => 'timezone',
'general.header_link' => 'titleLink',
'updates.check_updates' => 'config.ENABLE_UPDATECHECK',
'updates.check_updates_branch' => 'config.UPDATECHECK_BRANCH',
'updates.check_updates_interval' => 'config.UPDATECHECK_INTERVAL',
'privacy.default_private_links' => 'privateLinkByDefault',
'feed.rss_permalinks' => 'config.ENABLE_RSS_PERMALINKS',
'general.links_per_page' => 'config.LINKS_PER_PAGE',
'thumbnail.enable_thumbnails' => 'config.ENABLE_THUMBNAILS',
'thumbnail.enable_localcache' => 'config.ENABLE_LOCALCACHE',
'general.enabled_plugins' => 'config.ENABLED_PLUGINS',
'redirector.url' => 'redirector',
'redirector.encode_url' => 'config.REDIRECTOR_URLENCODE',
'feed.show_atom' => 'config.SHOW_ATOM',
'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
'security.open_shaarli' => 'config.OPEN_SHAARLI',
];

/**
* @inheritdoc
*/
public function read($filepath)
{
if (! file_exists($filepath) || ! is_readable($filepath)) {
return [];
}

include $filepath;

$out = [];
foreach (self::$ROOT_KEYS as $key) {
$out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : '';
}
$out['config'] = isset($GLOBALS['config']) ? $GLOBALS['config'] : [];
$out['plugins'] = isset($GLOBALS['plugins']) ? $GLOBALS['plugins'] : [];
return $out;
}

/**
* @inheritdoc
*/
public function write($filepath, $conf)
{
$configStr = '<?php ' . PHP_EOL;
foreach (self::$ROOT_KEYS as $key) {
if (isset($conf[$key])) {
$configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL;
}
}

// Store all $conf['config']
foreach ($conf['config'] as $key => $value) {
$configStr .= '$GLOBALS[\'config\'][\''
. $key
. '\'] = '
. var_export($conf['config'][$key], true) . ';'
. PHP_EOL;
}

if (isset($conf['plugins'])) {
foreach ($conf['plugins'] as $key => $value) {
$configStr .= '$GLOBALS[\'plugins\'][\''
. $key
. '\'] = '
. var_export($conf['plugins'][$key], true) . ';'
. PHP_EOL;
}
}

if (
!file_put_contents($filepath, $configStr)
|| strcmp(file_get_contents($filepath), $configStr) != 0
) {
throw new \Shaarli\Exceptions\IOException(
$filepath,
t('Shaarli could not create the config file. ' .
'Please make sure Shaarli has the right to write in the folder is it installed in.')
);
}
}

/**
* @inheritdoc
*/
public function getExtension()
{
return '.php';
}
}
127 changes: 127 additions & 0 deletions application/config/ConfigPlugin.php
@@ -0,0 +1,127 @@
<?php

use Shaarli\Config\Exception\PluginConfigOrderException;
use Shaarli\Plugin\PluginManager;

/**
* Plugin configuration helper functions.
*
* Note: no access to configuration files here.
*/

/**
* Process plugin administration form data and save it in an array.
*
* @param array $formData Data sent by the plugin admin form.
*
* @return array New list of enabled plugin, ordered.
*
* @throws PluginConfigOrderException Plugins can't be sorted because their order is invalid.
*/
function save_plugin_config($formData)
{
// We can only save existing plugins
$directories = str_replace(
PluginManager::$PLUGINS_PATH . '/',
'',
glob(PluginManager::$PLUGINS_PATH . '/*')
);
$formData = array_filter(
$formData,
function ($value, string $key) use ($directories) {
return startsWith($key, 'order') || in_array($key, $directories);
},
ARRAY_FILTER_USE_BOTH
);

// Make sure there are no duplicates in orders.
if (!validate_plugin_order($formData)) {
throw new PluginConfigOrderException();
}

$plugins = [];
$newEnabledPlugins = [];
foreach ($formData as $key => $data) {
if (startsWith($key, 'order')) {
continue;
}

// If there is no order, it means a disabled plugin has been enabled.
if (isset($formData['order_' . $key])) {
$plugins[(int) $formData['order_' . $key]] = $key;
} else {
$newEnabledPlugins[] = $key;
}
}

// New enabled plugins will be added at the end of order.
$plugins = array_merge($plugins, $newEnabledPlugins);

// Sort plugins by order.
if (!ksort($plugins)) {
throw new PluginConfigOrderException();
}

$finalPlugins = [];
// Make plugins order continuous.
foreach ($plugins as $plugin) {
$finalPlugins[] = $plugin;
}

return $finalPlugins;
}

/**
* Validate plugin array submitted.
* Will fail if there is duplicate orders value.
*
* @param array $formData Data from submitted form.
*
* @return bool true if ok, false otherwise.
*/
function validate_plugin_order($formData)
{
$orders = [];
foreach ($formData as $key => $value) {
// No duplicate order allowed.
if (in_array($value, $orders, true)) {
return false;
}

if (startsWith($key, 'order')) {
$orders[] = $value;
}
}

return true;
}

/**
* Affect plugin parameters values from the ConfigManager into plugins array.
*
* @param mixed $plugins Plugins array:
* $plugins[<plugin_name>]['parameters'][<param_name>] = [
* 'value' => <value>,
* 'desc' => <description>
* ]
* @param mixed $conf Plugins configuration.
*
* @return mixed Updated $plugins array.
*/
function load_plugin_parameter_values($plugins, $conf)
{
$out = $plugins;
foreach ($plugins as $name => $plugin) {
if (empty($plugin['parameters'])) {
continue;
}

foreach ($plugin['parameters'] as $key => $param) {
if (!empty($conf[$key])) {
$out[$name]['parameters'][$key]['value'] = $conf[$key];
}
}
}

return $out;
}
22 changes: 22 additions & 0 deletions application/config/exception/MissingFieldConfigException.php
@@ -0,0 +1,22 @@
<?php

namespace Shaarli\Config\Exception;

/**
* Exception used if a mandatory field is missing in given configuration.
*/
class MissingFieldConfigException extends \Exception
{
public $field;

/**
* Construct exception.
*
* @param string $field field name missing.
*/
public function __construct($field)
{
$this->field = $field;
$this->message = sprintf(t('Configuration value is required for %s'), $this->field);
}
}
17 changes: 17 additions & 0 deletions application/config/exception/PluginConfigOrderException.php
@@ -0,0 +1,17 @@
<?php

namespace Shaarli\Config\Exception;

/**
* Exception used if an error occur while saving plugin configuration.
*/
class PluginConfigOrderException extends \Exception
{
/**
* Construct exception.
*/
public function __construct()
{
$this->message = t('An error occurred while trying to save plugins loading order.');
}
}
17 changes: 17 additions & 0 deletions application/config/exception/UnauthorizedConfigException.php
@@ -0,0 +1,17 @@
<?php

namespace Shaarli\Config\Exception;

/**
* Exception used if an unauthorized attempt to edit configuration has been made.
*/
class UnauthorizedConfigException extends \Exception
{
/**
* Construct exception.
*/
public function __construct()
{
$this->message = t('You are not authorized to alter config.');
}
}
176 changes: 176 additions & 0 deletions application/container/ContainerBuilder.php
@@ -0,0 +1,176 @@
<?php

declare(strict_types=1);

namespace Shaarli\Container;

use malkusch\lock\mutex\FlockMutex;
use Psr\Log\LoggerInterface;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\Feed\FeedBuilder;
use Shaarli\Formatter\FormatterFactory;
use Shaarli\Front\Controller\Visitor\ErrorController;
use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
use Shaarli\History;
use Shaarli\Http\HttpAccess;
use Shaarli\Http\MetadataRetriever;
use Shaarli\Netscape\NetscapeBookmarkUtils;
use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageBuilder;
use Shaarli\Render\PageCacheManager;
use Shaarli\Security\CookieManager;
use Shaarli\Security\LoginManager;
use Shaarli\Security\SessionManager;
use Shaarli\Thumbnailer;
use Shaarli\Updater\Updater;
use Shaarli\Updater\UpdaterUtils;

/**
* Class ContainerBuilder
*
* Helper used to build a Slim container instance with Shaarli's object dependencies.
* Note that most injected objects MUST be added as closures, to let the container instantiate
* only the objects it requires during the execution.
*
* @package Container
*/
class ContainerBuilder
{
/** @var ConfigManager */
protected $conf;

/** @var SessionManager */
protected $session;

/** @var CookieManager */
protected $cookieManager;

/** @var LoginManager */
protected $login;

/** @var PluginManager */
protected $pluginManager;

/** @var LoggerInterface */
protected $logger;

/** @var string|null */
protected $basePath = null;

public function __construct(
ConfigManager $conf,
SessionManager $session,
CookieManager $cookieManager,
LoginManager $login,
PluginManager $pluginManager,
LoggerInterface $logger
) {
$this->conf = $conf;
$this->session = $session;
$this->login = $login;
$this->cookieManager = $cookieManager;
$this->pluginManager = $pluginManager;
$this->logger = $logger;
}

public function build(): ShaarliContainer
{
$container = new ShaarliContainer();

$container['conf'] = $this->conf;
$container['sessionManager'] = $this->session;
$container['cookieManager'] = $this->cookieManager;
$container['loginManager'] = $this->login;
$container['pluginManager'] = $this->pluginManager;
$container['logger'] = $this->logger;
$container['basePath'] = $this->basePath;


$container['history'] = function (ShaarliContainer $container): History {
return new History($container->conf->get('resource.history'));
};

$container['bookmarkService'] = function (ShaarliContainer $container): BookmarkServiceInterface {
return new BookmarkFileService(
$container->conf,
$container->pluginManager,
$container->history,
new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
$container->loginManager->isLoggedIn()
);
};

$container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
return new MetadataRetriever($container->conf, $container->httpAccess);
};

$container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
return new PageBuilder(
$container->conf,
$container->sessionManager->getSession(),
$container->logger,
$container->bookmarkService,
$container->sessionManager->generateToken(),
$container->loginManager->isLoggedIn()
);
};

$container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
return new FormatterFactory(
$container->conf,
$container->loginManager->isLoggedIn()
);
};

$container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager {
return new PageCacheManager(
$container->conf->get('resource.page_cache'),
$container->loginManager->isLoggedIn()
);
};

$container['feedBuilder'] = function (ShaarliContainer $container): FeedBuilder {
return new FeedBuilder(
$container->bookmarkService,
$container->formatterFactory->getFormatter(),
$container->environment,
$container->loginManager->isLoggedIn()
);
};

$container['thumbnailer'] = function (ShaarliContainer $container): Thumbnailer {
return new Thumbnailer($container->conf);
};

$container['httpAccess'] = function (): HttpAccess {
return new HttpAccess();
};

$container['netscapeBookmarkUtils'] = function (ShaarliContainer $container): NetscapeBookmarkUtils {
return new NetscapeBookmarkUtils($container->bookmarkService, $container->conf, $container->history);
};

$container['updater'] = function (ShaarliContainer $container): Updater {
return new Updater(
UpdaterUtils::readUpdatesFile($container->conf->get('resource.updates')),
$container->bookmarkService,
$container->conf,
$container->loginManager->isLoggedIn()
);
};

$container['notFoundHandler'] = function (ShaarliContainer $container): ErrorNotFoundController {
return new ErrorNotFoundController($container);
};
$container['errorHandler'] = function (ShaarliContainer $container): ErrorController {
return new ErrorController($container);
};
$container['phpErrorHandler'] = function (ShaarliContainer $container): ErrorController {
return new ErrorController($container);
};

return $container;
}
}
54 changes: 54 additions & 0 deletions application/container/ShaarliContainer.php
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace Shaarli\Container;

use Psr\Log\LoggerInterface;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\Feed\FeedBuilder;
use Shaarli\Formatter\FormatterFactory;
use Shaarli\History;
use Shaarli\Http\HttpAccess;
use Shaarli\Http\MetadataRetriever;
use Shaarli\Netscape\NetscapeBookmarkUtils;
use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageBuilder;
use Shaarli\Render\PageCacheManager;
use Shaarli\Security\CookieManager;
use Shaarli\Security\LoginManager;
use Shaarli\Security\SessionManager;
use Shaarli\Thumbnailer;
use Shaarli\Updater\Updater;
use Slim\Container;

/**
* Extension of Slim container to document the injected objects.
*
* @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`)
* @property BookmarkServiceInterface $bookmarkService
* @property CookieManager $cookieManager
* @property ConfigManager $conf
* @property mixed[] $environment $_SERVER automatically injected by Slim
* @property callable $errorHandler Overrides default Slim exception display
* @property FeedBuilder $feedBuilder
* @property FormatterFactory $formatterFactory
* @property History $history
* @property HttpAccess $httpAccess
* @property LoginManager $loginManager
* @property LoggerInterface $logger
* @property MetadataRetriever $metadataRetriever
* @property NetscapeBookmarkUtils $netscapeBookmarkUtils
* @property callable $notFoundHandler Overrides default Slim exception display
* @property PageBuilder $pageBuilder
* @property PageCacheManager $pageCacheManager
* @property callable $phpErrorHandler Overrides default Slim PHP error display
* @property PluginManager $pluginManager
* @property SessionManager $sessionManager
* @property Thumbnailer $thumbnailer
* @property Updater $updater
*/
class ShaarliContainer extends Container
{
}
26 changes: 26 additions & 0 deletions application/exceptions/IOException.php
@@ -0,0 +1,26 @@
<?php

namespace Shaarli\Exceptions;

use Exception;

/**
* Exception class thrown when a filesystem access failure happens
*/
class IOException extends Exception
{
private $path;

/**
* Construct a new IOException
*
* @param string $path path to the resource that cannot be accessed
* @param string $message Custom exception message.
*/
public function __construct($path, $message = '')
{
$this->path = $path;
$this->message = empty($message) ? t('Error accessing') : $message;
$this->message .= ' "' . $this->path . '"';
}
}
81 changes: 81 additions & 0 deletions application/feed/CachedPage.php
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace Shaarli\Feed;

use DatePeriod;

/**
* Simple cache system, mainly for the RSS/ATOM feeds
*/
class CachedPage
{
/** Directory containing page caches */
protected $cacheDir;

/** Should this URL be cached (boolean)? */
protected $shouldBeCached;

/** Name of the cache file for this URL */
protected $filename;

/** @var DatePeriod|null Optionally specify a period of time for cache validity */
protected $validityPeriod;

/**
* Creates a new CachedPage
*
* @param string $cacheDir page cache directory
* @param string $url page URL
* @param bool $shouldBeCached whether this page needs to be cached
* @param ?DatePeriod $validityPeriod Optionally specify a time limit on requested cache
*/
public function __construct($cacheDir, $url, $shouldBeCached, ?DatePeriod $validityPeriod)
{
// TODO: check write access to the cache directory
$this->cacheDir = $cacheDir;
$this->filename = $this->cacheDir . '/' . sha1($url) . '.cache';
$this->shouldBeCached = $shouldBeCached;
$this->validityPeriod = $validityPeriod;
}

/**
* Returns the cached version of a page, if it exists and should be cached
*
* @return string a cached version of the page if it exists, null otherwise
*/
public function cachedVersion()
{
if (!$this->shouldBeCached) {
return null;
}
if (!is_file($this->filename)) {
return null;
}
if ($this->validityPeriod !== null) {
$cacheDate = \DateTime::createFromFormat('U', (string) filemtime($this->filename));
if (
$cacheDate < $this->validityPeriod->getStartDate()
|| $cacheDate > $this->validityPeriod->getEndDate()
) {
return null;
}
}

return file_get_contents($this->filename);
}

/**
* Puts a page in the cache
*
* @param string $pageContent XML content to cache
*/
public function cache($pageContent)
{
if (!$this->shouldBeCached) {
return;
}
file_put_contents($this->filename, $pageContent);
}
}
286 changes: 286 additions & 0 deletions application/feed/FeedBuilder.php
@@ -0,0 +1,286 @@
<?php

namespace Shaarli\Feed;

use DateTime;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Formatter\BookmarkFormatter;

/**
* FeedBuilder class.
*
* Used to build ATOM and RSS feeds data.
*/
class FeedBuilder
{
/**
* @var string Constant: RSS feed type.
*/
public static $FEED_RSS = 'rss';

/**
* @var string Constant: ATOM feed type.
*/
public static $FEED_ATOM = 'atom';

/**
* @var string Default language if the locale isn't set.
*/
public static $DEFAULT_LANGUAGE = 'en-en';

/**
* @var int Number of bookmarks to display in a feed by default.
*/
public static $DEFAULT_NB_LINKS = 50;

/**
* @var BookmarkServiceInterface instance.
*/
protected $linkDB;

/**
* @var BookmarkFormatter instance.
*/
protected $formatter;

/** @var mixed[] $_SERVER */
protected $serverInfo;

/**
* @var boolean True if the user is currently logged in, false otherwise.
*/
protected $isLoggedIn;

/**
* @var boolean Use permalinks instead of direct bookmarks if true.
*/
protected $usePermalinks;

/**
* @var boolean true to hide dates in feeds.
*/
protected $hideDates;

/**
* @var string server locale.
*/
protected $locale;
/**
* @var DateTime Latest item date.
*/
protected $latestDate;

/**
* Feed constructor.
*
* @param BookmarkServiceInterface $linkDB LinkDB instance.
* @param BookmarkFormatter $formatter instance.
* @param array $serverInfo $_SERVER.
* @param boolean $isLoggedIn True if the user is currently logged in, false otherwise.
*/
public function __construct($linkDB, $formatter, $serverInfo, $isLoggedIn)
{
$this->linkDB = $linkDB;
$this->formatter = $formatter;
$this->serverInfo = $serverInfo;
$this->isLoggedIn = $isLoggedIn;
}

/**
* Build data for feed templates.
*
* @param string $feedType Type of feed (RSS/ATOM).
* @param array $userInput $_GET.
*
* @return array Formatted data for feeds templates.
*/
public function buildData(string $feedType, ?array $userInput)
{
// Search for untagged bookmarks
if (isset($this->userInput['searchtags']) && empty($userInput['searchtags'])) {
$userInput['searchtags'] = false;
}

$limit = $this->getLimit($userInput);

// Optionally filter the results:
$searchResult = $this->linkDB->search($userInput ?? [], null, false, false, true, ['limit' => $limit]);

$pageaddr = escape(index_url($this->serverInfo));
$this->formatter->addContextData('index_url', $pageaddr);
$links = [];
foreach ($searchResult->getBookmarks() as $key => $bookmark) {
$links[$key] = $this->buildItem($feedType, $bookmark, $pageaddr);
}

$data['language'] = $this->getTypeLanguage($feedType);
$data['last_update'] = $this->getLatestDateFormatted($feedType);
$data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
// Remove leading path from REQUEST_URI (already contained in $pageaddr).
$requestUri = preg_replace('#(.*?/)(feed.*)#', '$2', escape($this->serverInfo['REQUEST_URI']));
$data['self_link'] = $pageaddr . $requestUri;
$data['index_url'] = $pageaddr;
$data['usepermalinks'] = $this->usePermalinks === true;
$data['links'] = $links;

return $data;
}

/**
* Set this to true to use permalinks instead of direct bookmarks.
*
* @param boolean $usePermalinks true to force permalinks.
*/
public function setUsePermalinks($usePermalinks)
{
$this->usePermalinks = $usePermalinks;
}

/**
* Set this to true to hide timestamps in feeds.
*
* @param boolean $hideDates true to enable.
*/
public function setHideDates($hideDates)
{
$this->hideDates = $hideDates;
}

/**
* Set the locale. Used to show feed language.
*
* @param string $locale The locale (eg. 'fr_FR.UTF8').
*/
public function setLocale($locale)
{
$this->locale = strtolower($locale);
}

/**
* Build a feed item (one per shaare).
*
* @param string $feedType Type of feed (RSS/ATOM).
* @param Bookmark $link Single link array extracted from LinkDB.
* @param string $pageaddr Index URL.
*
* @return array Link array with feed attributes.
*/
protected function buildItem(string $feedType, $link, $pageaddr)
{
$data = $this->formatter->format($link);
$data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl'];
if ($this->usePermalinks === true) {
$permalink = '<a href="' . $data['url'] . '" title="' . t('Direct link') . '">' . t('Direct link') . '</a>';
} else {
$permalink = '<a href="' . $data['guid'] . '" title="' . t('Permalink') . '">' . t('Permalink') . '</a>';
}
$data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;

$data['pub_iso_date'] = $this->getIsoDate($feedType, $data['created']);

// atom:entry elements MUST contain exactly one atom:updated element.
if (!empty($link->getUpdated())) {
$data['up_iso_date'] = $this->getIsoDate($feedType, $data['updated'], DateTime::ATOM);
} else {
$data['up_iso_date'] = $this->getIsoDate($feedType, $data['created'], DateTime::ATOM);
}

// Save the more recent item.
if (empty($this->latestDate) || $this->latestDate < $data['created']) {
$this->latestDate = $data['created'];
}
if (!empty($data['updated']) && $this->latestDate < $data['updated']) {
$this->latestDate = $data['updated'];
}

return $data;
}

/**
* Get the language according to the feed type, based on the locale:
*
* - RSS format: en-us (default: 'en-en').
* - ATOM format: fr (default: 'en').
*
* @param string $feedType Type of feed (RSS/ATOM).
*
* @return string The language.
*/
protected function getTypeLanguage(string $feedType)
{
// Use the locale do define the language, if available.
if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) {
$length = ($feedType === self::$FEED_RSS) ? 5 : 2;
return str_replace('_', '-', substr($this->locale, 0, $length));
}
return ($feedType === self::$FEED_RSS) ? 'en-en' : 'en';
}

/**
* Format the latest item date found according to the feed type.
*
* Return an empty string if invalid DateTime is passed.
*
* @param string $feedType Type of feed (RSS/ATOM).
*
* @return string Formatted date.
*/
protected function getLatestDateFormatted(string $feedType)
{
if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) {
return '';
}

$type = ($feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
return $this->latestDate->format($type);
}

/**
* Get ISO date from DateTime according to feed type.
*
* @param string $feedType Type of feed (RSS/ATOM).
* @param DateTime $date Date to format.
* @param string|bool $format Force format.
*
* @return string Formatted date.
*/
protected function getIsoDate(string $feedType, DateTime $date, $format = false)
{
if ($format !== false) {
return $date->format($format);
}
if ($feedType == self::$FEED_RSS) {
return $date->format(DateTime::RSS);
}
return $date->format(DateTime::ATOM);
}

/**
* Returns the number of link to display according to 'nb' user input parameter.
*
* If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS.
* If 'nb' is set to 'all', display all filtered bookmarks (max parameter).
*
* @param array $userInput $_GET.
*
* @return int number of bookmarks to display.
*/
protected function getLimit(?array $userInput)
{
if (empty($userInput['nb'])) {
return self::$DEFAULT_NB_LINKS;
}

if ($userInput['nb'] == 'all') {
return null;
}

$intNb = intval($userInput['nb']);
if (!is_int($intNb) || $intNb == 0) {
return self::$DEFAULT_NB_LINKS;
}

return $intNb;
}
}
229 changes: 229 additions & 0 deletions application/formatter/BookmarkDefaultFormatter.php
@@ -0,0 +1,229 @@
<?php

namespace Shaarli\Formatter;

use Shaarli\Bookmark\Bookmark;

/**
* Class BookmarkDefaultFormatter
*
* Default bookmark formatter.
* Escape values for HTML display and automatically add link to URL and hashtags.
*
* @package Shaarli\Formatter
*/
class BookmarkDefaultFormatter extends BookmarkFormatter
{
public const SEARCH_HIGHLIGHT_OPEN = 'SHAARLI_O_HIGHLIGHT';
public const SEARCH_HIGHLIGHT_CLOSE = 'SHAARLI_C_HIGHLIGHT';

/**
* @inheritdoc
*/
protected function formatTitle($bookmark)
{
return escape($bookmark->getTitle());
}

/**
* @inheritdoc
*/
protected function formatTitleHtml($bookmark)
{
$title = $this->tokenizeSearchHighlightField(
$bookmark->getTitle() ?? '',
$bookmark->getAdditionalContentEntry('search_highlight')['title'] ?? []
);

return $this->replaceTokens(escape($title));
}

/**
* @inheritdoc
*/
protected function formatDescription($bookmark)
{
$indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
$description = $this->tokenizeSearchHighlightField(
$bookmark->getDescription() ?? '',
$bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
);
$description = format_description(
escape($description),
$indexUrl,
$this->conf->get('formatter_settings.autolink', true)
);

return $this->replaceTokens($description);
}

/**
* @inheritdoc
*/
protected function formatTagList($bookmark)
{
return escape(parent::formatTagList($bookmark));
}

/**
* @inheritdoc
*/
protected function formatTagListHtml($bookmark)
{
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
return $this->formatTagList($bookmark);
}

$tags = $this->tokenizeSearchHighlightField(
$bookmark->getTagsString($tagsSeparator),
$bookmark->getAdditionalContentEntry('search_highlight')['tags']
);
$tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator));
$tags = escape($tags);
$tags = $this->replaceTokensArray($tags);

return $tags;
}

/**
* @inheritdoc
*/
protected function formatTagString($bookmark)
{
return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark));
}

/**
* @inheritdoc
*/
protected function formatUrl($bookmark)
{
if ($bookmark->isNote() && isset($this->contextData['index_url'])) {
return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/'));
}

return escape($bookmark->getUrl());
}

/**
* @inheritdoc
*/
protected function formatRealUrl($bookmark)
{
if ($bookmark->isNote()) {
if (isset($this->contextData['index_url'])) {
$prefix = rtrim($this->contextData['index_url'], '/') . '/';
}

if (isset($this->contextData['base_path'])) {
$prefix = rtrim($this->contextData['base_path'], '/') . '/';
}

return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl() ?? '', '/'));
}

return escape($bookmark->getUrl());
}

/**
* @inheritdoc
*/
protected function formatUrlHtml($bookmark)
{
$url = $this->tokenizeSearchHighlightField(
$bookmark->getUrl() ?? '',
$bookmark->getAdditionalContentEntry('search_highlight')['url'] ?? []
);

return $this->replaceTokens(escape($url));
}

/**
* @inheritdoc
*/
protected function formatThumbnail($bookmark)
{
return escape($bookmark->getThumbnail());
}

/**
* @inheritDoc
*/
protected function formatAdditionalContent(Bookmark $bookmark): array
{
$additionalContent = parent::formatAdditionalContent($bookmark);

unset($additionalContent['search_highlight']);

return $additionalContent;
}

/**
* Insert search highlight token in provided field content based on a list of search result positions
*
* @param string $fieldContent
* @param array|null $positions List of of search results with 'start' and 'end' positions.
*
* @return string Updated $fieldContent.
*/
protected function tokenizeSearchHighlightField(string $fieldContent, ?array $positions): string
{
if (empty($positions)) {
return $fieldContent;
}

$insertedTokens = 0;
$tokenLength = strlen(static::SEARCH_HIGHLIGHT_OPEN);
foreach ($positions as $position) {
$position = [
'start' => $position['start'] + ($insertedTokens * $tokenLength),
'end' => $position['end'] + ($insertedTokens * $tokenLength),
];

$content = mb_substr($fieldContent, 0, $position['start']);
$content .= static::SEARCH_HIGHLIGHT_OPEN;
$content .= mb_substr($fieldContent, $position['start'], $position['end'] - $position['start']);
$content .= static::SEARCH_HIGHLIGHT_CLOSE;
$content .= mb_substr($fieldContent, $position['end']);

$fieldContent = $content;

$insertedTokens += 2;
}

return $fieldContent;
}

/**
* Replace search highlight tokens with HTML highlighted span.
*
* @param string $fieldContent
*
* @return string updated content.
*/
protected function replaceTokens(string $fieldContent): string
{
return str_replace(
[static::SEARCH_HIGHLIGHT_OPEN, static::SEARCH_HIGHLIGHT_CLOSE],
['<span class="search-highlight">', '</span>'],
$fieldContent
);
}

/**
* Apply replaceTokens to an array of content strings.
*
* @param string[] $fieldContents
*
* @return array
*/
protected function replaceTokensArray(array $fieldContents): array
{
foreach ($fieldContents as &$entry) {
$entry = $this->replaceTokens($entry);
}

return $fieldContents;
}
}
390 changes: 390 additions & 0 deletions application/formatter/BookmarkFormatter.php
@@ -0,0 +1,390 @@
<?php

namespace Shaarli\Formatter;

use DateTimeInterface;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Config\ConfigManager;

/**
* Class BookmarkFormatter
*
* Abstract class processing all bookmark attributes through methods designed to be overridden.
*
* List of available formatted fields:
* - id ID
* - shorturl Unique identifier, used in permalinks
* - url URL, can be altered in some way, e.g. passing through an HTTP reverse proxy
* - real_url (legacy) same as `url`
* - url_html URL to be displayed in HTML content (it can contain HTML tags)
* - title Title
* - title_html Title to be displayed in HTML content (it can contain HTML tags)
* - description Description content. It most likely contains HTML tags
* - thumbnail Thumbnail: path to local cache file, false if there is none, null if hasn't been retrieved
* - taglist List of tags (array)
* - taglist_urlencoded List of tags (array) URL encoded: it must be used to create a link to a URL containing a tag
* - taglist_html List of tags (array) to be displayed in HTML content (it can contain HTML tags)
* - tags Tags separated by a single whitespace
* - tags_urlencoded Tags separated by a single whitespace, URL encoded: must be used to create a link
* - sticky Is sticky (bool)
* - private Is private (bool)
* - class Additional CSS class
* - created Creation DateTime
* - updated Last edit DateTime
* - timestamp Creation timestamp
* - updated_timestamp Last edit timestamp
*
* @package Shaarli\Formatter
*/
abstract class BookmarkFormatter
{
/**
* @var ConfigManager
*/
protected $conf;

/** @var bool */
protected $isLoggedIn;

/**
* @var array Additional parameters than can be used for specific formatting
* e.g. index_url for Feed formatting
*/
protected $contextData = [];

/**
* LinkDefaultFormatter constructor.
* @param ConfigManager $conf
*/
public function __construct(ConfigManager $conf, bool $isLoggedIn)
{
$this->conf = $conf;
$this->isLoggedIn = $isLoggedIn;
}

/**
* Convert a Bookmark into an array usable by templates and plugins.
*
* All Bookmark attributes are formatted through a format method
* that can be overridden in a formatter extending this class.
*
* @param Bookmark $bookmark instance
*
* @return array formatted representation of a Bookmark
*/
public function format($bookmark)
{
$out['id'] = $this->formatId($bookmark);
$out['shorturl'] = $this->formatShortUrl($bookmark);
$out['url'] = $this->formatUrl($bookmark);
$out['real_url'] = $this->formatRealUrl($bookmark);
$out['url_html'] = $this->formatUrlHtml($bookmark);
$out['title'] = $this->formatTitle($bookmark);
$out['title_html'] = $this->formatTitleHtml($bookmark);
$out['description'] = $this->formatDescription($bookmark);
$out['thumbnail'] = $this->formatThumbnail($bookmark);
$out['taglist'] = $this->formatTagList($bookmark);
$out['taglist_urlencoded'] = $this->formatTagListUrlEncoded($bookmark);
$out['taglist_html'] = $this->formatTagListHtml($bookmark);
$out['tags'] = $this->formatTagString($bookmark);
$out['tags_urlencoded'] = $this->formatTagStringUrlEncoded($bookmark);
$out['sticky'] = $bookmark->isSticky();
$out['private'] = $bookmark->isPrivate();
$out['class'] = $this->formatClass($bookmark);
$out['created'] = $this->formatCreated($bookmark);
$out['updated'] = $this->formatUpdated($bookmark);
$out['timestamp'] = $this->formatCreatedTimestamp($bookmark);
$out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark);
$out['additional_content'] = $this->formatAdditionalContent($bookmark);

return $out;
}

/**
* Add additional data available to formatters.
* This is used for example to add `index_url` in description's links.
*
* @param string $key Context data key
* @param string $value Context data value
*/
public function addContextData($key, $value)
{
$this->contextData[$key] = $value;

return $this;
}

/**
* Format ID
*
* @param Bookmark $bookmark instance
*
* @return int formatted ID
*/
protected function formatId($bookmark)
{
return $bookmark->getId();
}

/**
* Format ShortUrl
*
* @param Bookmark $bookmark instance
*
* @return string formatted ShortUrl
*/
protected function formatShortUrl($bookmark)
{
return $bookmark->getShortUrl();
}

/**
* Format Url
*
* @param Bookmark $bookmark instance
*
* @return string formatted Url
*/
protected function formatUrl($bookmark)
{
return $bookmark->getUrl();
}

/**
* Format RealUrl
* Legacy: identical to Url
*
* @param Bookmark $bookmark instance
*
* @return string formatted RealUrl
*/
protected function formatRealUrl($bookmark)
{
return $this->formatUrl($bookmark);
}

/**
* Format Url Html: to be displayed in HTML content, it can contains HTML tags.
*
* @param Bookmark $bookmark instance
*
* @return string formatted Url HTML
*/
protected function formatUrlHtml($bookmark)
{
return $this->formatUrl($bookmark);
}

/**
* Format Title
*
* @param Bookmark $bookmark instance
*
* @return string formatted Title
*/
protected function formatTitle($bookmark)
{
return $bookmark->getTitle();
}

/**
* Format Title HTML: to be displayed in HTML content, it can contains HTML tags.
*
* @param Bookmark $bookmark instance
*
* @return string formatted Title
*/
protected function formatTitleHtml($bookmark)
{
return $bookmark->getTitle();
}

/**
* Format Description
*
* @param Bookmark $bookmark instance
*
* @return string formatted Description
*/
protected function formatDescription($bookmark)
{
return $bookmark->getDescription();
}

/**
* Format Thumbnail
*
* @param Bookmark $bookmark instance
*
* @return string formatted Thumbnail
*/
protected function formatThumbnail($bookmark)
{
return $bookmark->getThumbnail();
}

/**
* Format Tags
*
* @param Bookmark $bookmark instance
*
* @return array formatted Tags
*/
protected function formatTagList($bookmark)
{
return $this->filterTagList($bookmark->getTags());
}

/**
* Format Url Encoded Tags
*
* @param Bookmark $bookmark instance
*
* @return array formatted Tags
*/
protected function formatTagListUrlEncoded($bookmark)
{
return array_map('urlencode', $this->filterTagList($bookmark->getTags()));
}

/**
* Format Tags HTML: to be displayed in HTML content, it can contains HTML tags.
*
* @param Bookmark $bookmark instance
*
* @return array formatted Tags
*/
protected function formatTagListHtml($bookmark)
{
return $this->formatTagList($bookmark);
}

/**
* Format TagString
*
* @param Bookmark $bookmark instance
*
* @return string formatted TagString
*/
protected function formatTagString($bookmark)
{
return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark));
}

/**
* Format TagString
*
* @param Bookmark $bookmark instance
*
* @return string formatted TagString
*/
protected function formatTagStringUrlEncoded($bookmark)
{
return implode(' ', $this->formatTagListUrlEncoded($bookmark));
}

/**
* Format Class
* Used to add specific CSS class for a link
*
* @param Bookmark $bookmark instance
*
* @return string formatted Class
*/
protected function formatClass($bookmark)
{
return $bookmark->isPrivate() ? 'private' : '';
}

/**
* Format Created
*
* @param Bookmark $bookmark instance
*
* @return DateTimeInterface instance
*/
protected function formatCreated(Bookmark $bookmark)
{
return $bookmark->getCreated();
}

/**
* Format Updated
*
* @param Bookmark $bookmark instance
*
* @return DateTimeInterface instance
*/
protected function formatUpdated(Bookmark $bookmark)
{
return $bookmark->getUpdated();
}

/**
* Format CreatedTimestamp
*
* @param Bookmark $bookmark instance
*
* @return int formatted CreatedTimestamp
*/
protected function formatCreatedTimestamp(Bookmark $bookmark)
{
if (! empty($bookmark->getCreated())) {
return $bookmark->getCreated()->getTimestamp();
}
return 0;
}

/**
* Format UpdatedTimestamp
*
* @param Bookmark $bookmark instance
*
* @return int formatted UpdatedTimestamp
*/
protected function formatUpdatedTimestamp(Bookmark $bookmark)
{
if (! empty($bookmark->getUpdated())) {
return $bookmark->getUpdated()->getTimestamp();
}
return 0;
}

/**
* Format bookmark's additional content
*
* @param Bookmark $bookmark instance
*
* @return mixed[]
*/
protected function formatAdditionalContent(Bookmark $bookmark): array
{
return $bookmark->getAdditionalContent();
}

/**
* Format tag list, e.g. remove private tags if the user is not logged in.
* TODO: this method is called multiple time to format tags, the result should be cached.
*
* @param array $tags
*
* @return array
*/
protected function filterTagList(array $tags): array
{
if ($this->isLoggedIn === true) {
return $tags;
}

$out = [];
foreach ($tags as $tag) {
if (strpos($tag, '.') === 0) {
continue;
}

$out[] = $tag;
}

return $out;
}
}
24 changes: 24 additions & 0 deletions application/formatter/BookmarkMarkdownExtraFormatter.php
@@ -0,0 +1,24 @@
<?php

namespace Shaarli\Formatter;

use Shaarli\Config\ConfigManager;
use Shaarli\Formatter\Parsedown\ShaarliParsedownExtra;

/**
* Class BookmarkMarkdownExtraFormatter
*
* Format bookmark description into MarkdownExtra format.
*
* @see https://michelf.ca/projects/php-markdown/extra/
*
* @package Shaarli\Formatter
*/
class BookmarkMarkdownExtraFormatter extends BookmarkMarkdownFormatter
{
public function __construct(ConfigManager $conf, bool $isLoggedIn)
{
parent::__construct($conf, $isLoggedIn);
$this->parsedown = new ShaarliParsedownExtra();
}
}