diff --git a/CHANGELOG.md b/CHANGELOG.md index 3769af6d..4fb51397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This file is manually updated. Please file an issue if something is missing. ## v4.10.0, v3.10.0 (unreleased) +- **Feature**: Allow sharing albums using public links - **Feature**: Allow sharing albums with groups - Fix folder share title and remove footer - Other minor fixes diff --git a/appinfo/routes.php b/appinfo/routes.php index e37edef4..19191649 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1,13 +1,15 @@ [ $param => '.*' ], - 'defaults' => [ $param => '' ] + 'requirements' => [$param => '.*'], + 'defaults' => [$param => ''] ]; } -function w($base, $param) { +function w($base, $param) +{ return array_merge($base, getWildcard($param)); } @@ -30,15 +32,18 @@ return [ // Public folder share ['name' => 'Public#showShare', 'url' => '/s/{token}', 'verb' => 'GET'], [ - 'name' => 'Public#showAuthenticate', - 'url' => '/s/{token}/authenticate/{redirect}', - 'verb' => 'GET', - ], - [ - 'name' => 'Public#authenticate', - 'url' => '/s/{token}/authenticate/{redirect}', - 'verb' => 'POST', - ], + 'name' => 'Public#showAuthenticate', + 'url' => '/s/{token}/authenticate/{redirect}', + 'verb' => 'GET', + ], + [ + 'name' => 'Public#authenticate', + 'url' => '/s/{token}/authenticate/{redirect}', + 'verb' => 'POST', + ], + + // Public album share + ['name' => 'PublicAlbum#showShare', 'url' => '/a/{token}', 'verb' => 'GET'], // API Routes ['name' => 'Days#days', 'url' => '/api/days', 'verb' => 'GET'], diff --git a/lib/Controller/ApiBase.php b/lib/Controller/ApiBase.php index f63811d3..42d05c2e 100644 --- a/lib/Controller/ApiBase.php +++ b/lib/Controller/ApiBase.php @@ -29,8 +29,6 @@ use OCA\Memories\Db\TimelineRoot; use OCA\Memories\Exif; use OCP\App\IAppManager; use OCP\AppFramework\Controller; -use OCP\AppFramework\Http; -use OCP\AppFramework\Http\JSONResponse; use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\IRootFolder; @@ -46,6 +44,7 @@ class ApiBase extends Controller protected IRootFolder $rootFolder; protected IAppManager $appManager; protected TimelineQuery $timelineQuery; + protected IDBConnection $connection; public function __construct( IRequest $request, @@ -65,14 +64,14 @@ class ApiBase extends Controller $this->timelineQuery = new TimelineQuery($connection); } - /** Get logged in user's UID or throw HTTP error */ + /** Get logged in user's UID or throw exception */ protected function getUID(): string { $user = $this->userSession->getUser(); if ($this->getShareToken()) { $user = null; } elseif (null === $user) { - return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + throw new \Exception('User not logged in'); } return $user ? $user->getUID() : ''; @@ -81,11 +80,17 @@ class ApiBase extends Controller /** Get the TimelineRoot object relevant to the request */ protected function getRequestRoot() { + $user = $this->userSession->getUser(); $root = new TimelineRoot(); // Albums have no folder - if ($this->request->getParam('album')) { - return $root; + if ($this->albumsIsEnabled() && $this->request->getParam('album')) { + if (null !== $user) { + return $root; + } + if (($token = $this->getShareToken()) && $this->timelineQuery->getAlbumByLink($token)) { + return $root; + } } // Public shared folder @@ -96,7 +101,6 @@ class ApiBase extends Controller } // Anything else needs a user - $user = $this->userSession->getUser(); if (null === $user) { throw new \Exception('User not logged in'); } @@ -143,7 +147,7 @@ class ApiBase extends Controller // Check both user folder and album return $this->getUserFolderFile($fileId) ?? - $this->getAlbumFile($fileId); + $this->getAlbumFile($fileId); } /** @@ -189,6 +193,24 @@ class ApiBase extends Controller protected function getShareFile(int $id): ?File { try { + // Album share + if ($this->request->getParam('album')) { + $album = $this->timelineQuery->getAlbumByLink($this->getShareToken()); + if (null === $album) { + return null; + } + + $owner = $this->timelineQuery->albumHasFile($album['album_id'], $id); + if (!$owner) { + return null; + } + + $folder = $this->rootFolder->getUserFolder($owner); + + return $this->getOneFileFromFolder($folder, $id); + } + + // Folder share if ($share = $this->getShareNode()) { return $this->getOneFileFromFolder($share, $id); } @@ -220,7 +242,7 @@ class ApiBase extends Controller protected function getShareToken() { - return $this->request->getParam('folder_share'); + return $this->request->getParam('token'); } protected function getShareObject() @@ -242,8 +264,10 @@ class ApiBase extends Controller $session = \OC::$server->get(\OCP\ISession::class); // https://github.com/nextcloud/server/blob/0447b53bda9fe95ea0cbed765aa332584605d652/lib/public/AppFramework/PublicShareController.php#L119 - if ($session->get('public_link_authenticated_token') !== $token - || $session->get('public_link_authenticated_password_hash') !== $password) { + if ( + $session->get('public_link_authenticated_token') !== $token + || $session->get('public_link_authenticated_password_hash') !== $password + ) { throw new \Exception('Share is password protected and user is not authenticated'); } } diff --git a/lib/Controller/DaysController.php b/lib/Controller/DaysController.php index 8f675071..765c98c0 100644 --- a/lib/Controller/DaysController.php +++ b/lib/Controller/DaysController.php @@ -39,7 +39,11 @@ class DaysController extends ApiBase public function days(): JSONResponse { // Get the folder to show - $uid = $this->getUID(); + try { + $uid = $this->getUID(); + } catch (\Exception $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_PRECONDITION_FAILED); + } // Get the folder to show $root = null; @@ -183,6 +187,13 @@ class DaysController extends ApiBase $transforms[] = [$this->timelineQuery, 'transformExtraFields', $fields]; } + // Filter for one album + if ($this->albumsIsEnabled()) { + if ($albumId = $this->request->getParam('album')) { + $transforms[] = [$this->timelineQuery, 'transformAlbumFilter', $albumId]; + } + } + // Other transforms not allowed for public shares if (null === $this->userSession->getUser()) { return $transforms; @@ -226,13 +237,6 @@ class DaysController extends ApiBase } } - // Filter for one album - if ($this->albumsIsEnabled()) { - if ($albumId = $this->request->getParam('album')) { - $transforms[] = [$this->timelineQuery, 'transformAlbumFilter', $albumId]; - } - } - // Limit number of responses for day query $limit = $this->request->getParam('limit'); if ($limit) { diff --git a/lib/Controller/PublicAlbumController.php b/lib/Controller/PublicAlbumController.php new file mode 100644 index 00000000..470959ae --- /dev/null +++ b/lib/Controller/PublicAlbumController.php @@ -0,0 +1,89 @@ +appName = $appName; + $this->eventDispatcher = $eventDispatcher; + $this->initialState = $initialState; + $this->appManager = $appManager; + $this->config = $config; + $this->connection = $connection; + } + + /** + * @PublicPage + * + * @NoCSRFRequired + */ + public function showShare(string $token): TemplateResponse + { + \OC_User::setIncognitoMode(true); + + // Validate token exists + $timelineQuery = new TimelineQuery($this->connection); + $album = $timelineQuery->getAlbumByLink($token); + if (!$album) { + return new TemplateResponse('core', '404', [], 'guest'); + } + + // Scripts + Util::addScript($this->appName, 'memories-main'); + $this->eventDispatcher->dispatchTyped(new LoadSidebar()); + + $this->initialState->provideInitialState('version', $this->appManager->getAppInfo('memories')['version']); + $this->initialState->provideInitialState('notranscode', $this->config->getSystemValue('memories.no_transcode', 'UNSET')); + + $policy = new ContentSecurityPolicy(); + $policy->addAllowedWorkerSrcDomain("'self'"); + $policy->addAllowedScriptDomain("'self'"); + + // Video player + $policy->addAllowedWorkerSrcDomain('blob:'); + $policy->addAllowedScriptDomain('blob:'); + $policy->addAllowedMediaDomain('blob:'); + + // Image editor + $policy->addAllowedConnectDomain('data:'); + + // Allow nominatim for metadata + $policy->addAllowedConnectDomain('nominatim.openstreetmap.org'); + $policy->addAllowedFrameDomain('www.openstreetmap.org'); + + $response = new PublicTemplateResponse($this->appName, 'main'); + $response->setHeaderTitle($album['name']); + $response->setFooterVisible(false); // wth is that anyway? + $response->setContentSecurityPolicy($policy); + + return $response; + } +} diff --git a/lib/Db/TimelineQueryAlbums.php b/lib/Db/TimelineQueryAlbums.php index 3fed6940..b9e31a46 100644 --- a/lib/Db/TimelineQueryAlbums.php +++ b/lib/Db/TimelineQueryAlbums.php @@ -117,6 +117,24 @@ trait TimelineQueryAlbums return $dayIds; } + /** + * Check if an album has a file. + * + * @return bool|string owner of file + */ + public function albumHasFile(int $albumId, int $fileId) + { + $query = $this->connection->getQueryBuilder(); + $query->select('owner')->from('photos_albums_files')->where( + $query->expr()->andX( + $query->expr()->eq('file_id', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)), + $query->expr()->eq('album_id', $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT)), + ) + ); + + return $query->executeQuery()->fetchOne(); + } + /** * Check if a file belongs to a user through an album. * @@ -159,25 +177,28 @@ trait TimelineQueryAlbums */ public function getAlbumIfAllowed(string $uid, string $albumId) { + $album = null; + // Split name and uid $parts = explode('/', $albumId); - if (2 !== \count($parts)) { - return null; - } - $albumUid = $parts[0]; - $albumName = $parts[1]; + if (2 === \count($parts)) { + $albumUid = $parts[0]; + $albumName = $parts[1]; - // Check if owner - $query = $this->connection->getQueryBuilder(); - $query->select('*')->from('photos_albums')->where( - $query->expr()->andX( - $query->expr()->eq('name', $query->createNamedParameter($albumName)), - $query->expr()->eq('user', $query->createNamedParameter($albumUid)), - ) - ); - $album = $query->executeQuery()->fetch(); + // Check if owner + $query = $this->connection->getQueryBuilder(); + $query->select('*')->from('photos_albums')->where( + $query->expr()->andX( + $query->expr()->eq('name', $query->createNamedParameter($albumName)), + $query->expr()->eq('user', $query->createNamedParameter($albumUid)), + ) + ); + $album = $query->executeQuery()->fetch(); + } + + // Album not found: it could be a link token at best if (!$album) { - return null; + return $this->getAlbumByLink($albumId); } // Check if user is owner @@ -200,6 +221,24 @@ trait TimelineQueryAlbums } } + /** + * Get album object by token. + * Returns false if album link does not exist. + */ + public function getAlbumByLink(string $token) + { + $query = $this->connection->getQueryBuilder(); + $query->select('*')->from('photos_albums', 'pa') + ->innerJoin('pa', $this->collaboratorsTable(), 'pc', $query->expr()->andX( + $query->expr()->eq('pc.album_id', 'pa.album_id'), + $query->expr()->eq('collaborator_id', $query->createNamedParameter($token)), + $query->expr()->eq('collaborator_type', $query->createNamedParameter(3)), // = TYPE_LINK + )) + ; + + return $query->executeQuery()->fetch() ?: null; + } + /** * Get full list of fileIds in album. */ diff --git a/src/App.vue b/src/App.vue index a404ad12..519d2ae2 100644 --- a/src/App.vue +++ b/src/App.vue @@ -140,7 +140,7 @@ export default defineComponent({ }, showNavigation(): boolean { - return this.$route.name !== "folder-share"; + return !this.$route.name?.endsWith("-share"); }, }, @@ -286,12 +286,12 @@ export default defineComponent({ }, doRouteChecks() { - if (this.$route.name === "folder-share") { - this.putFolderShareToken(this.$route.params.token); + if (this.$route.name.endsWith("-share")) { + this.putShareToken(this.$route.params.token); } }, - putFolderShareToken(token: string) { + putShareToken(token: string) { // Viewer looks for an input with ID sharingToken with the value as the token // Create this element or update it otherwise files not gonna open // https://github.com/nextcloud/viewer/blob/a8c46050fb687dcbb48a022a15a5d1275bf54a8e/src/utils/davUtils.js#L61 diff --git a/src/components/SelectionManager.vue b/src/components/SelectionManager.vue index 4e7127c1..5bd481a6 100644 --- a/src/components/SelectionManager.vue +++ b/src/components/SelectionManager.vue @@ -264,7 +264,7 @@ export default defineComponent({ /** Public route that can't modify anything */ routeIsPublic() { - return this.$route.name === "folder-share"; + return this.$route.name?.endsWith("-share"); }, /** Trigger to update props from selection set */ diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 1dd7fe07..8972b387 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -266,7 +266,9 @@ export default defineComponent({ return this.$route.name === "archive"; }, isMonthView(): boolean { - return this.$route.name === "albums"; + return ( + this.$route.name === "albums" || this.$route.name === "album-share" + ); }, /** Get view name for dynamic top matter */ viewName(): string { @@ -1408,4 +1410,4 @@ export default defineComponent({ } } } - \ No newline at end of file + diff --git a/src/components/modal/AlbumCollaborators.vue b/src/components/modal/AlbumCollaborators.vue index e71bf20a..9a548ee2 100644 --- a/src/components/modal/AlbumCollaborators.vue +++ b/src/components/modal/AlbumCollaborators.vue @@ -421,7 +421,7 @@ export default defineComponent({ async copyPublicLink() { await navigator.clipboard.writeText( `${window.location.protocol}//${window.location.host}${generateUrl( - `apps/photos/public/${this.publicLink.id}` + `apps/memories/a/${this.publicLink.id}` )}` ); this.publicLinkCopied = true; @@ -553,4 +553,4 @@ export default defineComponent({ } } } - \ No newline at end of file + diff --git a/src/components/modal/AlbumShareModal.vue b/src/components/modal/AlbumShareModal.vue index 3a2387ea..1efd0bb3 100644 --- a/src/components/modal/AlbumShareModal.vue +++ b/src/components/modal/AlbumShareModal.vue @@ -9,6 +9,7 @@ :album-name="album.basename" :collaborators="album.collaborators" :public-link="album.publicLink" + :allow-public-link="true" v-slot="{ collaborators }" > \ No newline at end of file + diff --git a/src/components/viewer/Viewer.vue b/src/components/viewer/Viewer.vue index 28b0f463..a0fa90e5 100644 --- a/src/components/viewer/Viewer.vue +++ b/src/components/viewer/Viewer.vue @@ -283,7 +283,7 @@ export default defineComponent({ /** Route is public */ routeIsPublic(): boolean { - return this.$route.name === "folder-share"; + return this.$route.name?.endsWith("-share"); }, /** Route is album */ diff --git a/src/router.ts b/src/router.ts index 0c7b7579..12c6c784 100644 --- a/src/router.ts +++ b/src/router.ts @@ -120,5 +120,14 @@ export default new Router({ rootTitle: t("memories", "Shared Folder"), }), }, + + { + path: "/a/:token", + component: Timeline, + name: "album-share", + props: (route) => ({ + rootTitle: t("memories", "Shared Album"), + }), + }, ], }); diff --git a/src/services/API.ts b/src/services/API.ts index e13854eb..d389ab86 100644 --- a/src/services/API.ts +++ b/src/services/API.ts @@ -9,7 +9,10 @@ function tok(url: string) { const route = vueroute(); if (route.name === "folder-share") { const token = route.params.token; - url = API.Q(url, `folder_share=${token}`); + url = API.Q(url, `token=${token}`); + } else if (route.name === "album-share") { + const token = route.params.token; + url = API.Q(url, `token=${token}&album=${token}`); } return url; }