Skip to content

Commit

Permalink
feat(PageReference): Public page reference lookups
Browse files Browse the repository at this point in the history
Allow to resolve page references by share token to support lookups from
public shares. This adds link previews for links to other pages in the
same collective in public shares.

It requires the new public reference API from nextcloud/server#46378 and
the Text app being built with nextcloud-libraries/nextcloud-vue#5800.

Fixes: #1275
Contributes to nextcloud/text#5520

Signed-off-by: Jonas <jonas@freesources.org>
  • Loading branch information
mejo- committed Jul 16, 2024
1 parent fe6eff3 commit 7b9abad
Show file tree
Hide file tree
Showing 9 changed files with 377 additions and 103 deletions.
12 changes: 11 additions & 1 deletion lib/Db/Collective.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@ class Collective extends Entity implements JsonSerializable {
protected ?int $trashTimestamp = null;
protected int $pageMode = self::defaultPageMode;
/** transient attributes, not persisted in database */
protected string $name;
protected string $name = '';
protected int $level = Member::LEVEL_MEMBER;
protected ?string $shareToken = null;
protected bool $isPageShare = false;
protected int $sharePageId = 0;
protected bool $shareEditable = false;
protected int $userPageOrder = Collective::defaultPageOrder;
protected bool $userShowRecentPages = Collective::defaultShowRecentPages;
Expand Down Expand Up @@ -158,6 +159,14 @@ public function setIsPageShare(bool $isPageShare): void {
$this->isPageShare = $isPageShare;
}

public function getSharePageId(): int {
return $this->sharePageId;
}

public function setSharePageId(int $sharePageId): void {
$this->sharePageId = $sharePageId;
}

public function getShareEditable(): bool {
return $this->shareEditable;
}
Expand Down Expand Up @@ -251,6 +260,7 @@ public function jsonSerialize(): array {
'canShare' => $this->canShare(),
'shareToken' => $this->shareToken,
'isPageShare' => $this->isPageShare,
'sharePageId' => $this->sharePageId,
'shareEditable' => $this->canEdit() && $this->shareEditable,
'userPageOrder' => $this->userPageOrder,
'userShowRecentPages' => $this->userShowRecentPages,
Expand Down
273 changes: 176 additions & 97 deletions lib/Reference/SearchablePageReferenceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
use OC\Collaboration\Reference\LinkReferenceProvider;
use OC\Collaboration\Reference\ReferenceManager;
use OCA\Collectives\AppInfo\Application;
use OCA\Collectives\Db\Collective;
use OCA\Collectives\Model\PageInfo;
use OCA\Collectives\Service\CollectiveService;
use OCA\Collectives\Service\NotFoundException;
use OCA\Collectives\Service\PageService;
use OCA\Collectives\Service\SharePageService;
use OCP\Collaboration\Reference\ADiscoverableReferenceProvider;
use OCP\Collaboration\Reference\IPublicReferenceProvider;
use OCP\Collaboration\Reference\IReference;
use OCP\Collaboration\Reference\ISearchableReferenceProvider;
use OCP\Collaboration\Reference\Reference;
Expand All @@ -22,18 +25,20 @@
use OCP\IURLGenerator;
use Throwable;

class SearchablePageReferenceProvider extends ADiscoverableReferenceProvider implements ISearchableReferenceProvider {
class SearchablePageReferenceProvider extends ADiscoverableReferenceProvider implements ISearchableReferenceProvider, IPublicReferenceProvider {

Check failure on line 28 in lib/Reference/SearchablePageReferenceProvider.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedClass

lib/Reference/SearchablePageReferenceProvider.php:28:119: UndefinedClass: Class, interface or enum named OCP\Collaboration\Reference\IPublicReferenceProvider does not exist (see https://psalm.dev/019)
private const RICH_OBJECT_TYPE = Application::APP_NAME . '_page';

public function __construct(private CollectiveService $collectiveService,
public function __construct(
private CollectiveService $collectiveService,
private PageService $pageService,
private SharePageService $sharePageService,
private IL10N $l10n,
private IURLGenerator $urlGenerator,
private IDateTimeFormatter $dateTimeFormatter,
private ReferenceManager $referenceManager,
private LinkReferenceProvider $linkReferenceProvider,
private ?string $userId) {
}
private ?string $userId,
) {}

public function getId(): string {
return Application::APP_NAME . '-ref-pages';
Expand All @@ -57,125 +62,199 @@ public function getSupportedSearchProviderIds(): array {
return ['collectives-pages'];
}

public function matchReference(string $referenceText): bool {
if ($this->userId === null) {
return false;
private static function pagePathFromMatches(string $url, array $matches): array {
$pagePath = [
'collectiveName' => urldecode($matches[1]),
'pagePath' => urldecode($matches[2]),
];
preg_match('/\?fileId=(\d+)$/i', $url, $matches);
if ($matches && count($matches) > 1) {
$pagePath['fileId'] = (int) $matches[1];
}
return $pagePath;
}

public function matchUrl(string $url): ?array {
// link examples:
// https://nextcloud.local/apps/collectives/supacollective/p/MsdwSCmP9F6jcQX/Tutos/Hacking/Spectre?fileId=14457
// https://nextcloud.local/apps/collectives/supacollective/p/MsdwSCmP9F6jcQX/Tutos/Hacking/Spectre
// https://nextcloud.local/apps/collectives/supacollective/index.php/p/MsdwSCmP9F6jcQX/Tutos/Hacking/Spectre?fileId=14457
$startPublicRegexes = [
$this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_NAME . '/p'),
$this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_NAME . '/p'),
];

$matches = false;
foreach ($startPublicRegexes as $regex) {
preg_match('/^' . preg_quote($regex, '/') . '\/\w+' . '\/([^\/]+)\/([^?]+)/i', $url, $matches);
if ($matches && count($matches) > 2) {
return self::pagePathFromMatches($url, $matches);
}
}
$start = $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_NAME);
$startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_NAME);

// link example:
// link examples:
// https://nextcloud.local/apps/collectives/supacollective/Tutos/Hacking/Spectre?fileId=14457
// https://nextcloud.local/apps/collectives/supacollective/Tutos/Hacking/Spectre
$noIndexMatch = preg_match('/^' . preg_quote($start, '/') . '\/[^\/]+\//i', $referenceText) === 1;
$indexMatch = preg_match('/^' . preg_quote($startIndex, '/') . '\/[^\/]+\//i', $referenceText) === 1;
$startRegexes = [
$this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_NAME),
$this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_NAME),
];

return $noIndexMatch || $indexMatch;
foreach ($startRegexes as $regex) {
preg_match('/^' . preg_quote($regex, '/') . '\/([^\/]+)\/([^?]+)/i', $url, $matches);
if ($matches && count($matches) > 2) {
return self::pagePathFromMatches($url, $matches);
}
}

return null;
}

public function resolveReference(string $referenceText): ?IReference {
if ($this->matchReference($referenceText)) {
$pageReferenceInfo = $this->getPagePathFromDirectLink($referenceText);
if (!$pageReferenceInfo) {
// fallback to opengraph if it matches, but somehow we can't resolve
return $this->linkReferenceProvider->resolveReference($referenceText);
}
public function matchReference(string $referenceText): bool {
return (bool)$this->matchUrl($referenceText);
}

/**
* @throws NotFoundException
*/
private function getCollective(string $collectiveName, ?string $sharingToken): Collective {
if ($sharingToken) {
// TODO: Check if share is password protected; if yes, then check in session if authenticated
return $this->collectiveService->findCollectiveByShare($sharingToken);
}

return $this->collectiveService->findCollectiveByName($this->userId, $collectiveName);
}

/**
* @throws NotFoundException
*/
private function getPage(Collective $collective, array $pageReferenceInfo, bool $public): PageInfo {
if ($public && !$collective->getShareToken()) {
throw new NotFoundException('Collective share token is missing');
}

$collectiveName = $pageReferenceInfo['collectiveName'];
if (isset($pageReferenceInfo['fileId'])) {
$page = $public
? $this->sharePageService->findSharePageById($collective->getShareToken(), $pageReferenceInfo['fileId'])
: $this->pageService->findByFileId($collective->getId(), $pageReferenceInfo['fileId'], $this->userId);
} else {
try {
$collective = $this->collectiveService->findCollectiveByName($this->userId, $collectiveName);
if (isset($pageReferenceInfo['fileId'])) {
$page = $this->pageService->findByFileId($collective->getId(), $pageReferenceInfo['fileId'], $this->userId);
} else {
try {
$page = $this->pageService->findByPath($collective->getId(), $pageReferenceInfo['pagePath'], $this->userId);
} catch (NotFoundException) {
$pathInfo = pathinfo($pageReferenceInfo['pagePath']);
if (!$pathInfo || !array_key_exists('extension', $pathInfo)) {
throw new NotFoundException('Pathinfo for page path is incomplete');
}
if ('.' . $pathInfo['extension'] === PageInfo::SUFFIX) {
if ($pathInfo['filename'] === PageInfo::INDEX_PAGE_TITLE) {
// try to find page by stripping `/Readme.md`
$page = $this->pageService->findByPath($collective->getId(), $pathInfo['dirname'], $this->userId);
} else {
// try to find page by stripping `.md`
$page = $this->pageService->findByPath($collective->getId(), $pathInfo['filename'], $this->userId);
}
}
$page = $public
? $this->sharePageService->findSharePageByPath($collective->getShareToken(), $pageReferenceInfo['pagePath'])
: $this->pageService->findByPath($collective->getId(), $pageReferenceInfo['pagePath'], $this->userId);
} catch (NotFoundException) {
$pathInfo = pathinfo($pageReferenceInfo['pagePath']);
if (!$pathInfo || !array_key_exists('extension', $pathInfo)) {
throw new NotFoundException('Pathinfo for page path is incomplete');
}
if ('.' . $pathInfo['extension'] === PageInfo::SUFFIX) {
if ($pathInfo['filename'] === PageInfo::INDEX_PAGE_TITLE) {
// try to find page by stripping `/Readme.md`
$page = $public
? $this->sharePageService->findSharePageByPath($collective->getShareToken(), $pathInfo['dirname'])
: $this->pageService->findByPath($collective->getId(), $pathInfo['dirname'], $this->userId);
} else {
// try to find page by stripping `.md`
$page = $public
? $this->sharePageService->findSharePageByPath($collective->getShareToken(), $pathInfo['filename'])
: $this->pageService->findByPath($collective->getId(), $pathInfo['filename'], $this->userId);
}
} else {
throw new NotFoundException('Pathinfo for page path is incomplete');
}
} catch (Exception | Throwable) {
// fallback to opengraph if it matches, but somehow we can't resolve
return $this->linkReferenceProvider->resolveReference($referenceText);
}

$pageReferenceInfo['collective'] = $collective;
$pageReferenceInfo['page'] = $page;

$link = $this->urlGenerator->linkToRouteAbsolute('collectives.start.index') . $this->pageService->getPageLink($collective->getName(), $page);
$reference = new Reference($link);
$pageEmoji = $page->getEmoji();
$refTitle = $pageEmoji ? $pageEmoji . ' ' . $page->getTitle() : $page->getTitle();
$reference->setTitle($refTitle);

$description = $this->l10n->t('In collective %1$s', [$this->collectiveService->getCollectiveNameWithEmoji($collective)])
. ' - ' . $page->getFilePath();
$reference->setDescription($description);
$pageReferenceInfo['description'] = $description;

$date = new DateTime();
$date->setTimestamp($page->getTimestamp());
$formattedRelativeDate = $this->dateTimeFormatter->formatTimeSpan($date);
$pageReferenceInfo['lastEdited'] = $this->l10n->t('Last edition %1$s', [$formattedRelativeDate]);

$imageUrl = $this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath(Application::APP_NAME, 'page.svg')
);
$reference->setImageUrl($imageUrl);

$pageReferenceInfo['link'] = $link;
$reference->setUrl($link);

$reference->setRichObject(
self::RICH_OBJECT_TYPE,
$pageReferenceInfo,
);
return $reference;
}

return null;
return $page;
}

public function getPagePathFromDirectLink(string $url): ?array {
$start = $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_NAME);
$startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_NAME);
private function resolve(string $referenceText, bool $public = false, string $sharingToken = ''): ?IReference {
if (!$this->matchReference($referenceText)) {
return null;
}

preg_match('/^' . preg_quote($start, '/') . '\/([^\/]+)\/([^?]+)/i', $url, $matches);
if (!$matches || count($matches) < 3) {
preg_match('/^' . preg_quote($startIndex, '/') . '\/([^\/]+)\/([^?]+)/i', $url, $matches);
$pageReferenceInfo = $this->getPagePathFromDirectLink($referenceText);
if (!$pageReferenceInfo) {
// fallback to opengraph if it matches, but somehow we can't resolve
return $this->linkReferenceProvider->resolveReference($referenceText);
}
if ($matches && count($matches) > 2) {
$pagePath = [
'collectiveName' => urldecode($matches[1]),
'pagePath' => urldecode($matches[2]),
];
preg_match('/\?fileId=(\d+)$/i', $url, $matches);
if ($matches && count($matches) > 1) {
$pagePath['fileId'] = (int) $matches[1];
}
return $pagePath;

$collectiveName = $pageReferenceInfo['collectiveName'];

if ($public && !$sharingToken) {
// fallback to opengraph for public lookups without share token
return $this->linkReferenceProvider->resolveReference($referenceText);
}

return null;
try {
$collective = $this->getCollective($collectiveName, $sharingToken);
$page = $this->getPage($collective, $pageReferenceInfo, $public);
} catch (Exception | Throwable) {
// fallback to opengraph if it matches, but somehow we can't resolve
return $this->linkReferenceProvider->resolveReference($referenceText);
}

$pageReferenceInfo['collective'] = $collective;
$pageReferenceInfo['page'] = $page;

$collectiveLink = $this->urlGenerator->linkToRouteAbsolute('collectives.start.index') . ($public ? 'p/' . $sharingToken . '/' : '') . $collectiveName . '/';
$link = $collectiveLink . $this->pageService->getPageLink($collective->getName(), $page);
$reference = new Reference($link);
$pageEmoji = $page->getEmoji();
$refTitle = $pageEmoji ? $pageEmoji . ' ' . $page->getTitle() : $page->getTitle();
$reference->setTitle($refTitle);

$description = $this->l10n->t('In collective %1$s', [$this->collectiveService->getCollectiveNameWithEmoji($collective)])
. ' - ' . $page->getFilePath();
$reference->setDescription($description);
$pageReferenceInfo['description'] = $description;

$date = new DateTime();
$date->setTimestamp($page->getTimestamp());
$formattedRelativeDate = $this->dateTimeFormatter->formatTimeSpan($date);
$pageReferenceInfo['lastEdited'] = $this->l10n->t('Last edition %1$s', [$formattedRelativeDate]);

$imageUrl = $this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath(Application::APP_NAME, 'page.svg')
);
$reference->setImageUrl($imageUrl);

$pageReferenceInfo['link'] = $link;
$reference->setUrl($link);

$reference->setRichObject(
self::RICH_OBJECT_TYPE,
$pageReferenceInfo,
);
return $reference;
}

public function resolveReference(string $referenceText): ?IReference {
if ($this->userId === null) {
return $this->linkReferenceProvider->resolveReference($referenceText);
}
return $this->resolve($referenceText);
}

public function resolveReferencePublic(string $referenceText, string $sharingToken): ?IReference {
return $this->resolve($referenceText, true, $sharingToken);
}

public function getPagePathFromDirectLink(string $url): ?array {
return $this->matchUrl($url);
}

public function getCachePrefix(string $referenceId): string {
return $this->userId ?? '';
return $referenceId;
}

public function getCacheKey(string $referenceId): ?string {
return $referenceId;
return $this->userId ?? '';
}

public function getCacheKeyPublic(string $referenceId, string $sharingToken): ?string {
return $sharingToken;
}

public function invalidateUserCache(string $userId): void {
Expand Down
Loading

0 comments on commit 7b9abad

Please sign in to comment.