diff --git a/appinfo/routes.php b/appinfo/routes.php index 54eef3f..4916137 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -2,9 +2,13 @@ return [ 'routes' => [ - ['name' => 'timeGate#timeGate', 'url' => '/timegate/{url}', + ['name' => 'timeGate#singleUserTimeGate', 'url' => '/u/{userId}/timegate/{url}', 'requirements' => array('url' => '.+')], - ['name' => 'timeMap#timeMap', 'url' => '/timemap/{url}', + ['name' => 'timeGate#allUsersTimeGate', 'url' => '/timegate/{url}', + 'requirements' => array('url' => '.+')], + ['name' => 'timeMap#singleUserTimeMap', 'url' => '/u/{userId}/timemap/{url}', + 'requirements' => array('url' => '.+')], + ['name' => 'timeMap#allUsersTimeMap', 'url' => '/timemap/{url}', 'requirements' => array('url' => '.+')], ] ]; diff --git a/lib/Controller/TimeGateController.php b/lib/Controller/TimeGateController.php index 1657946..41f8e0b 100644 --- a/lib/Controller/TimeGateController.php +++ b/lib/Controller/TimeGateController.php @@ -6,37 +6,56 @@ require_once __DIR__ . '/datetimeConversion.php'; require_once __DIR__ . '/getUrlParameter.php'; use OCP\IRequest; -use OCP\IURLGenerator; use OCP\IServerContainer; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\DataDisplayResponse; class TimeGateController extends Controller { - private $userFolder; - private $URLGenerator; + use MementoFinder; + + private $loggedInUserId; + private $serverContainer; public function __construct( $AppName, IRequest $request, $UserId, - IServerContainer $serverContainer, - IURLGenerator $URLGenerator + IServerContainer $serverContainer ) { parent::__construct($AppName, $request); - $this->userFolder = $serverContainer->getUserFolder($UserId); - $this->URLGenerator = $URLGenerator; + $this->loggedInUserId = $UserId; + $this->serverContainer = $serverContainer; } /** * @NoAdminRequired * @NoCSRFRequired + * @PublicPage */ - public function timeGate($url) { - $url = getUrlParameter('timegate'); // XXX workaround, as nextcloud corrupts the $url parameter. + public function singleUserTimeGate($userId, $url) { + // XXX workaround, as nextcloud corrupts the $url parameter. + $url = getUrlParameter("u/$userId/timegate"); + + $matchingMementos = $this->findSingleUserMementosForUrl($userId, $url); + + return $this->makeResponse($url, $matchingMementos); + } - $matchingMementos = findMementos($this->userFolder, $url); + /** + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + */ + public function allUsersTimeGate($url) { + $url = getUrlParameter('timegate'); + + $matchingMementos = $this->findAllUsersMementosForUrl($url); + + return $this->makeResponse($url, $matchingMementos); + } + private function makeResponse($url, $matchingMementos) { // Choose one of the matched mementos, if any. if (count($matchingMementos) === 0) { // No matches. :( @@ -69,12 +88,17 @@ class TimeGateController extends Controller { // Send a 302 Found redirect pointing to the chosen memento. $response = new RedirectResponse($chosenMemento['mementoUrl']); $response->setStatus(302); - $response->addHeader('Vary', 'accept-datetime'); + // Both the requested datetime and the authenticated user influence the response. + $response->addHeader('Vary', 'accept-datetime, cookie'); - // Add a link to the original and to the timemap. - $originalLink = "<{$chosenMemento['originalUrl']}>;rel=\"original\""; + // Add a link to the original(s) and to the timemap. + $originalLinks = implode(", ", array_map( + function ($originalUrl) { return "<$originalUrl>;rel=\"original\""; }, + $chosenMemento['originalUrls'] + )); // XXX hardcoding the route URL. - $timeMapUrl = $this->URLGenerator->getAbsoluteUrl("/apps/memento/timemap/$url"); + $timeMapUrl = $this->serverContainer->getURLGenerator() + ->getAbsoluteUrl("/apps/memento/timemap/$url"); $firstDatetime = datetimeTimestampToString($matchingMementos[0]['datetime']); $lastMemento = $matchingMementos[count($matchingMementos)-1]; $lastDatetime = datetimeTimestampToString($lastMemento['datetime']); @@ -82,14 +106,13 @@ class TimeGateController extends Controller { . ";rel=\"timemap\"" . ";type=\"application/link-format\"" . ";from=\"$firstDatetime\";until=\"$lastDatetime\""; - $response->addHeader('Link', "$originalLink, $timeMapLink"); + $response->addHeader('Link', "$originalLinks, $timeMapLink"); return $response; } } function minBy($array, $iteratee) { - // is there any simpler way for this in php? $values = array_map($iteratee, $array); $argmin = array_search(min($values), $values); return $array[$argmin]; diff --git a/lib/Controller/TimeMapController.php b/lib/Controller/TimeMapController.php index 918a139..08bc8fc 100644 --- a/lib/Controller/TimeMapController.php +++ b/lib/Controller/TimeMapController.php @@ -12,36 +12,59 @@ use OCP\AppFramework\Controller; use OCP\AppFramework\Http\DataDisplayResponse; class TimeMapController extends Controller { - private $userFolder; - private $URLGenerator; + use MementoFinder; + + private $loggedInUserId; + private $serverContainer; public function __construct( $AppName, IRequest $request, $UserId, - IServerContainer $serverContainer, - IURLGenerator $URLGenerator + IServerContainer $serverContainer ) { parent::__construct($AppName, $request); - $this->userFolder = $serverContainer->getUserFolder($UserId); - $this->URLGenerator = $URLGenerator; + $this->loggedInUserId = $UserId; + $this->serverContainer = $serverContainer; } /** * @NoAdminRequired * @NoCSRFRequired + * @PublicPage */ - public function timeMap($url) { - $url = getUrlParameter('timemap'); // XXX workaround, as nextcloud corrupts the $url parameter. + public function singleUserTimeMap($userId, $url) { + // XXX workaround, as nextcloud corrupts the $url parameter. + $routePrefix = "u/$userId/"; + $url = getUrlParameter("{$routePrefix}timemap"); + + $matchingMementos = $this->findSingleUserMementosForUrl($userId, $url); + + return $this->makeResponse($url, $matchingMementos, $routePrefix); + } - $matchingMementos = findMementos($this->userFolder, $url); + /** + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + */ + public function allUsersTimeMap($url) { + $routePrefix = ""; + $url = getUrlParameter("{$routePrefix}timemap"); + + $matchingMementos = $this->findAllUsersMementosForUrl($url); + + return $this->makeResponse($url, $matchingMementos, $routePrefix); + } + private function makeResponse($url, $matchingMementos, $routePrefix) { // Build the list of links. // $timeMapUrl = $this->URLGenerator->linkToRouteAbsolute('timeMap#timeMap', [ 'url' => $url ]); // $timeGateUrl = $this->URLGenerator->linkToRouteAbsolute('timeGate#timeGate', [ 'url' => $url ]); // FIXME ...is linkToRouteAbsolute broken? Hardcoding the path then.. - $timeMapUrl = $this->URLGenerator->getAbsoluteUrl("/apps/memento/timemap/$url"); - $timeGateUrl = $this->URLGenerator->getAbsoluteUrl("/apps/memento/timegate/$url"); + $URLGenerator = $this->serverContainer->getURLGenerator(); + $timeMapUrl = $URLGenerator->getAbsoluteUrl("/apps/memento/{$routePrefix}timemap/$url"); + $timeGateUrl = $URLGenerator->getAbsoluteUrl("/apps/memento/{$routePrefix}timegate/$url"); if (count($matchingMementos) > 0) { $firstDatetime = datetimeTimestampToString($matchingMementos[0]['datetime']); $lastMemento = $matchingMementos[count($matchingMementos)-1]; @@ -58,7 +81,7 @@ class TimeMapController extends Controller { $maybeFirst = $index === 0 ? 'first ' : ''; $maybeLast = $index === count($matchingMementos)-1 ? 'last ' : ''; // Make absolute, as the spec says URLs are to be interpreted relative to the *original* url! - $absoluteMementoUrl = $this->URLGenerator->getAbsoluteURL($memento['mementoUrl']); + $absoluteMementoUrl = $URLGenerator->getAbsoluteURL($memento['mementoUrl']); $links[] = "<$absoluteMementoUrl>" . ";rel=\"{$maybeFirst}{$maybeLast}memento\"" . ";datetime=\"$datetime\""; diff --git a/lib/Controller/findMementos.php b/lib/Controller/findMementos.php index 4d3a2d6..d52df21 100644 --- a/lib/Controller/findMementos.php +++ b/lib/Controller/findMementos.php @@ -1,61 +1,172 @@ URL of the file, relative to the nextcloud instance -// 'originalUrl' => original URL, presumably equal to the given $url, except we normalise a bit +// 'originalUrls' => original URLs, usually just one. // 'datetime' => snapshot datetime as a unix timestamp // ] -function findMementos($folder, $url) { - // Get all HTML files the user owns. - $files = $folder->searchByMime('text/html'); +// +// Each mementoUrl is hardcoded to /apps/raw/..., thus relying on the 'raw' app to serve the files. + +trait MementoFinder { + function findSingleUserMementosForUrl($userId, $url) { + // Get the user's public mementos. + $foundMementos = findPublicMementos($this->serverContainer->getShareManager(), $userId); + + // If logged in, and asking for one's own mementos, get private mementos too. + if ($this->loggedInUserId === $userId) { + $userFolder = $this->serverContainer->getUserFolder($this->loggedInUserId); + $moreMementos = findPrivateMementos($userFolder); + $foundMementos = mergeMementos($foundMementos, $moreMementos); + } + + // Filter those that match the requested URL, and sort them. + $matchingMementos = filterMementosByUrl($foundMementos, $url); + sortMementos($matchingMementos); + return $matchingMementos; + } + + function findAllUsersMementosForUrl($url) { + $foundMementos = []; + + // Get the public mementos of every user. + $allUserIds = []; + $this->serverContainer->getUserManager()->callForAllUsers( + function ($user) use (&$allUserIds) { $allUserIds[] = $user->getUID(); } + ); + $shareManager = $this->serverContainer->getShareManager(); + foreach ($allUserIds as $userId) { + $moreMementos = findPublicMementos($shareManager, $userId); + $foundMementos = mergeMementos($foundMementos, $moreMementos); + } + + // If logged in, get current user's private mementos too. + if ($this->loggedInUserId) { + $userFolder = $this->serverContainer->getUserFolder($this->loggedInUserId); + $moreMementos = findPrivateMementos($userFolder); + $foundMementos = mergeMementos($foundMementos, $moreMementos); + } - // Filter them for pages that have a referring to the given URL. - $matchingMementos = array(); + // Filter those that match the requested URL, and sort them. + $matchingMementos = filterMementosByUrl($foundMementos, $url); + $matchingMementos = sortMementos($matchingMementos); + return $matchingMementos; + } +} + +function findPrivateMementos($folder) { + $urlForFile = function ($file) use ($folder) { + $absoluteFilePath = $file->getPath(); + $relativeFilePath = $folder->getRelativePath($absoluteFilePath); + $rawFileUrl = joinPaths("/apps/raw/files", $relativeFilePath); // XXX hardcoded dependency + return $rawFileUrl; + }; + + // Peek into each HTML file the user owns, and return those that are mementos. + $files = $folder->searchByMime('text/html'); + $foundMementos = []; foreach ($files as $file) { - $content = $file->getContent(); - try { - $DOM = new DOMDocument; - $DOM->loadHTML($content); - $headElement = $DOM->documentElement->getElementsByTagName('head')[0]; - $originalUrls = getOriginalUrls($headElement); - foreach ($originalUrls as $originalUrl) { - if (normaliseUrl($originalUrl) === normaliseUrl($url)) { - // Found a match! - // Read its datetime - $datetime = getDatetime($headElement); - // Construct its URL. - $absoluteFilePath = $file->getPath(); - $relativeFilePath = $folder->getRelativePath($absoluteFilePath); - $mementoUrl = joinPaths("/apps/raw/files", $relativeFilePath); // XXX hardcoded dependency - - $matchingMementos[] = [ - 'mementoUrl' => $mementoUrl, - 'originalUrl' => $originalUrl, - 'datetime' => $datetime - ]; - } + $mementoInfo = extractMementoInfo($file); + if ($mementoInfo) { + $mementoInfo['mementoUrl'] = $urlForFile($file); + $foundMementos[] = $mementoInfo; + } + } + return $foundMementos; +} + +function findPublicMementos($shareManager, $userId) { + $shares = $shareManager->getSharesBy( + $userId, + Share::SHARE_TYPE_LINK, + null, /* path */ + true, /* include reshares */ + -1 /* no limit */ + ); + + $urlForShare = function ($share) { + return "/apps/raw/s/" . $share->getToken(); // XXX hardcoded dependency + }; + + // Look into every shared file to see if it is a memento. + $foundMementos = []; + foreach ($shares as $share) { + $node = $share->getNode(); + if ($node->getType() === FileInfo::TYPE_FILE) { + $mementoInfo = extractMementoInfo($node); + if ($mementoInfo) { + $mementoInfo['mementoUrl'] = $urlForShare($share); + $foundMementos[] = $mementoInfo; } - } catch (Exception $e) { - continue; + } else { + // TODO add files inside shared folders? How to make URLs for those? } } + return $foundMementos; +} - // Sort mementos by their datetime. Oldest first. - usort($matchingMementos, function ($m1, $m2) { return $m1['datetime'] <=> $m2['datetime']; }); +function mergeMementos($mementos1, $mementos2) { + // TODO deduplicate (we'll get public & private URLs for the same files) + return array_merge($mementos1, $mementos2); +} +function filterMementosByUrl($mementos, $url) { + $matchingMementos = array_filter($mementos, function ($mementoInfo) use ($url) { + return matchesUrl($mementoInfo, $url); + }); return $matchingMementos; } +function matchesUrl($mementoInfo, $url) { + $originalUrls = $mementoInfo['originalUrls']; + foreach ($originalUrls as $originalUrl) { + if (normaliseUrl($originalUrl) === normaliseUrl($url)) { + return true; + } + } + return false; +} + +function normaliseUrl($url) { + // Ignore trailing slashes. Because everybody does. + $url = rtrim($url, '/'); + return $url; +} + +// Sort an array of mementos by their datetime. Oldest first. +function sortMementos($mementos) { + usort($mementos, function ($m1, $m2) { return $m1['datetime'] <=> $m2['datetime']; }); + return $mementos; +} + function joinPaths($piece1, $piece2) { $left = rtrim($piece1, '/'); $right = ltrim($piece2, '/'); return "$left/$right"; } +function extractMementoInfo($file) { + $content = $file->getContent(); + $DOM = new DOMDocument; + $DOM->loadHTML($content); + $headElement = $DOM->documentElement->getElementsByTagName('head')[0]; + if (!$headElement) return null; // possibly $content was not HTML at all. + $originalUrls = getOriginalUrls($headElement); + $datetime = getDatetime($headElement); + return [ + 'originalUrls' => $originalUrls, + 'datetime' => $datetime + ]; +} + // Reads hrefs from any with relation type "original". // (note the plural: we also accept pages that claim to correspond to multiple original URLs) function getOriginalUrls($headElement) { @@ -87,9 +198,3 @@ function getDatetime($headElement) { } return null; } - -function normaliseUrl($url) { - // Ignore trailing slashes. Because everybody does. - $url = rtrim($url, '/'); - return $url; -}