Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/photos.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLouis Chemineau <louis@chmn.me>2022-09-21 17:50:28 +0300
committerLouis Chemineau <louis@chmn.me>2022-10-20 12:54:08 +0300
commit3d024beb7ad95e2d8d8641c5e5b4586c57f3625d (patch)
tree33f1184a97f0b905f7893d268646d9cbc9dccd95
parent77674859dcb98a2809eafd785f7b8641960cafe8 (diff)
Add public link logic
Signed-off-by: Louis Chemineau <louis@chmn.me>
-rw-r--r--appinfo/routes.php12
-rw-r--r--lib/Album/AlbumMapper.php26
-rw-r--r--lib/Controller/PublicAlbumController.php123
-rw-r--r--lib/Sabre/Album/AlbumPhoto.php6
-rw-r--r--lib/Sabre/Album/AlbumRoot.php11
-rw-r--r--lib/Sabre/Album/PropFindPlugin.php24
-rw-r--r--lib/Sabre/Album/PublicAlbumRoot.php95
-rw-r--r--lib/Sabre/Album/PublicAlbumsHome.php73
-rw-r--r--lib/Sabre/Album/SharedAlbumRoot.php5
-rw-r--r--lib/Sabre/Album/SharedAlbumsHome.php4
-rw-r--r--lib/Sabre/PhotosHome.php5
-rw-r--r--lib/Service/UserConfigService.php9
-rw-r--r--src/PhotosPublic.vue102
-rw-r--r--src/components/Albums/CollaboratorsSelectionForm.vue160
-rw-r--r--src/components/Collection/CollectionContent.vue6
-rw-r--r--src/components/HeaderNavigation.vue4
-rw-r--r--src/mixins/FetchAlbumsMixin.js75
-rw-r--r--src/mixins/FetchSharedAlbumsMixin.js63
-rw-r--r--src/public.js67
-rw-r--r--src/router/index.js10
-rw-r--r--src/services/Albums.js168
-rw-r--r--src/store/collectionStoreFactory.js214
-rw-r--r--src/store/index.js2
-rw-r--r--src/store/publicAlbums.js213
-rw-r--r--src/views/AlbumContent.vue4
-rw-r--r--src/views/Albums.vue6
-rw-r--r--src/views/PublicAlbumContent.vue324
-rw-r--r--src/views/SharedAlbumContent.vue8
-rw-r--r--src/views/SharedAlbums.vue10
-rw-r--r--webpack.js7
30 files changed, 1592 insertions, 244 deletions
diff --git a/appinfo/routes.php b/appinfo/routes.php
index d290fdb0..2389fc2d 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -46,6 +46,14 @@ return [
'path' => '',
]
],
+ [ 'name' => 'publicAlbum#get', 'url' => '/public/{ownerId}/{token}', 'verb' => 'GET',
+ 'requirements' => [
+ 'ownerId' => '.*',
+ ],
+ 'requirements' => [
+ 'token' => '.*',
+ ],
+ ],
['name' => 'page#index', 'url' => '/folders/{path}', 'verb' => 'GET', 'postfix' => 'folders',
'requirements' => [
'path' => '.*',
@@ -74,10 +82,8 @@ return [
'requirements' => [
'path' => '.*',
],
- 'defaults' => [
- 'path' => '',
- ]
],
+ [ 'name' => 'public#get', 'url' => '/display/{token}', 'verb' => 'GET' ],
['name' => 'page#index', 'url' => '/tags/{path}', 'verb' => 'GET', 'postfix' => 'tags',
'requirements' => [
'path' => '.*',
diff --git a/lib/Album/AlbumMapper.php b/lib/Album/AlbumMapper.php
index a38ee2a5..577e0064 100644
--- a/lib/Album/AlbumMapper.php
+++ b/lib/Album/AlbumMapper.php
@@ -33,6 +33,7 @@ use OCP\IGroup;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IGroupManager;
+use OCP\IL10N;
class AlbumMapper {
private IDBConnection $connection;
@@ -40,6 +41,7 @@ class AlbumMapper {
private ITimeFactory $timeFactory;
private IUserManager $userManager;
private IGroupManager $groupManager;
+ protected IL10N $l;
// Same mapping as IShare.
public const TYPE_USER = 0;
@@ -51,13 +53,15 @@ class AlbumMapper {
IMimeTypeLoader $mimeTypeLoader,
ITimeFactory $timeFactory,
IUserManager $userManager,
- IGroupManager $groupManager
+ IGroupManager $groupManager,
+ IL10N $l
) {
$this->connection = $connection;
$this->mimeTypeLoader = $mimeTypeLoader;
$this->timeFactory = $timeFactory;
$this->userManager = $userManager;
$this->groupManager = $groupManager;
+ $this->l = $l;
}
public function create(string $userId, string $name, string $location = ""): AlbumInfo {
@@ -147,12 +151,13 @@ class AlbumMapper {
$query->executeStatement();
}
- public function setLocation(int $id, string $newLocation): void {
+ public function setLocation(int $id, string $newLocation): string {
$query = $this->connection->getQueryBuilder();
$query->update("photos_albums")
->set("location", $query->createNamedParameter($newLocation))
->where($query->expr()->eq('album_id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
$query->executeStatement();
+ return $newLocation;
}
public function delete(int $id): void {
@@ -294,26 +299,29 @@ class AlbumMapper {
$collaborators = array_map(function (array $row) {
/** @var IUser|IGroup|null */
- $collaborator = null;
+ $displayName = null;
switch ($row['collaborator_type']) {
case self::TYPE_USER:
- $collaborator = $this->userManager->get($row['collaborator_id']);
+ $displayName = $this->userManager->get($row['collaborator_id'])->getDisplayName();
break;
case self::TYPE_GROUP:
- $collaborator = $this->groupManager->get($row['collaborator_id']);
+ $displayName = $this->groupManager->get($row['collaborator_id'])->getDisplayName();
+ break;
+ case self::TYPE_LINK:
+ $displayName = $this->l->t('Public link');;
break;
default:
throw new \Exception('Invalid collaborator type: ' . $row['collaborator_type']);
}
- if (is_null($collaborator)) {
+ if (is_null($displayName)) {
return null;
}
return [
'id' => $row['collaborator_id'],
- 'label' => $collaborator->getDisplayName(),
+ 'label' => $displayName,
'type' => $row['collaborator_type'],
];
}, $rows);
@@ -345,6 +353,8 @@ class AlbumMapper {
throw new \Exception('Unknown collaborator: ' . $collaborator['id']);
}
break;
+ case self::TYPE_LINK:
+ break;
default:
throw new \Exception('Invalid collaborator type: ' . $collaborator['type']);
}
@@ -397,7 +407,7 @@ class AlbumMapper {
if ($row['fileid']) {
$mimeId = $row['mimetype'];
$mimeType = $this->mimeTypeLoader->getMimetypeById($mimeId);
- $filesByAlbum[$albumId][] = new AlbumFile((int)$row['fileid'], $row['album_name'].' ('.$row['album_user'].')', $mimeType, (int)$row['size'], (int)$row['mtime'], $row['etag'], (int)$row['added'], $row['owner']);
+ $filesByAlbum[$albumId][] = new AlbumFile((int)$row['fileid'], $row['file_name'], $mimeType, (int)$row['size'], (int)$row['mtime'], $row['etag'], (int)$row['added'], $row['owner']);
}
if (!isset($albumsById[$albumId])) {
diff --git a/lib/Controller/PublicAlbumController.php b/lib/Controller/PublicAlbumController.php
new file mode 100644
index 00000000..b9a9364e
--- /dev/null
+++ b/lib/Controller/PublicAlbumController.php
@@ -0,0 +1,123 @@
+<?php
+
+/**
+ * @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
+ *
+ * @author Louis Chemineau <louis@chmn.me>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Photos\Controller;
+
+use OCP\AppFramework\PublicShareController;
+use OCA\Files\Event\LoadSidebar;
+use OCA\Photos\AppInfo\Application;
+use OCA\Photos\Service\UserConfigService;
+use OCA\Viewer\Event\LoadViewer;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Http\ContentSecurityPolicy;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\AppFramework\Services\IInitialState;
+use OCP\IRequest;
+use OCP\ISession;
+use OCP\Util;
+
+
+class PublicAlbumController extends PublicShareController {
+ private IAppManager $appManager;
+ private IEventDispatcher $eventDispatcher;
+ private UserConfigService $userConfig;
+ private IInitialState $initialState;
+
+ public function __construct(
+ IRequest $request,
+ ISession $session,
+ IAppManager $appManager,
+ IEventDispatcher $eventDispatcher,
+ UserConfigService $userConfig,
+ IInitialState $initialState,
+ ) {
+ parent::__construct(Application::APP_ID, $request, $session);
+
+ $this->appManager = $appManager;
+ $this->eventDispatcher = $eventDispatcher;
+ $this->userConfig = $userConfig;
+ $this->initialState = $initialState;
+ }
+
+ /**
+ * Validate the token of this share. If the token is invalid this controller
+ * will return a 404.
+ */
+ public function isValidToken(): bool {
+ // TODO: uncomment
+ // $album = $this->albumMapper->getAlbumForToken($this->getToken);
+ // return $album !== null;
+ return true;
+ }
+
+ public function getPasswordHash(): string {
+ return '';
+ }
+
+ /**
+ * Allows you to specify if this share is password protected
+ */
+ protected function isPasswordProtected(): bool {
+ return false;
+ }
+
+ /**
+ * Your normal controller function. The following annotation will allow guests
+ * to open the page as well
+ *
+ * @PublicPage
+ * @NoAdminRequired
+ * @NoCSRFRequired
+ *
+ * @return TemplateResponse
+ */
+ public function get(): TemplateResponse {
+ $this->eventDispatcher->dispatch(LoadSidebar::class, new LoadSidebar());
+ $this->eventDispatcher->dispatch(LoadViewer::class, new LoadViewer());
+
+ $this->initialState->provideInitialState('image-mimes', Application::IMAGE_MIMES);
+ $this->initialState->provideInitialState('video-mimes', Application::VIDEO_MIMES);
+ $this->initialState->provideInitialState('maps', $this->appManager->isEnabledForUser('maps') === true);
+ $this->initialState->provideInitialState('recognize', $this->appManager->isEnabledForUser('recognize') === true);
+ $this->initialState->provideInitialState('systemtags', $this->appManager->isEnabledForUser('systemtags') === true);
+
+ // Provide user config
+ foreach (array_keys(UserConfigService::DEFAULT_CONFIGS) as $key) {
+ $this->initialState->provideInitialState($key, $this->userConfig->getUserConfig($key));
+ }
+
+ Util::addScript(Application::APP_ID, 'photos-public');
+ Util::addStyle(Application::APP_ID, 'icons');
+
+ $response = new TemplateResponse(Application::APP_ID, 'main');
+
+ $policy = new ContentSecurityPolicy();
+ $policy->addAllowedWorkerSrcDomain("'self'");
+ $policy->addAllowedScriptDomain("'self'");
+ $response->setContentSecurityPolicy($policy);
+
+ return $response;
+ }
+} \ No newline at end of file
diff --git a/lib/Sabre/Album/AlbumPhoto.php b/lib/Sabre/Album/AlbumPhoto.php
index a5e51bff..24ab0c41 100644
--- a/lib/Sabre/Album/AlbumPhoto.php
+++ b/lib/Sabre/Album/AlbumPhoto.php
@@ -152,9 +152,11 @@ class AlbumPhoto implements IFile {
switch ($favoriteState) {
case "0":
- return $tagger->removeFromFavorites($this->albumFile->getFileId());
+ $tagger->removeFromFavorites($this->albumFile->getFileId());
+ return "0";
case "1":
- return $tagger->addToFavorites($this->albumFile->getFileId());
+ $tagger->addToFavorites($this->albumFile->getFileId());
+ return "1";
default:
new \Exception('Favorite state is invalide, should be 0 or 1.');
}
diff --git a/lib/Sabre/Album/AlbumRoot.php b/lib/Sabre/Album/AlbumRoot.php
index 4c42f799..e2fe6c9f 100644
--- a/lib/Sabre/Album/AlbumRoot.php
+++ b/lib/Sabre/Album/AlbumRoot.php
@@ -218,7 +218,7 @@ class AlbumRoot implements ICollection, ICopyTarget {
}
/**
- * @return array
+ * @return array{'id': string, 'label': string, 'type': int}
*/
public function getCollaborators() {
return array_map(
@@ -226,4 +226,13 @@ class AlbumRoot implements ICollection, ICopyTarget {
$this->albumMapper->getCollaborators($this->album->getAlbum()->getId()),
);
}
+
+ /**
+ * @param array{'id': string, 'type': int} $collaborators
+ * @return array{'id': string, 'label': string, 'type': int}
+ */
+ public function setCollaborators($collaborators) {
+ $this->albumMapper->setCollaborators($this->getAlbum()->getAlbum()->getId(), $collaborators);
+ return $this->getCollaborators();
+ }
}
diff --git a/lib/Sabre/Album/PropFindPlugin.php b/lib/Sabre/Album/PropFindPlugin.php
index 46d1c225..00fb8017 100644
--- a/lib/Sabre/Album/PropFindPlugin.php
+++ b/lib/Sabre/Album/PropFindPlugin.php
@@ -78,7 +78,7 @@ class PropFindPlugin extends ServerPlugin {
public function propFind(PropFind $propFind, INode $node): void {
if ($node instanceof AlbumPhoto) {
- // Checking if the node is trulely available and ignoring if not
+ // Checking if the node is truly available and ignoring if not
// Should be pre-emptively handled by the NodeDeletedEvent
try {
$fileInfo = $node->getFileInfo();
@@ -87,12 +87,10 @@ class PropFindPlugin extends ServerPlugin {
}
$propFind->handle(FilesPlugin::INTERNAL_FILEID_PROPERTYNAME, fn () => $node->getFile()->getFileId());
- $propFind->handle(FilesPlugin::GETETAG_PROPERTYNAME, fn () => $node->getETag());
- $propFind->handle(self::FILE_NAME_PROPERTYNAME, fn () => $node->getFile()->getName());
- $propFind->handle(self::FAVORITE_PROPERTYNAME, fn () => $node->isFavorite() ? 1 : 0);
- $propFind->handle(FilesPlugin::HAS_PREVIEW_PROPERTYNAME, function () use ($fileInfo) {
- return json_encode($this->previewManager->isAvailable($fileInfo));
- });
+ $propFind->handle(FilesPlugin::GETETAG_PROPERTYNAME, fn () => $node->getETag());
+ $propFind->handle(self::FILE_NAME_PROPERTYNAME, fn () => $node->getFile()->getName());
+ $propFind->handle(self::FAVORITE_PROPERTYNAME, fn () => $node->isFavorite() ? 1 : 0);
+ $propFind->handle(FilesPlugin::HAS_PREVIEW_PROPERTYNAME, fn () => json_encode($this->previewManager->isAvailable($fileInfo)));
if ($this->metadataEnabled) {
$propFind->handle(FilesPlugin::FILE_METADATA_SIZE, function () use ($node) {
@@ -111,11 +109,11 @@ class PropFindPlugin extends ServerPlugin {
}
}
- if ($node instanceof AlbumRoot || $node instanceof SharedAlbumRoot) {
- $propFind->handle(self::LAST_PHOTO_PROPERTYNAME, fn () => $node->getAlbum()->getAlbum()->getLastAddedPhoto());
- $propFind->handle(self::NBITEMS_PROPERTYNAME, fn () => count($node->getChildren()));
- $propFind->handle(self::LOCATION_PROPERTYNAME, fn () => $node->getAlbum()->getAlbum()->getLocation());
- $propFind->handle(self::DATE_RANGE_PROPERTYNAME, fn () => json_encode($node->getDateRange()));
+ if ($node instanceof AlbumRoot) {
+ $propFind->handle(self::LAST_PHOTO_PROPERTYNAME, fn () => $node->getAlbum()->getAlbum()->getLastAddedPhoto());
+ $propFind->handle(self::NBITEMS_PROPERTYNAME, fn () => count($node->getChildren()));
+ $propFind->handle(self::LOCATION_PROPERTYNAME, fn () => $node->getAlbum()->getAlbum()->getLocation());
+ $propFind->handle(self::DATE_RANGE_PROPERTYNAME, fn () => json_encode($node->getDateRange()));
$propFind->handle(self::COLLABORATORS_PROPERTYNAME, fn () => $node->getCollaborators());
// TODO detect dynamically which metadata groups are requested and
@@ -141,7 +139,7 @@ class PropFindPlugin extends ServerPlugin {
return true;
});
$propPatch->handle(self::COLLABORATORS_PROPERTYNAME, function ($collaborators) use ($node) {
- $this->albumMapper->setCollaborators($node->getAlbum()->getAlbum()->getId(), json_decode($collaborators, true));
+ $collaborators = $node->setCollaborators(json_decode($collaborators, true));
return true;
});
}
diff --git a/lib/Sabre/Album/PublicAlbumRoot.php b/lib/Sabre/Album/PublicAlbumRoot.php
new file mode 100644
index 00000000..8ca2e1e6
--- /dev/null
+++ b/lib/Sabre/Album/PublicAlbumRoot.php
@@ -0,0 +1,95 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Photos\Sabre\Album;
+
+use Sabre\DAV\Exception\Forbidden;
+use Sabre\DAV\Exception\Conflict;
+use OCP\Files\Folder;
+
+class PublicAlbumRoot extends AlbumRoot {
+ /**
+ * @return void
+ */
+ public function delete() {
+ throw new Forbidden('Not allowed to delete a public album');
+ }
+
+ /**
+ * @return void
+ */
+ public function setName($name) {
+ throw new Forbidden('Not allowed to rename a public album');
+ }
+
+ // TODO: uncomment else it is a security hole.
+ // public function copyInto($targetName, $sourcePath, INode $sourceNode): bool {
+ // throw new Forbidden('Not allowed to copy into a public album');
+ // }
+
+ /**
+ * We cannot create files in an Album
+ * We add the file to the default Photos folder and then link it there.
+ *
+ * @param [type] $name
+ * @param [type] $data
+ * @return void
+ */
+ public function createFile($name, $data = null) {
+ try {
+ $albumOwner = $this->album->getAlbum()->getUserId();
+ $photosLocation = $this->userConfigService->getConfigForUser($albumOwner, 'photosLocation');
+ $photosFolder = $this->rootFolder->getUserFolder($albumOwner)->get($photosLocation);
+ if (!($photosFolder instanceof Folder)) {
+ throw new Conflict('The destination exists and is not a folder');
+ }
+
+ // Check for conflict and rename the file accordingly
+ $newName = \basename(\OC_Helper::buildNotExistingFileName($photosLocation, $name));
+
+ $node = $photosFolder->newFile($newName, $data);
+ $this->addFile($node->getId(), $node->getOwner()->getUID());
+ // Cheating with header because we are using fileID-fileName
+ // https://github.com/nextcloud/server/blob/af29b978078ffd9169a9bd9146feccbb7974c900/apps/dav/lib/Connector/Sabre/FilesPlugin.php#L564-L585
+ \header('OC-FileId: ' . $node->getId());
+ return '"' . $node->getEtag() . '"';
+ } catch (\Exception $e) {
+ throw new Forbidden('Could not create file');
+ }
+ }
+
+
+ protected function addFile(int $sourceId, string $ownerUID): bool {
+ if (in_array($sourceId, $this->album->getFileIds())) {
+ throw new Conflict("File $sourceId is already in the folder");
+ }
+
+ $this->albumMapper->addFile($this->album->getAlbum()->getId(), $sourceId, $ownerUID);
+ return true;
+ }
+
+ // Do not reveal collaborators for public albums.
+ public function getCollaborators() {
+ return [];
+ }
+}
diff --git a/lib/Sabre/Album/PublicAlbumsHome.php b/lib/Sabre/Album/PublicAlbumsHome.php
new file mode 100644
index 00000000..9d7d7362
--- /dev/null
+++ b/lib/Sabre/Album/PublicAlbumsHome.php
@@ -0,0 +1,73 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Photos\Sabre\Album;
+
+use Sabre\DAV\Exception\Forbidden;
+use Sabre\DAV\Exception\NotFound;
+use OCP\Files\IRootFolder;
+use OCP\IUser;
+use OCA\Photos\Sabre\Album\PublicAlbumRoot;
+use OCA\Photos\Service\UserConfigService;
+use OCA\Photos\Album\AlbumMapper;
+
+class PublicAlbumsHome extends AlbumsHome {
+ public function __construct(
+ array $principalInfo,
+ AlbumMapper $albumMapper,
+ IUser $user,
+ IRootFolder $rootFolder,
+ UserConfigService $userConfigService,
+ ) {
+ parent::__construct(
+ $principalInfo,
+ $albumMapper,
+ $user,
+ $rootFolder,
+ $userConfigService,
+ );
+ }
+
+ public function getName(): string {
+ return 'public';
+ }
+
+ /**
+ * @return never
+ */
+ public function createDirectory($name) {
+ throw new Forbidden('Not allowed to create folders in this folder');
+ }
+
+ public function getChild($name) {
+ $albums = $this->albumMapper->getSharedAlbumsForCollaboratorWithFiles($name, AlbumMapper::TYPE_LINK);
+
+ array_filter($albums, fn ($album) => $album->getAlbum()->getUserId() === $this->user->getUid());
+
+ if (count($albums) !== 1) {
+ throw new NotFound();
+ }
+
+ return new PublicAlbumRoot($this->albumMapper, $albums[0], $this->rootFolder, $this->userFolder, $this->user, $this->userConfigService);
+ }
+}
diff --git a/lib/Sabre/Album/SharedAlbumRoot.php b/lib/Sabre/Album/SharedAlbumRoot.php
index 26a2272e..607a009f 100644
--- a/lib/Sabre/Album/SharedAlbumRoot.php
+++ b/lib/Sabre/Album/SharedAlbumRoot.php
@@ -59,4 +59,9 @@ class SharedAlbumRoot extends AlbumRoot {
$this->albumMapper->addFile($this->album->getAlbum()->getId(), $sourceId, $ownerUID);
return true;
}
+
+ // Do not reveal collaborators for shared albums.
+ public function getCollaborators() {
+ return [];
+ }
}
diff --git a/lib/Sabre/Album/SharedAlbumsHome.php b/lib/Sabre/Album/SharedAlbumsHome.php
index 89a0dd51..799ea15d 100644
--- a/lib/Sabre/Album/SharedAlbumsHome.php
+++ b/lib/Sabre/Album/SharedAlbumsHome.php
@@ -41,7 +41,6 @@ class SharedAlbumsHome extends AlbumsHome {
IRootFolder $rootFolder,
IGroupManager $groupManager,
UserConfigService $userConfigService
-
) {
parent::__construct(
$principalInfo,
@@ -66,7 +65,7 @@ class SharedAlbumsHome extends AlbumsHome {
}
/**
- * @return AlbumRoot[]
+ * @return SharedAlbumRoot[]
*/
public function getChildren(): array {
if ($this->children === null) {
@@ -82,7 +81,6 @@ class SharedAlbumsHome extends AlbumsHome {
$this->children = array_map(function (AlbumWithFiles $folder) {
return new SharedAlbumRoot($this->albumMapper, $folder, $this->rootFolder, $this->userFolder, $this->user, $this->userConfigService);
}, $albums);
- ;
}
return $this->children;
diff --git a/lib/Sabre/PhotosHome.php b/lib/Sabre/PhotosHome.php
index 668983fb..ba4d266e 100644
--- a/lib/Sabre/PhotosHome.php
+++ b/lib/Sabre/PhotosHome.php
@@ -26,6 +26,7 @@ namespace OCA\Photos\Sabre;
use OCA\Photos\Album\AlbumMapper;
use OCA\Photos\Sabre\Album\AlbumsHome;
use OCA\Photos\Sabre\Album\SharedAlbumsHome;
+use OCA\Photos\Sabre\Album\PublicAlbumsHome;
use OCA\Photos\Service\UserConfigService;
use OCP\Files\IRootFolder;
use OCP\IUser;
@@ -93,13 +94,15 @@ class PhotosHome implements ICollection {
return new AlbumsHome($this->principalInfo, $this->albumMapper, $this->user, $this->rootFolder, $this->userConfigService);
} elseif ($name === 'sharedalbums') {
return new SharedAlbumsHome($this->principalInfo, $this->albumMapper, $this->user, $this->rootFolder, $this->groupManager, $this->userConfigService);
+ } elseif ($name === 'public') {
+ return new PublicAlbumsHome($this->principalInfo, $this->albumMapper, $this->user, $this->rootFolder, $this->userConfigService);
}
throw new NotFound();
}
/**
- * @return (AlbumsHome|SharedAlbumsHome)[]
+ * @return (AlbumsHome|SharedAlbumsHome|PublicAlbumHome)[]
*/
public function getChildren(): array {
return [new AlbumsHome($this->principalInfo, $this->albumMapper, $this->user, $this->rootFolder, $this->userConfigService)];
diff --git a/lib/Service/UserConfigService.php b/lib/Service/UserConfigService.php
index 58292081..cf717d83 100644
--- a/lib/Service/UserConfigService.php
+++ b/lib/Service/UserConfigService.php
@@ -48,13 +48,16 @@ class UserConfigService {
}
public function getUserConfig(string $key) {
+ $user = $this->userSession->getUser();
+ return $this->getConfigForUser($user->getUid(), $key);
+ }
+
+ public function getConfigForUser(string $userId, string $key) {
if (!in_array($key, array_keys(self::DEFAULT_CONFIGS))) {
throw new Exception('Unknown user config key');
}
-
- $user = $this->userSession->getUser();
$default = self::DEFAULT_CONFIGS[$key];
- $value = $this->config->getUserValue($user->getUid(), Application::APP_ID, $key, $default);
+ $value = $this->config->getUserValue($userId, Application::APP_ID, $key, $default);
return $value;
}
diff --git a/src/PhotosPublic.vue b/src/PhotosPublic.vue
new file mode 100644
index 00000000..18d008ef
--- /dev/null
+++ b/src/PhotosPublic.vue
@@ -0,0 +1,102 @@
+<!--
+ - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license AGPL-3.0-or-later
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+
+<template>
+ <NcContent app-name="photos">
+ <NcAppContent>
+ <router-view />
+
+ <!-- svg img loading placeholder (linked to the File component) -->
+ <!-- eslint-disable-next-line vue/no-v-html (because it's an SVG file) -->
+ <span class="hidden-visually" role="none" v-html="svgplaceholder" />
+ <!-- eslint-disable-next-line vue/no-v-html (because it's an SVG file) -->
+ <span class="hidden-visually" role="none" v-html="imgplaceholder" />
+ <!-- eslint-disable-next-line vue/no-v-html (because it's an SVG file) -->
+ <span class="hidden-visually" role="none" v-html="videoplaceholder" />
+ </NcAppContent>
+ </NcContent>
+</template>
+
+<script>
+import { generateUrl } from '@nextcloud/router'
+
+import { NcContent, NcAppContent } from '@nextcloud/vue'
+
+import svgplaceholder from './assets/file-placeholder.svg'
+import imgplaceholder from './assets/image.svg'
+import videoplaceholder from './assets/video.svg'
+
+export default {
+ name: 'PhotosPublic',
+ components: {
+ NcAppContent,
+ NcContent,
+ },
+ data() {
+ return {
+ svgplaceholder,
+ imgplaceholder,
+ videoplaceholder,
+ }
+ },
+
+ async beforeMount() {
+ if ('serviceWorker' in navigator) {
+ // Use the window load event to keep the page load performant
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register(generateUrl('/apps/photos/service-worker.js', {}, {
+ noRewrite: true,
+ }), {
+ scope: '/',
+ }).then(registration => {
+ console.debug('SW registered: ', registration)
+ }).catch(registrationError => {
+ console.error('SW registration failed: ', registrationError)
+ })
+
+ })
+ } else {
+ console.debug('Service Worker is not enabled on this browser.')
+ }
+ },
+
+ beforeDestroy() {
+ window.removeEventListener('load', () => {
+ navigator.serviceWorker.register(generateUrl('/apps/photos/service-worker.js', {}, {
+ noRewrite: true,
+ }))
+ })
+ },
+}
+</script>
+<style lang="scss">
+.app-content {
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ align-content: space-between;
+}
+
+.app-navigation__photos::v-deep .app-navigation-entry-icon.icon-photos {
+ background-size: 20px;
+}
+</style>
diff --git a/src/components/Albums/CollaboratorsSelectionForm.vue b/src/components/Albums/CollaboratorsSelectionForm.vue
index 03ef7ddc..d55a3a83 100644
--- a/src/components/Albums/CollaboratorsSelectionForm.vue
+++ b/src/components/Albums/CollaboratorsSelectionForm.vue
@@ -46,16 +46,16 @@
</label>
<ul v-if="searchResults.length !== 0" :id="`manage-collaborators__form__list-${randomId}`" class="manage-collaborators__form__list">
- <li v-for="result of searchResults" :key="result.key">
+ <li v-for="collaboratorKey of searchResults" :key="collaboratorKey">
<a>
- <NcListItemIcon :id="availableCollaborators[result.key].id"
+ <NcListItemIcon :id="availableCollaborators[collaboratorKey].id"
class="manage-collaborators__form__list__result"
- :title="availableCollaborators[result.key].id"
+ :title="availableCollaborators[collaboratorKey].id"
:search="searchText"
- :user="availableCollaborators[result.key].id"
- :display-name="availableCollaborators[result.key].label"
- :aria-label="t('photos', 'Add {collaboratorLabel} to the collaborators list', {collaboratorLabel: availableCollaborators[result.key].label})"
- @click="selectEntity(result.key)" />
+ :user="availableCollaborators[collaboratorKey].id"
+ :display-name="availableCollaborators[collaboratorKey].label"
+ :aria-label="t('photos', 'Add {collaboratorLabel} to the collaborators list', {collaboratorLabel: availableCollaborators[collaboratorKey].label})"
+ @click="selectEntity(collaboratorKey)" />
</a>
</li>
</ul>
@@ -69,7 +69,7 @@
</form>
<ul class="manage-collaborators__selection">
- <li v-for="collaboratorKey of selectedCollaboratorsKeys"
+ <li v-for="collaboratorKey of listableSelectedCollaboratorsKeys"
:key="collaboratorKey"
class="manage-collaborators__selection__item">
<NcListItemIcon :id="availableCollaborators[collaboratorKey].id"
@@ -87,8 +87,9 @@
<div class="actions">
<div v-if="allowPublicLink" class="actions__public-link">
- <template v-if="publicLink">
+ <template v-if="isPublicLinkSelected">
<NcButton class="manage-collaborators__public-link-button"
+ :aria-label="t('photos', 'Copy the public link')"
@click="copyPublicLink">
<template v-if="publicLinkCopied">
{{ t('photos', 'Public link copied!') }}
@@ -101,7 +102,7 @@
<ContentCopy v-else />
</template>
</NcButton>
- <NcButton @click="deletePublicLink">
+ <NcButton type="tertiary" :aria-label="t('photos', 'Delete the public link')" @click="deletePublicLink">
<Close slot="icon" />
</NcButton>
</template>
@@ -119,28 +120,30 @@
</div>
</div>
</template>
-
<script>
+import { mapActions } from 'vuex'
+
import Magnify from 'vue-material-design-icons/Magnify'
import Close from 'vue-material-design-icons/Close'
+import Check from 'vue-material-design-icons/Check'
+import ContentCopy from 'vue-material-design-icons/ContentCopy'
import AccountGroup from 'vue-material-design-icons/AccountGroup'
import Earth from 'vue-material-design-icons/Earth'
import axios from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
import { getCurrentUser } from '@nextcloud/auth'
-import { generateOcsUrl } from '@nextcloud/router'
+import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import { NcButton, NcListItemIcon, NcLoadingIcon, NcPopover, NcTextField, NcEmptyContent } from '@nextcloud/vue'
import logger from '../../services/logger.js'
-const SHARE = {
- TYPE: {
- USER: 0,
- GROUP: 1,
- // LINK: 3,
- },
-}
+/**
+ * @typedef {object} Collaborator
+ * @property {string} id - The id of the collaborator.
+ * @property {string} label - The label of the collaborator for display.
+ * @property {0|1|3} type - The type of the collaborator.
+ */
export default {
name: 'CollaboratorsSelectionForm',
@@ -149,6 +152,8 @@ export default {
Magnify,
Close,
AccountGroup,
+ ContentCopy,
+ Check,
Earth,
NcLoadingIcon,
NcButton,
@@ -164,16 +169,12 @@ export default {
required: true,
},
+ /** @type {import('vue').PropType<Collaborator[]>} */
collaborators: {
type: Array,
default: () => [],
},
- publicLink: {
- type: String,
- default: '',
- },
-
allowPublicLink: {
type: Boolean,
default: true,
@@ -183,8 +184,11 @@ export default {
data() {
return {
searchText: '',
+ /** @type {Object<string, Collaborator>} */
availableCollaborators: {},
+ /** @type {string[]} */
selectedCollaboratorsKeys: [],
+ /** @type {Collaborator[]} */
currentSearchResults: [],
loadingCollaborators: false,
randomId: Math.random().toString().substring(2, 10),
@@ -192,6 +196,12 @@ export default {
config: {
minSearchStringLength: parseInt(OC.config['sharing.minSearchStringLength'], 10) || 0,
},
+ /** @type {Collaborator} */
+ publicLink: {
+ id: (Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2)).substring(0, 15),
+ label: t('photos', 'Public link'),
+ type: OC.Share.SHARE_TYPE_LINK,
+ },
}
},
@@ -203,29 +213,56 @@ export default {
return this.currentSearchResults
.filter(({ id }) => id !== getCurrentUser().uid)
.map(({ type, id }) => `${type}:${id}`)
- .filter(key => !this.selectedCollaboratorsKeys.includes(key))
- .map((key) => ({ key, height: 48 }))
+ .filter(collaboratorKey => !this.selectedCollaboratorsKeys.includes(collaboratorKey))
},
/**
- * @return {object[]}
+ * @return {string[]}
+ */
+ listableSelectedCollaboratorsKeys() {
+ return this.selectedCollaboratorsKeys
+ .filter(collaboratorKey => this.availableCollaborators[collaboratorKey].type !== OC.Share.SHARE_TYPE_LINK)
+ },
+
+ /**
+ * @return {Collaborator[]}
*/
selectedCollaborators() {
- return this.selectedCollaboratorsKeys.map((collaboratorKey) => this.availableCollaborators[collaboratorKey])
+ return this.selectedCollaboratorsKeys
+ .map((collaboratorKey) => this.availableCollaborators[collaboratorKey])
+ },
+
+ /**
+ * @return {boolean}
+ */
+ isPublicLinkSelected() {
+ return this.selectedCollaborators
+ .some(collaborator => collaborator.type === OC.Share.SHARE_TYPE_LINK)
+
},
},
mounted() {
this.searchCollaborators()
- this.selectedCollaboratorsKeys = this.collaborators.map(({ type, id }) => `${type}:${id}`)
+
+ const initialCollaborators = this.collaborators.reduce(this.indexCollaborators, {})
+ const publicLink = this.collaborators.find(collaborator => collaborator.type === OC.Share.SHARE_TYPE_LINK)
+
+ if (publicLink !== undefined) {
+ this.publicLink = publicLink
+ }
+
+ this.selectedCollaboratorsKeys = Object.keys(initialCollaborators)
this.availableCollaborators = {
+ [`${this.publicLink.type}:${this.publicLink.id}`]: this.publicLink,
...this.availableCollaborators,
- ...this.collaborators
- .reduce((collaborators, collaborator) => ({ ...collaborators, [`${collaborator.type}:${collaborator.id}`]: collaborator }), {}),
+ ...initialCollaborators,
}
},
methods: {
+ ...mapActions(['updateAlbum']),
+
/**
* Fetch possible collaborators.
*/
@@ -241,30 +278,27 @@ export default {
search: this.searchText,
itemType: 'share-recipients',
shareTypes: [
- SHARE.TYPE.USER,
- SHARE.TYPE.GROUP,
+ OC.Share.SHARE_TYPE_USER,
+ OC.Share.SHARE_TYPE_GROUP,
],
},
})
this.currentSearchResults = response.data.ocs.data
.map(collaborator => {
- let type = -1
switch (collaborator.source) {
case 'users':
- type = OC.Share.SHARE_TYPE_USER
- break
+ return { id: collaborator.id, label: collaborator.label, type: OC.Share.SHARE_TYPE_USER }
case 'groups':
- type = OC.Share.SHARE_TYPE_GROUP
- break
+ return { id: collaborator.id, label: collaborator.label, type: OC.Share.SHARE_TYPE_GROUP }
+ default:
+ throw new Error(`Invalid collaborator source ${collaborator.source}`)
}
-
- return { ...collaborator, type }
})
+
this.availableCollaborators = {
...this.availableCollaborators,
- ...this.currentSearchResults
- .reduce((collaborators, collaborator) => ({ ...collaborators, [`${collaborator.type}:${collaborator.id}`]: collaborator }), {}),
+ ...this.currentSearchResults.reduce(this.indexCollaborators, {}),
}
} catch (error) {
this.errorFetchingCollaborators = error
@@ -275,17 +309,46 @@ export default {
}
},
- // TODO: implement public sharing
+ /**
+ * @param {Object<string, Collaborator>} collaborators - Index of collaborators
+ * @param {Collaborator} collaborator - A collaborator
+ */
+ indexCollaborators(collaborators, collaborator) {
+ return { ...collaborators, [`${collaborator.type}:${collaborator.id}`]: collaborator }
+ },
+
async createPublicLinkForAlbum() {
- return axios.put(generateOcsUrl(`apps/photos/createPublicLink/${this.albumName}`))
+ this.selectEntity(`${this.publicLink.type}:${this.publicLink.id}`)
+ await this.updateAlbumCollaborators()
},
async deletePublicLink() {
- return axios.delete(generateOcsUrl(`apps/photos/createPublicLink/${this.albumName}`))
+ this.unselectEntity(`${this.publicLink.type}:${this.publicLink.id}`)
+
+ this.publicLinkCopied = false
+
+ delete this.availableCollaborators[`${this.publicLink.type}:${this.publicLink.id}`]
+ this.publicLink = {
+ id: (Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2)).substring(0, 15),
+ label: t('photos', 'Public link'),
+ type: OC.Share.SHARE_TYPE_LINK,
+ }
+ this.availableCollaborators[`${this.publicLink.type}:${this.publicLink.id}`] = this.publicLink
+
+ await this.updateAlbumCollaborators()
+ },
+
+ async updateAlbumCollaborators() {
+ await this.updateAlbum({
+ albumName: this.albumName,
+ properties: {
+ collaborators: this.selectedCollaborators,
+ },
+ })
},
async copyPublicLink() {
- await navigator.clipboard.writeText(this.publicLink)
+ await navigator.clipboard.writeText(`${window.location.protocol}//${window.location.host}${generateUrl(`apps/photos/public/${getCurrentUser().uid}/${this.publicLink.id}`)}`)
this.publicLinkCopied = true
setTimeout(() => {
this.publicLinkCopied = false
@@ -308,7 +371,6 @@ export default {
},
}
</script>
-
<style lang="scss" scoped>
.manage-collaborators {
display: flex;
@@ -396,6 +458,10 @@ export default {
&__public-link {
display: flex;
align-items: center;
+
+ button {
+ margin-left: 8px;
+ }
}
&__slot {
diff --git a/src/components/Collection/CollectionContent.vue b/src/components/Collection/CollectionContent.vue
index 5e483317..f61341cb 100644
--- a/src/components/Collection/CollectionContent.vue
+++ b/src/components/Collection/CollectionContent.vue
@@ -24,7 +24,7 @@
<NcEmptyContent v-if="collection === undefined && !loading"
class="empty-content-with-illustration"
:title="t('photos', 'This collection does not exist')">
- <FolderMultipleImage />
+ <FolderMultipleImage slot="icon" />
</NcEmptyContent>
<NcEmptyContent v-else-if="error" :title="t('photos', 'An error occurred')">
<AlertCircle slot="icon" />
@@ -102,8 +102,8 @@ export default {
},
error: {
- type: [Error],
- default: '',
+ type: [Error, Number],
+ default: null,
},
semaphore: {
diff --git a/src/components/HeaderNavigation.vue b/src/components/HeaderNavigation.vue
index 5933c088..7fc65bfe 100644
--- a/src/components/HeaderNavigation.vue
+++ b/src/components/HeaderNavigation.vue
@@ -170,7 +170,9 @@ export default {
toggleNavigationButton(hide) {
// Hide the navigation toggle if the back button is shown
const navigationToggle = document.querySelector('button.app-navigation-toggle')
- navigationToggle.style.display = hide ? 'none' : null
+ if (navigationToggle !== null) {
+ navigationToggle.style.display = hide ? 'none' : null
+ }
},
},
}
diff --git a/src/mixins/FetchAlbumsMixin.js b/src/mixins/FetchAlbumsMixin.js
index 131898f0..90e39a10 100644
--- a/src/mixins/FetchAlbumsMixin.js
+++ b/src/mixins/FetchAlbumsMixin.js
@@ -20,16 +20,12 @@
*
*/
-import { mapGetters } from 'vuex'
+import { mapGetters, mapActions } from 'vuex'
-import moment from '@nextcloud/moment'
-import { showError } from '@nextcloud/dialogs'
import { getCurrentUser } from '@nextcloud/auth'
-import client from '../services/DavClient.js'
-import logger from '../services/logger.js'
-import { genFileInfo } from '../utils/fileUtils.js'
import AbortControllerMixin from './AbortControllerMixin.js'
+import { fetchAlbums } from '../services/Albums.js'
export default {
name: 'FetchAlbumsMixin',
@@ -56,6 +52,10 @@ export default {
},
methods: {
+ ...mapActions([
+ 'addAlbums',
+ ]),
+
async fetchAlbums() {
if (this.loadingAlbums) {
return
@@ -65,74 +65,15 @@ export default {
this.loadingAlbums = true
this.errorFetchingAlbums = null
- const response = await client.getDirectoryContents(`/photos/${getCurrentUser()?.uid}/albums`, {
- data: `<?xml version="1.0"?>
- <d:propfind xmlns:d="DAV:"
- xmlns:oc="http://owncloud.org/ns"
- xmlns:nc="http://nextcloud.org/ns"
- xmlns:ocs="http://open-collaboration-services.org/ns">
- <d:prop>
- <nc:last-photo />
- <nc:nbItems />
- <nc:location />
- <nc:dateRange />
- <nc:collaborators />
- <nc:publicLink />
- </d:prop>
- </d:propfind>`,
- details: true,
- signal: this.abortController.signal,
- })
-
- const albums = response.data
- .filter(album => album.filename !== `/photos/${getCurrentUser()?.uid}/albums`)
- // Ensure that we have a proper collaborators array.
- .map(album => {
- if (album.props.collaborators === '') {
- album.props.collaborators = []
- } else if (typeof album.props.collaborators.collaborator === 'object') {
- if (Array.isArray(album.props.collaborators.collaborator)) {
- album.props.collaborators = album.props.collaborators.collaborator
- } else {
- album.props.collaborators = [album.props.collaborators.collaborator]
- }
- }
-
- return album
- })
- .map(album => genFileInfo(album))
- .map(album => {
- const dateRange = JSON.parse(album.dateRange?.replace(/&quot;/g, '"') ?? '{}')
-
- if (dateRange.start === null) {
- dateRange.start = moment().unix()
- dateRange.end = moment().unix()
- }
-
- const dateRangeFormated = {
- startDate: moment.unix(dateRange.start).format('MMMM YYYY'),
- endDate: moment.unix(dateRange.end).format('MMMM YYYY'),
- }
-
- if (dateRangeFormated.startDate === dateRangeFormated.endDate) {
- return { ...album, date: dateRangeFormated.startDate }
- } else {
- return { ...album, date: this.t('photos', '{startDate} to {endDate}', dateRangeFormated) }
- }
- })
+ const albums = await fetchAlbums(`/photos/${getCurrentUser()?.uid}/albums`, this.abortController.signal)
- this.$store.dispatch('addAlbums', { albums })
- logger.debug(`[FetchAlbumsMixin] Fetched ${albums.length} new files: `, albums)
+ this.addAlbums({ albums })
} catch (error) {
if (error.response?.status === 404) {
this.errorFetchingAlbums = 404
- } else if (error.code === 'ERR_CANCELED') {
- return
} else {
this.errorFetchingAlbums = error
}
- logger.error(t('photos', 'Failed to fetch albums list.'), error)
- showError(t('photos', 'Failed to fetch albums list.'))
} finally {
this.loadingAlbums = false
}
diff --git a/src/mixins/FetchSharedAlbumsMixin.js b/src/mixins/FetchSharedAlbumsMixin.js
index c4a57b18..3298f857 100644
--- a/src/mixins/FetchSharedAlbumsMixin.js
+++ b/src/mixins/FetchSharedAlbumsMixin.js
@@ -20,16 +20,12 @@
*
*/
-import { mapGetters } from 'vuex'
+import { mapGetters, mapActions } from 'vuex'
-import moment from '@nextcloud/moment'
-import { showError } from '@nextcloud/dialogs'
import { getCurrentUser } from '@nextcloud/auth'
-import client from '../services/DavClient.js'
-import logger from '../services/logger.js'
-import { genFileInfo } from '../utils/fileUtils.js'
import AbortControllerMixin from './AbortControllerMixin.js'
+import { fetchAlbums } from '../services/Albums.js'
export default {
name: 'FetchSharedAlbumsMixin',
@@ -56,6 +52,10 @@ export default {
},
methods: {
+ ...mapActions([
+ 'addSharedAlbums',
+ ]),
+
async fetchAlbums() {
if (this.loadingAlbums) {
return
@@ -65,62 +65,15 @@ export default {
this.loadingAlbums = true
this.errorFetchingAlbums = null
- const response = await client.getDirectoryContents(`/photos/${getCurrentUser()?.uid}/sharedalbums`, {
- data: `<?xml version="1.0"?>
- <d:propfind xmlns:d="DAV:"
- xmlns:oc="http://owncloud.org/ns"
- xmlns:nc="http://nextcloud.org/ns"
- xmlns:ocs="http://open-collaboration-services.org/ns">
- <d:prop>
- <nc:last-photo />
- <nc:nbItems />
- <nc:location />
- <nc:dateRange />
- <nc:collaborators />
- <nc:publicLink />
- </d:prop>
- </d:propfind>`,
- details: true,
- signal: this.abortController.signal,
- })
-
- const albums = response.data
- .filter(album => album.filename !== `/photos/${getCurrentUser()?.uid}/sharedalbums`)
- .map(album => genFileInfo(album))
- .map(album => {
- album.collaborators = album.collaborators === '' ? [] : album.collaborators
-
- const dateRange = JSON.parse(album.dateRange?.replace(/&quot;/g, '"') ?? '{}')
-
- if (dateRange.start === null) {
- dateRange.start = moment().unix()
- dateRange.end = moment().unix()
- }
-
- const dateRangeFormated = {
- startDate: moment.unix(dateRange.start).format('MMMM YYYY'),
- endDate: moment.unix(dateRange.end).format('MMMM YYYY'),
- }
-
- if (dateRangeFormated.startDate === dateRangeFormated.endDate) {
- return { ...album, date: dateRangeFormated.startDate }
- } else {
- return { ...album, date: this.t('photos', '{startDate} to {endDate}', dateRangeFormated) }
- }
- })
+ const albums = await fetchAlbums(`/photos/${getCurrentUser()?.uid}/sharedalbums`, this.abortController.signal)
- this.$store.dispatch('addSharedAlbums', { albums })
- logger.debug(`[FetchSharedAlbumsMixin] Fetched ${albums.length} new files: `, albums)
+ this.addSharedAlbums({ albums })
} catch (error) {
if (error.response?.status === 404) {
this.errorFetchingAlbums = 404
- } else if (error.code === 'ERR_CANCELED') {
- return
} else {
this.errorFetchingAlbums = error
}
- logger.error(t('photos', 'Failed to fetch albums list.'), error)
- showError(t('photos', 'Failed to fetch albums list.'))
} finally {
this.loadingAlbums = false
}
diff --git a/src/public.js b/src/public.js
new file mode 100644
index 00000000..f67536d1
--- /dev/null
+++ b/src/public.js
@@ -0,0 +1,67 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import { generateFilePath } from '@nextcloud/router'
+import { getRequestToken } from '@nextcloud/auth'
+import { sync } from 'vuex-router-sync'
+import { translate, translatePlural } from '@nextcloud/l10n'
+import Vue from 'vue'
+
+import PhotosPublic from './PhotosPublic.vue'
+import router from './router/index.js'
+import store from './store/index.js'
+
+// CSP config for webpack dynamic chunk loading
+// eslint-disable-next-line
+__webpack_nonce__ = btoa(getRequestToken())
+
+// Correct the root of the app for chunk loading
+// OC.linkTo matches the apps folders
+// OC.generateUrl ensure the index.php (or not)
+// We do not want the index.php since we're loading files
+// eslint-disable-next-line
+__webpack_public_path__ = generateFilePath('photos', '', 'js/')
+
+sync(store, router)
+
+Vue.prototype.t = translate
+Vue.prototype.n = translatePlural
+
+// TODO: remove when we have a proper fileinfo standalone library
+// original scripts are loaded from
+// https://github.com/nextcloud/server/blob/5bf3d1bb384da56adbf205752be8f840aac3b0c5/lib/private/legacy/template.php#L120-L122
+window.addEventListener('DOMContentLoaded', () => {
+ if (!window.OCA.Files) {
+ window.OCA.Files = {}
+ }
+ // register unused client for the sidebar to have access to its parser methods
+ Object.assign(window.OCA.Files, { App: { fileList: { filesClient: OC.Files.getClient() } } }, window.OCA.Files)
+})
+
+export default new Vue({
+ el: '#content',
+ // eslint-disable-next-line vue/match-component-file-name
+ name: 'PhotosRoot',
+ router,
+ store,
+ render: h => h(PhotosPublic),
+})
diff --git a/src/router/index.js b/src/router/index.js
index 574a3ffa..b9a96bc9 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -35,6 +35,7 @@ const Albums = () => import('../views/Albums')
const AlbumContent = () => import('../views/AlbumContent')
const SharedAlbums = () => import('../views/SharedAlbums')
const SharedAlbumContent = () => import('../views/SharedAlbumContent')
+const PublicAlbumContent = () => import('../views/PublicAlbumContent')
const Tags = () => import('../views/Tags')
const TagContent = () => import('../views/TagContent')
const Timeline = () => import('../views/Timeline')
@@ -119,6 +120,15 @@ const router = new Router({
}),
},
{
+ path: '/public/:userId/:token',
+ component: PublicAlbumContent,
+ name: 'publicAlbums',
+ props: route => ({
+ userId: route.params.userId,
+ token: route.params.token,
+ }),
+ },
+ {
path: '/folders/:path*',
component: Folders,
name: 'folders',
diff --git a/src/services/Albums.js b/src/services/Albums.js
index 1ad2b7a7..3dc33297 100644
--- a/src/services/Albums.js
+++ b/src/services/Albums.js
@@ -20,8 +20,14 @@
*
*/
-import axios from '@nextcloud/axios'
-import { generateUrl } from '@nextcloud/router'
+import moment from '@nextcloud/moment'
+import { showError } from '@nextcloud/dialogs'
+import { translate } from '@nextcloud/l10n'
+
+import client from '../services/DavClient.js'
+import logger from '../services/logger.js'
+import DavRequest from '../services/DavRequest.js'
+import { genFileInfo } from '../utils/fileUtils.js'
/**
* @typedef {object} Album
@@ -35,19 +41,151 @@ import { generateUrl } from '@nextcloud/router'
*/
/**
- * List albums.
*
- * @return {Promise<Album[]>} the album list
+ * @param {string} path - Albums' root path.
+ * @param {AbortSignal} signal - Abort signal to cancel the request.
+ * @return {Promise<Album|null>}
*/
-export default async function() {
- const response = await axios.get(generateUrl('/apps/photos/api/v1/albums'))
- return response.data.map(album => ({
- id: `${album.fileid}`,
- name: album.basename,
- location: 'Paris',
- creationDate: album.lastmod,
- state: 0,
- itemCount: 100,
- cover: '13397',
- }))
+export async function fetchAlbum(path, signal) {
+ try {
+ const response = await client.stat(path, {
+ data: `<?xml version="1.0"?>
+ <d:propfind xmlns:d="DAV:"
+ xmlns:oc="http://owncloud.org/ns"
+ xmlns:nc="http://nextcloud.org/ns"
+ xmlns:ocs="http://open-collaboration-services.org/ns">
+ <d:prop>
+ <nc:last-photo />
+ <nc:nbItems />
+ <nc:location />
+ <nc:dateRange />
+ <nc:collaborators />
+ </d:prop>
+ </d:propfind>`,
+ signal,
+ details: true,
+ })
+
+ logger.debug('[Albums] Fetched an album: ', response.data)
+
+ return formatAlbum(response.data)
+ } catch (error) {
+ if (error.code === 'ERR_CANCELED') {
+ return null
+ }
+
+ throw error
+ }
}
+
+/**
+ *
+ * @param {string} path - Albums' root path.
+ * @param {AbortSignal} signal - Abort signal to cancel the request.
+ * @return {Promise<Album[]>}
+ */
+export async function fetchAlbums(path, signal) {
+ try {
+ const response = await client.getDirectoryContents(path, {
+ data: `<?xml version="1.0"?>
+ <d:propfind xmlns:d="DAV:"
+ xmlns:oc="http://owncloud.org/ns"
+ xmlns:nc="http://nextcloud.org/ns"
+ xmlns:ocs="http://open-collaboration-services.org/ns">
+ <d:prop>
+ <nc:last-photo />
+ <nc:nbItems />
+ <nc:location />
+ <nc:dateRange />
+ <nc:collaborators />
+ </d:prop>
+ </d:propfind>`,
+ details: true,
+ signal,
+ })
+
+ logger.debug(`[Albums] Fetched ${response.data.length} albums: `, response.data)
+
+ return response.data
+ .filter(album => album.filename !== path)
+ .map(formatAlbum)
+ } catch (error) {
+ if (error.code === 'ERR_CANCELED') {
+ return []
+ }
+
+ throw error
+ }
+}
+
+/**
+ *
+ * @param {object} album - An album received from a webdav request.
+ * @return {Album}
+ */
+function formatAlbum(album) {
+ // Ensure that we have a proper collaborators array.
+ if (album.props.collaborators === '') {
+ album.props.collaborators = []
+ } else if (typeof album.props.collaborators.collaborator === 'object') {
+ if (Array.isArray(album.props.collaborators.collaborator)) {
+ album.props.collaborators = album.props.collaborators.collaborator
+ } else {
+ album.props.collaborators = [album.props.collaborators.collaborator]
+ }
+ }
+
+ // Extract custom props.
+ album = genFileInfo(album)
+
+ // Compute date range label.
+ const dateRange = JSON.parse(album.dateRange?.replace(/&quot;/g, '"') ?? '{}')
+ if (dateRange.start === null) {
+ dateRange.start = moment().unix()
+ dateRange.end = moment().unix()
+ }
+ const dateRangeFormated = {
+ startDate: moment.unix(dateRange.start).format('MMMM YYYY'),
+ endDate: moment.unix(dateRange.end).format('MMMM YYYY'),
+ }
+ if (dateRangeFormated.startDate === dateRangeFormated.endDate) {
+ album.date = dateRangeFormated.startDate
+ } else {
+ album.date = translate('photos', '{startDate} to {endDate}', dateRangeFormated)
+ }
+
+ return album
+}
+
+/**
+ *
+ * @param {string} path - Albums' root path.
+ * @param {AbortSignal} signal - Abort signal to cancel the request.
+ * @return {Promise<[]>}
+ */
+export async function fetchAlbumContent(path, signal) {
+ try {
+ const response = await client.getDirectoryContents(path, {
+ data: DavRequest,
+ details: true,
+ signal,
+ })
+
+ const fetchedFiles = response.data
+ .map(file => genFileInfo(file))
+ .filter(file => file.fileid)
+
+ logger.debug(`[Albums] Fetched ${fetchedFiles.length} new files: `, fetchedFiles)
+
+ return fetchedFiles
+ } catch (error) {
+ if (error.code === 'ERR_CANCELED') {
+ return []
+ }
+
+ logger.error('Error fetching album files', error)
+ console.error(error)
+
+ throw error
+ }
+} \ No newline at end of file
diff --git a/src/store/collectionStoreFactory.js b/src/store/collectionStoreFactory.js
new file mode 100644
index 00000000..aa09b915
--- /dev/null
+++ b/src/store/collectionStoreFactory.js
@@ -0,0 +1,214 @@
+/**
+ * @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
+ *
+ * @author Louis Chemineau <louis@chmn.me>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import { showError } from '@nextcloud/dialogs'
+
+import client from '../services/DavClient.js'
+import logger from '../services/logger.js'
+import Semaphore from '../utils/semaphoreWithPriority.js'
+import { translate } from '@nextcloud/l10n'
+
+/**
+ * @param {string} collectionName - The name of the collection/
+ */
+export default function collectionStoreFactory(collectionName) {
+ const capitalizedCollectionName = collectionName[0].toUpperCase() + collectionName.substr(1)
+
+ const state = {
+ [`${collectionName}s`]: {},
+ [`${collectionName}sFiles`]: {},
+ }
+
+ const mutations = {
+ /**
+ * Add a list of collections.
+ *
+ * @param {object} state vuex state
+ * @param {object} data destructuring object
+ * @param {Array} data.collections list of collections
+ */
+ [`add${capitalizedCollectionName}s`](state, { collections }) {
+ state[`${collectionName}s`] = {
+ ...state[`${collectionName}s`],
+ ...collections.reduce((collections, collection) => ({ ...collections, [collection.basename]: collection }), {}),
+ }
+ },
+
+ /**
+ * Remove a list of collections.
+ *
+ * @param {object} state vuex state
+ * @param {object} data destructuring object
+ * @param {Array} data.collectionIds list of collection ids
+ */
+ [`remove${capitalizedCollectionName}s`](state, { collectionIds }) {
+ collectionIds.forEach(collectionId => delete state[`${collectionName}s`][collectionId])
+ collectionIds.forEach(collectionId => delete state[`${collectionName}sFiles`][collectionId])
+ },
+
+ /**
+ * Add files to a collection.
+ *
+ * @param {object} state vuex state
+ * @param {object} data destructuring object
+ * @param {string} data.collectionId the collection id
+ * @param {string[]} data.fileIdsToAdd list of files
+ */
+ [`addFilesTo${capitalizedCollectionName}`](state, { collectionId, fileIdsToAdd }) {
+ const collectionFiles = state[`${collectionName}sFiles`][collectionId] || []
+ state[`${collectionName}sFiles`] = {
+ ...state[`${collectionName}sFiles`],
+ [collectionId]: [
+ ...collectionFiles,
+ ...fileIdsToAdd.filter(fileId => !collectionFiles.includes(fileId)), // Filter to prevent duplicate fileId.
+ ],
+ }
+ state[`${collectionName}s`][collectionId].nbItems += fileIdsToAdd.length
+ },
+
+ /**
+ * Remove files to an collection.
+ *
+ * @param {object} state vuex state
+ * @param {object} data destructuring object
+ * @param {string} data.collectionId the collection id
+ * @param {string[]} data.fileIdsToRemove list of files
+ */
+ [`removeFilesFrom${capitalizedCollectionName}`](state, { collectionId, fileIdsToRemove }) {
+ state[`${collectionName}sFiles`] = {
+ ...state[`${collectionName}sFiles`],
+ [collectionId]: state[`${collectionName}sFiles`][collectionId].filter(fileId => !fileIdsToRemove.includes(fileId)),
+ }
+ state[`${collectionName}s`][collectionId].nbItems -= fileIdsToRemove.length
+ },
+ }
+
+ const getters = {
+ [`${collectionName}s`]: state => state[`${collectionName}s`],
+ [`${collectionName}sFiles`]: state => state[`${collectionName}sFiles`],
+ }
+
+ const actions = {
+ /**
+ * Update files and collections
+ *
+ * @param {object} context vuex context
+ * @param {object} data destructuring object
+ * @param {Array} data.collections list of collections
+ */
+ [`add${capitalizedCollectionName}s`](context, { collections }) {
+ context.commit(`add${capitalizedCollectionName}s`, { collections })
+ },
+
+ /**
+ * Add files to an collection.
+ *
+ * @param {object} context vuex context
+ * @param {object} data destructuring object
+ * @param {string} data.collectionId the collection name
+ * @param {string[]} data.fileIdsToAdd list of files ids to add
+ */
+ async [`addFilesTo${capitalizedCollectionName}`](context, { collectionId, fileIdsToAdd }) {
+ const semaphore = new Semaphore(5)
+
+ context.commit(`addFilesTo${capitalizedCollectionName}`, { collectionId, fileIdsToAdd })
+
+ const promises = fileIdsToAdd
+ .map(async (fileId) => {
+ const file = context.getters.files[fileId]
+ const collection = context.getters[`${collectionName}s`][collectionId]
+ const symbol = await semaphore.acquire()
+
+ try {
+ await client.copyFile(
+ file.filename,
+ `${collection.filename}/${file.basename}`,
+ )
+ } catch (error) {
+ if (error.response.status !== 409) { // Already in the collection.
+ context.commit(`removeFilesFrom${capitalizedCollectionName}`, { collectionId, fileIdsToRemove: [fileId] })
+
+ logger.error(translate('photos', 'Failed to add {fileBaseName} to {collectionId}.', { fileBaseName: file.basename, collectionId }), error)
+ showError(translate('photos', 'Failed to add {fileBaseName} to {collectionId}.', { fileBaseName: file.basename, collectionId }))
+ }
+ } finally {
+ semaphore.release(symbol)
+ }
+ })
+
+ return Promise.all(promises)
+ },
+
+ /**
+ * Remove files to an collection.
+ *
+ * @param {object} context vuex context
+ * @param {object} data destructuring object
+ * @param {string} data.collectionId the collection name
+ * @param {string[]} data.fileIdsToRemove list of files ids to remove
+ */
+ async [`removeFilesFrom${capitalizedCollectionName}`](context, { collectionId, fileIdsToRemove }) {
+ const semaphore = new Semaphore(5)
+
+ context.commit(`removeFilesFrom${capitalizedCollectionName}`, { collectionId, fileIdsToRemove })
+
+ const promises = fileIdsToRemove
+ .map(async (fileId) => {
+ const file = context.getters.files[fileId]
+ const symbol = await semaphore.acquire()
+
+ try {
+ await client.deleteFile(file.filename)
+ } catch (error) {
+ context.commit(`addFilesTo${capitalizedCollectionName}`, { collectionId, fileIdsToAdd: [fileId] })
+
+ logger.error(translate('photos', 'Failed to delete {fileBaseName}.', { fileBaseName: file.basename }), error)
+ showError(translate('photos', 'Failed to delete {fileBaseName}.', { fileBaseName: file.basename }))
+ } finally {
+ semaphore.release(symbol)
+ }
+ })
+
+ return Promise.all(promises)
+ },
+
+ /**
+ * Delete a collection.
+ *
+ * @param {object} context vuex context
+ * @param {object} data destructuring object
+ * @param {string} data.collectionId the id of the collection
+ */
+ async [`delete${capitalizedCollectionName}`](context, { collectionId }) {
+ try {
+ const collection = context.getters[`${collectionName}s`][collectionId]
+ await client.deleteFile(collection.filename)
+ context.commit(`remove${capitalizedCollectionName}s`, { collectionIds: [collectionId] })
+ } catch (error) {
+ logger.error(translate('photos', 'Failed to delete {collectionId}.', { collectionId }), error)
+ showError(translate('photos', 'Failed to delete {collectionId}.', { collectionId }))
+ }
+ },
+ }
+
+ return { state, mutations, getters, actions }
+}
diff --git a/src/store/index.js b/src/store/index.js
index 65fab70c..f77eaccf 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -29,6 +29,7 @@ import sharedAlbums from './sharedAlbums.js'
import faces from './faces.js'
import folders from './folders.js'
import systemtags from './systemtags.js'
+import collectionStoreFactory from './collectionStoreFactory.js'
Vue.use(Vuex)
export default new Store({
@@ -39,6 +40,7 @@ export default new Store({
sharedAlbums,
faces,
systemtags,
+ publicAlbums: collectionStoreFactory('publicAlbum'),
},
strict: process.env.NODE_ENV !== 'production',
diff --git a/src/store/publicAlbums.js b/src/store/publicAlbums.js
new file mode 100644
index 00000000..03a84757
--- /dev/null
+++ b/src/store/publicAlbums.js
@@ -0,0 +1,213 @@
+/**
+ * @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
+ *
+ * @author Louis Chemineau <louis@chmn.me>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import { showError } from '@nextcloud/dialogs'
+
+import client from '../services/DavClient.js'
+import logger from '../services/logger.js'
+import Semaphore from '../utils/semaphoreWithPriority.js'
+
+/**
+ * @typedef {object} Album
+ * @property {string} basename - The name of the album.
+ * @property {number} lastmod - The creation date of the album.
+ * @property {string} size - The number of items in the album.
+ */
+
+const state = {
+ sharedAlbums: {},
+ sharedAlbumsFiles: {},
+}
+
+const mutations = {
+ /**
+ * Add albums to the album collection.
+ *
+ * @param {object} state vuex state
+ * @param {object} data destructuring object
+ * @param {Array} data.albums list of albums
+ */
+ addSharedAlbums(state, { albums }) {
+ state.sharedAlbums = {
+ ...state.sharedAlbums,
+ ...albums.reduce((albums, album) => ({ ...albums, [album.basename]: album }), {}),
+ }
+ },
+
+ /**
+ * Remove albums from the album collection.
+ *
+ * @param {object} state vuex state
+ * @param {object} data destructuring object
+ * @param {Array} data.albumNames list of albums ids
+ */
+ removeSharedAlbums(state, { albumNames }) {
+ albumNames.forEach(albumName => delete state.sharedAlbums[albumName])
+ albumNames.forEach(albumName => delete state.sharedAlbumsFiles[albumName])
+ },
+
+ /**
+ * Add files to an album.
+ *
+ * @param {object} state vuex state
+ * @param {object} data destructuring object
+ * @param {string} data.albumName the album id
+ * @param {string[]} data.fileIdsToAdd list of files
+ */
+ addFilesToSharedAlbum(state, { albumName, fileIdsToAdd }) {
+ const albumFiles = state.sharedAlbumsFiles[albumName] || []
+ state.sharedAlbumsFiles = {
+ ...state.sharedAlbumsFiles,
+ [albumName]: [
+ ...albumFiles,
+ ...fileIdsToAdd.filter(fileId => !albumFiles.includes(fileId)), // Filter to prevent duplicate fileId.
+ ],
+ }
+ state.sharedAlbums[albumName].nbItems += fileIdsToAdd.length
+ },
+
+ /**
+ * Remove files to an album.
+ *
+ * @param {object} state vuex state
+ * @param {object} data destructuring object
+ * @param {string} data.albumName the album id
+ * @param {string[]} data.fileIdsToRemove list of files
+ */
+ removeFilesFromSharedAlbum(state, { albumName, fileIdsToRemove }) {
+ state.sharedAlbumsFiles = {
+ ...state.sharedAlbumsFiles,
+ [albumName]: state.sharedAlbumsFiles[albumName].filter(fileId => !fileIdsToRemove.includes(fileId)),
+ }
+ state.sharedAlbums[albumName].nbItems -= fileIdsToRemove.length
+ },
+}
+
+const getters = {
+ sharedAlbums: state => state.sharedAlbums,
+ sharedAlbumsFiles: state => state.sharedAlbumsFiles,
+}
+
+const actions = {
+ /**
+ * Update files and albums
+ *
+ * @param {object} context vuex context
+ * @param {object} data destructuring object
+ * @param {Album[]} data.albums list of albums
+ */
+ addSharedAlbums(context, { albums }) {
+ context.commit('addSharedAlbums', { albums })
+ },
+
+ /**
+ * Add files to an album.
+ *
+ * @param {object} context vuex context
+ * @param {object} data destructuring object
+ * @param {string} data.albumName the album name
+ * @param {string[]} data.fileIdsToAdd list of files ids to add
+ */
+ async addFilesToSharedAlbum(context, { albumName, fileIdsToAdd }) {
+ const semaphore = new Semaphore(5)
+
+ context.commit('addFilesToSharedAlbum', { albumName, fileIdsToAdd })
+
+ const promises = fileIdsToAdd
+ .map(async (fileId) => {
+ const file = context.getters.files[fileId]
+ const album = context.getters.sharedAlbums[albumName]
+ const symbol = await semaphore.acquire()
+
+ try {
+ await client.copyFile(
+ file.filename,
+ `${album.filename}/${file.basename}`,
+ )
+ } catch (error) {
+ if (error.response.status !== 409) { // Already in the album.
+ context.commit('removeFilesFromSharedAlbum', { albumName, fileIdsToRemove: [fileId] })
+
+ logger.error(t('photos', 'Failed to add {fileBaseName} to shared album {albumName}.', { fileBaseName: file.basename, albumName }), error)
+ showError(t('photos', 'Failed to add {fileBaseName} to shared album {albumName}.', { fileBaseName: file.basename, albumName }))
+ }
+ } finally {
+ semaphore.release(symbol)
+ }
+ })
+
+ return Promise.all(promises)
+ },
+
+ /**
+ * Remove files to an album.
+ *
+ * @param {object} context vuex context
+ * @param {object} data destructuring object
+ * @param {string} data.albumName the album name
+ * @param {string[]} data.fileIdsToRemove list of files ids to remove
+ */
+ async removeFilesFromSharedAlbum(context, { albumName, fileIdsToRemove }) {
+ const semaphore = new Semaphore(5)
+
+ context.commit('removeFilesFromSharedAlbum', { albumName, fileIdsToRemove })
+
+ const promises = fileIdsToRemove
+ .map(async (fileId) => {
+ const file = context.getters.files[fileId]
+ const symbol = await semaphore.acquire()
+
+ try {
+ await client.deleteFile(file.filename)
+ } catch (error) {
+ context.commit('addFilesToSharedAlbum', { albumName, fileIdsToAdd: [fileId] })
+
+ logger.error(t('photos', 'Failed to delete {fileBaseName}.', { fileBaseName: file.basename }), error)
+ showError(t('photos', 'Failed to delete {fileBaseName}.', { fileBaseName: file.basename }))
+ } finally {
+ semaphore.release(symbol)
+ }
+ })
+
+ return Promise.all(promises)
+ },
+
+ /**
+ * Delete an album.
+ *
+ * @param {object} context vuex context
+ * @param {object} data destructuring object
+ * @param {string} data.albumName the id of the album
+ */
+ async deleteSharedAlbum(context, { albumName }) {
+ try {
+ const album = context.getters.sharedAlbums[albumName]
+ await client.deleteFile(album.filename)
+ context.commit('removeSharedAlbums', { albumNames: [albumName] })
+ } catch (error) {
+ logger.error(t('photos', 'Failed to delete {albumName}.', { albumName }), error)
+ showError(t('photos', 'Failed to delete {albumName}.', { albumName }))
+ }
+ },
+}
+
+export default { state, mutations, getters, actions }
diff --git a/src/views/AlbumContent.vue b/src/views/AlbumContent.vue
index b4e3413f..a481bc9b 100644
--- a/src/views/AlbumContent.vue
+++ b/src/views/AlbumContent.vue
@@ -361,9 +361,7 @@ export default {
this.errorFetchingFiles = error
}
- // cancelled request, moving on...
- logger.error('Error fetching album files')
- console.error(error)
+ logger.error('[AlbumContent] Error fetching album files', error)
} finally {
this.loadingFiles = false
this.semaphore.release(semaphoreSymbol)
diff --git a/src/views/Albums.vue b/src/views/Albums.vue
index 34fbd4c1..035d5c62 100644
--- a/src/views/Albums.vue
+++ b/src/views/Albums.vue
@@ -30,7 +30,7 @@
:loading="loadingAlbums"
:title="t('photos', 'Albums')"
:root-title="t('photos', 'Albums')"
- @refresh="onRefresh">
+ @refresh="fetchAlbums">
<NcButton :aria-label="t('photos', 'Create a new album.')"
@click="showAlbumCreationForm = true">
<template #icon>
@@ -122,10 +122,6 @@ export default {
this.showAlbumCreationForm = false
this.$router.push(`albums/${album.basename}`)
},
-
- onRefresh() {
- this.fetchAlbums()
- },
},
}
</script>
diff --git a/src/views/PublicAlbumContent.vue b/src/views/PublicAlbumContent.vue
new file mode 100644
index 00000000..6d3b5885
--- /dev/null
+++ b/src/views/PublicAlbumContent.vue
@@ -0,0 +1,324 @@
+<!--
+ - @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
+ -
+ - @author Louis Chemineau <louis@chmn.me>
+ -
+ - @license AGPL-3.0-or-later
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+<template>
+ <div>
+ <CollectionContent v-if="true"
+ ref="collectionContent"
+ :collection="album"
+ :collection-file-ids="albumFileIds"
+ :semaphore="semaphore"
+ :loading="loadingAlbum || loadingFiles"
+ :error="errorFetchingAlbum || errorFetchingFiles">
+ <!-- Header -->
+ <HeaderNavigation key="navigation"
+ slot="header"
+ slot-scope="{selectedFileIds}"
+ :loading="loadingAlbum || loadingFiles"
+ :params="{ albumName }"
+ :path="'/' + albumName"
+ :title="albumName"
+ @refresh="fetchAlbumContent">
+ <!-- TODO: enable upload on public albums -->
+ <!-- <UploadPicker :accept="allowedMimes"
+ :destination="folder.filename"
+ :multiple="true"
+ @uploaded="onUpload" /> -->
+ <div v-if="album.location !== ''" slot="subtitle" class="album__location">
+ <MapMarker />{{ album.location }}
+ </div>
+ <template v-if="album !== undefined" slot="right">
+ <NcButton v-if="album.nbItems !== 0"
+ type="tertiary"
+ :aria-label="t('photos', 'Add photos to this album')"
+ @click="showAddPhotosModal = true">
+ <Plus slot="icon" />
+ </NcButton>
+
+ <NcActions :force-menu="true" :aria-label="t('photos', 'Open actions menu')">
+ <!-- TODO: enable download on public albums -->
+ <!-- <ActionDownload v-if="albumFileIds.length > 0"
+ :selected-file-ids="albumFileIds"
+ :title="t('photos', 'Download all files in album')">
+ <DownloadMultiple slot="icon" />
+ </ActionDownload> -->
+
+ <template v-if="selectedFileIds.length > 0">
+ <NcActionSeparator />
+
+ <!-- TODO: enable download on public albums -->
+ <!-- <ActionDownload :selected-file-ids="selectedFileIds" :title="t('photos', 'Download selected files')">
+ <Download slot="icon" />
+ </ActionDownload> -->
+
+ <NcActionButton :close-after-click="true"
+ @click="handleRemoveFilesFromAlbum(selectedFileIds)">
+ {{ t('photos', 'Remove selection from album') }}
+ <Close slot="icon" />
+ </NcActionButton>
+ </template>
+ </NcActions>
+ </template>
+ </HeaderNavigation>
+
+ <!-- No content -->
+ <NcEmptyContent slot="empty-content"
+ :title="t('photos', 'This album does not have any photos or videos yet!')"
+ class="album__empty">
+ <ImagePlus slot="icon" />
+
+ <NcButton slot="action"
+ type="primary"
+ :aria-label="t('photos', 'Add photos to this album')"
+ @click="showAddPhotosModal = true">
+ <Plus slot="icon" />
+ {{ t('photos', "Add") }}
+ </NcButton>
+ </NcEmptyContent>
+ </CollectionContent>
+
+ <!-- Modals -->
+ <NcModal v-if="showAddPhotosModal"
+ size="large"
+ :title="t('photos', 'Add photos to the album')"
+ @close="showAddPhotosModal = false">
+ <FilesPicker :destination="album.basename"
+ :blacklist-ids="albumFileIds"
+ :loading="loadingAddFilesToAlbum"
+ @files-picked="handleFilesPicked" />
+ </NcModal>
+ </div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex'
+import MapMarker from 'vue-material-design-icons/MapMarker'
+import Plus from 'vue-material-design-icons/Plus'
+import ImagePlus from 'vue-material-design-icons/ImagePlus'
+import Close from 'vue-material-design-icons/Close'
+// import Download from 'vue-material-design-icons/Download'
+// import DownloadMultiple from 'vue-material-design-icons/DownloadMultiple'
+
+import { NcActions, NcActionButton, NcButton, NcModal, NcEmptyContent, NcActionSeparator, isMobile } from '@nextcloud/vue'
+import { showError } from '@nextcloud/dialogs'
+
+import FetchFilesMixin from '../mixins/FetchFilesMixin.js'
+import AbortControllerMixin from '../mixins/AbortControllerMixin.js'
+import CollectionContent from '../components/Collection/CollectionContent.vue'
+import HeaderNavigation from '../components/HeaderNavigation.vue'
+// import ActionDownload from '../components/Actions/ActionDownload.vue'
+import FilesPicker from '../components/FilesPicker.vue'
+import { fetchAlbum, fetchAlbumContent } from '../services/Albums.js'
+import logger from '../services/logger.js'
+
+export default {
+ name: 'PublicAlbumContent',
+ components: {
+ MapMarker,
+ Plus,
+ Close,
+ // Download,
+ // DownloadMultiple,
+ ImagePlus,
+ NcEmptyContent,
+ NcActions,
+ NcActionButton,
+ NcActionSeparator,
+ NcButton,
+ NcModal,
+ CollectionContent,
+ // ActionDownload,
+ FilesPicker,
+ HeaderNavigation,
+ },
+
+ mixins: [
+ FetchFilesMixin,
+ AbortControllerMixin,
+ isMobile,
+ ],
+
+ props: {
+ userId: {
+ type: String,
+ required: true,
+ },
+ token: {
+ type: String,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ showAddPhotosModal: false,
+ loadingAlbum: false,
+ errorFetchingAlbum: null,
+ loadingCount: 0,
+ loadingAddFilesToAlbum: false,
+ albumName: '',
+ }
+ },
+
+ computed: {
+ ...mapGetters([
+ 'files',
+ 'publicAlbums',
+ 'publicAlbumsFiles',
+ ]),
+
+ /**
+ * @return {object} The album information for the current albumName.
+ */
+ album() {
+ return this.publicAlbums[this.albumName] || {}
+ },
+
+ /**
+ * @return {string[]} The list of files for the current albumName.
+ */
+ albumFileIds() {
+ return this.publicAlbumsFiles[this.albumName] || []
+ },
+ },
+
+ beforeMount() {
+ this.fetchAlbumInfo()
+ this.fetchAlbumContent()
+ },
+
+ methods: {
+ ...mapActions([
+ 'appendFiles',
+ 'addPublicAlbums',
+ 'addFilesToPublicAlbum',
+ 'removeFilesFromPublicAlbum',
+ ]),
+
+ async fetchAlbumInfo() {
+ if (this.loadingAlbum) {
+ return
+ }
+
+ try {
+ this.loadingAlbum = true
+ this.errorFetchingAlbum = null
+
+ const album = await fetchAlbum(`/photos/${this.userId}/public/${this.token}`, this.abortController.signal)
+ this.addPublicAlbums({ collections: [album] })
+ this.albumName = album.basename
+ } catch (error) {
+ if (error.response?.status === 404) {
+ this.errorFetchingAlbum = 404
+ } else {
+ this.errorFetchingAlbum = error
+ }
+
+ logger.error('[PublicAlbumContent] Error fetching album', error)
+ showError(this.t('photos', 'Failed to fetch albums list.'))
+ } finally {
+ this.loadingAlbum = false
+ }
+ },
+
+ async fetchAlbumContent() {
+ if (this.loadingFiles || this.showEditAlbumForm) {
+ return []
+ }
+
+ const semaphoreSymbol = await this.semaphore.acquire(() => 0, 'fetchFiles')
+ const fetchSemaphoreSymbol = await this.fetchSemaphore.acquire()
+
+ try {
+ this.errorFetchingFiles = null
+ this.loadingFiles = true
+ this.semaphoreSymbol = semaphoreSymbol
+
+ const fetchedFiles = await fetchAlbumContent(
+ `/photos/${this.userId}/public/${this.token}`,
+ this.abortController.signal,
+ )
+
+ const fileIds = fetchedFiles
+ .map(file => file.fileid.toString())
+
+ this.appendFiles(fetchedFiles)
+
+ if (fetchedFiles.length > 0) {
+ await this.$store.commit('addFilesToPublicAlbum', { collectionId: this.albumName, fileIdsToAdd: fileIds })
+ }
+
+ return fetchedFiles
+ } catch (error) {
+ if (error.response?.status === 404) {
+ this.errorFetchingFiles = 404
+ return []
+ }
+
+ this.errorFetchingFiles = error
+
+ showError(this.t('photos', 'Failed to fetch albums list.'))
+ logger.error('[PublicAlbumContent] Error fetching album files', error)
+ } finally {
+ this.loadingFiles = false
+ this.semaphore.release(semaphoreSymbol)
+ this.fetchSemaphore.release(fetchSemaphoreSymbol)
+ }
+
+ return []
+ },
+
+ async handleFilesPicked(fileIds) {
+ this.showAddPhotosModal = false
+ await this.addFilesToPublicAlbum({ collectionName: this.albumName, fileIdsToAdd: fileIds })
+ // Re-fetch album content to have the proper filenames.
+ await this.fetchAlbumContent()
+ },
+
+ async handleRemoveFilesFromAlbum(fileIds) {
+ this.$refs.collectionContent.onUncheckFiles(fileIds)
+ await this.removeFilesFromPublicAlbum({ collectionName: this.albumName, fileIdsToRemove: fileIds })
+ },
+ },
+}
+</script>
+<style lang="scss" scoped>
+.album {
+ display: flex;
+ flex-direction: column;
+
+ &__title {
+ width: 100%;
+ }
+
+ &__name {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ &__location {
+ margin-left: -4px;
+ display: flex;
+ color: var(--color-text-lighter);
+ }
+}
+</style>
diff --git a/src/views/SharedAlbumContent.vue b/src/views/SharedAlbumContent.vue
index dde0656e..ab3848e2 100644
--- a/src/views/SharedAlbumContent.vue
+++ b/src/views/SharedAlbumContent.vue
@@ -49,12 +49,11 @@
type="tertiary"
:aria-label="t('photos', 'Add photos to this album')"
@click="showAddPhotosModal = true">
- <template #icon>
- <Plus />
- </template>
+ <Plus slot="icon" />
</NcButton>
<NcActions :force-menu="true" :aria-label="t('photos', 'Open actions menu')">
+ <!-- TODO: enable download on shared albums -->
<!-- <ActionDownload v-if="albumFileIds.length > 0"
:selected-file-ids="albumFileIds"
:title="t('photos', 'Download all files in album')">
@@ -70,6 +69,7 @@
<template v-if="selectedFileIds.length > 0">
<NcActionSeparator />
+ <!-- TODO: enable download on shared albums -->
<!-- <ActionDownload :selected-file-ids="selectedFileIds" :title="t('photos', 'Download selected files')">
<Download slot="icon" />
</ActionDownload> -->
@@ -263,7 +263,7 @@ export default {
}
// cancelled request, moving on...
- logger.error('Error fetching shared album files', error)
+ logger.error('[SharedAlbumContent] Error fetching album files', error)
} finally {
this.loadingFiles = false
this.semaphore.release(semaphoreSymbol)
diff --git a/src/views/SharedAlbums.vue b/src/views/SharedAlbums.vue
index 09250bb5..c2541cff 100644
--- a/src/views/SharedAlbums.vue
+++ b/src/views/SharedAlbums.vue
@@ -22,8 +22,6 @@
<template>
<CollectionsList :collections="sharedAlbums"
:loading="loadingAlbums"
- :collection-title="t('photos', 'Shared albums')"
- :collection-root="t('photos', 'Shared albums')"
:error="errorFetchingAlbums"
class="albums-list">
<HeaderNavigation key="navigation"
@@ -31,7 +29,7 @@
:loading="loadingAlbums"
:title="t('photos', 'Shared albums')"
:root-title="t('photos', 'Shared albums')"
- @refresh="onRefresh" />
+ @refresh="fetchAlbums" />
<CollectionCover :key="collection.basename"
slot-scope="{collection}"
@@ -90,12 +88,6 @@ export default {
mixins: [
FetchSharedAlbumsMixin,
],
-
- methods: {
- onRefresh() {
- this.fetchAlbums()
- },
- },
}
</script>
<style lang="scss" scoped>
diff --git a/webpack.js b/webpack.js
index b955f123..4e15e523 100644
--- a/webpack.js
+++ b/webpack.js
@@ -9,6 +9,11 @@ const BabelLoaderExcludeNodeModulesExcept = require('babel-loader-exclude-node-m
const WorkboxPlugin = require('workbox-webpack-plugin')
const { basename } = require('path')
+webpackConfig.entry = {
+ main: path.join(__dirname, 'src', 'main.js'),
+ public: path.join(__dirname, 'src', 'public.js'),
+}
+
webpackRules.RULE_JS.exclude = BabelLoaderExcludeNodeModulesExcept([
'@essentials/request-timeout',
'@nextcloud/event-bus',
@@ -43,7 +48,7 @@ webpackConfig.plugins.push(
// patch webdav/dist/request.js
new webpack.NormalModuleReplacementPlugin(
/request(\.js)?/,
- function(resource) {
+ function (resource) {
if (resource.context.indexOf('webdav') > -1) {
console.debug('Patched request for webdav', basename(resource.contextInfo.issuer))
resource.request = path.join(__dirname, 'src/patchedRequest.js')