Merge pull request #1278 from nextcloud/feat/public_album

Add public link logic
This commit is contained in:
Louis 2022-10-10 12:48:33 +02:00 committed by GitHub
commit 912dc1976e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
98 changed files with 1826 additions and 375 deletions

View File

@ -10,9 +10,10 @@
<author mail="skjnldsv@protonmail.com">John Molakvoæ</author>
<namespace>Photos</namespace>
<category>multimedia</category>
<types>
<dav/>
</types>
<types>
<dav />
<authentication />
</types>
<website>https://github.com/nextcloud/photos</website>
<bugs>https://github.com/nextcloud/photos/issues</bugs>
@ -29,12 +30,13 @@
</navigation>
</navigations>
<sabre>
<collections>
<collection>OCA\Photos\Sabre\RootCollection</collection>
</collections>
<plugins>
<plugin>OCA\Photos\Sabre\Album\PropFindPlugin</plugin>
</plugins>
</sabre>
<sabre>
<collections>
<collection>OCA\Photos\Sabre\RootCollection</collection>
<collection>OCA\Photos\Sabre\PublicRootCollection</collection>
</collections>
<plugins>
<plugin>OCA\Photos\Sabre\Album\PropFindPlugin</plugin>
</plugins>
</sabre>
</info>

View File

@ -46,6 +46,11 @@ return [
'path' => '',
]
],
[ 'name' => 'publicAlbum#get', 'url' => '/public/{token}', 'verb' => 'GET',
'requirements' => [
'token' => '.*',
],
],
['name' => 'page#index', 'url' => '/folders/{path}', 'verb' => 'GET', 'postfix' => 'folders',
'requirements' => [
'path' => '.*',
@ -74,9 +79,6 @@ return [
'requirements' => [
'path' => '.*',
],
'defaults' => [
'path' => '',
]
],
['name' => 'page#index', 'url' => '/tags/{path}', 'verb' => 'GET', 'postfix' => 'tags',
'requirements' => [
@ -127,5 +129,14 @@ return [
'fileId' => '.*',
]
],
[
'name' => 'publicPreview#index',
'url' => '/api/v1/publicPreview/{fileId}',
'verb' => 'GET',
'requirements' => [
'fileId' => '.*',
]
],
]
];

Binary file not shown.

Binary file not shown.

BIN
js/photos-public.js Normal file

Binary file not shown.

Binary file not shown.

BIN
js/photos-public.js.map Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -28,11 +28,13 @@ use OCA\Photos\Exception\AlreadyInAlbumException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\IMimeTypeLoader;
use OCP\Security\ISecureRandom;
use OCP\IDBConnection;
use OCP\IGroup;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IGroupManager;
use OCP\IL10N;
class AlbumMapper {
private IDBConnection $connection;
@ -40,6 +42,8 @@ class AlbumMapper {
private ITimeFactory $timeFactory;
private IUserManager $userManager;
private IGroupManager $groupManager;
protected IL10N $l;
protected ISecureRandom $random;
// Same mapping as IShare.
public const TYPE_USER = 0;
@ -51,13 +55,17 @@ class AlbumMapper {
IMimeTypeLoader $mimeTypeLoader,
ITimeFactory $timeFactory,
IUserManager $userManager,
IGroupManager $groupManager
IGroupManager $groupManager,
IL10N $l,
ISecureRandom $random
) {
$this->connection = $connection;
$this->mimeTypeLoader = $mimeTypeLoader;
$this->timeFactory = $timeFactory;
$this->userManager = $userManager;
$this->groupManager = $groupManager;
$this->l = $l;
$this->random = $random;
}
public function create(string $userId, string $name, string $location = ""): AlbumInfo {
@ -294,27 +302,30 @@ 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(),
'type' => $row['collaborator_type'],
'label' => $displayName,
'type' => (int)$row['collaborator_type'],
];
}, $rows);
@ -323,13 +334,18 @@ class AlbumMapper {
/**
* @param int $albumId
* @param array{'id': string, 'label': string, 'type': int} $collaborators
* @param array{'id': string, 'type': int} $collaborators
*/
public function setCollaborators(int $albumId, array $collaborators): void {
$existingCollaborators = $this->getCollaborators($albumId);
$collaboratorsToAdd = array_udiff($collaborators, $existingCollaborators, fn ($a, $b) => strcmp($a['id'].$a['type'], $b['id'].$b['type']));
$collaboratorsToRemove = array_udiff($existingCollaborators, $collaborators, fn ($a, $b) => strcmp($a['id'].$a['type'], $b['id'].$b['type']));
// Different behavior if type is link to prevent creating multiple link.
function computeKey($c) {
return ($c['type'] === AlbumMapper::TYPE_LINK ? '' : $c['id']).$c['type'];
}
$collaboratorsToAdd = array_udiff($collaborators, $existingCollaborators, fn ($a, $b) => strcmp(computeKey($a), computeKey($b)));
$collaboratorsToRemove = array_udiff($existingCollaborators, $collaborators, fn ($a, $b) => strcmp(computeKey($a), computeKey($b)));
$this->connection->beginTransaction();
@ -345,6 +361,9 @@ class AlbumMapper {
throw new \Exception('Unknown collaborator: ' . $collaborator['id']);
}
break;
case self::TYPE_LINK:
$collaborator['id'] = $this->random->generate(32, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS);
break;
default:
throw new \Exception('Invalid collaborator type: ' . $collaborator['type']);
}
@ -397,11 +416,17 @@ 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])) {
$albumsById[$albumId] = new AlbumInfo($albumId, $row['album_user'], $row['album_name'].' ('.$row['album_user'].')', $row['location'], (int)$row['created'], (int)$row['last_added_photo']);
$albumName = $row['album_name'];
// Suffix album name with the album owner to prevent duplicates.
// Not done for public link as it would like owner's uid.
if ($collaboratorType !== self::TYPE_LINK) {
$row['album_name'].' ('.$row['album_user'].')';
}
$albumsById[$albumId] = new AlbumInfo($albumId, $row['album_user'], $albumName, $row['location'], (int)$row['created'], (int)$row['last_added_photo']);
}
}
@ -433,7 +458,7 @@ class AlbumMapper {
* @param int $fileId
* @return AlbumInfo[]
*/
public function getAlbumForCollaboratorIdAndFileId(string $collaboratorId, int $collaboratorType, int $fileId): array {
public function getAlbumsForCollaboratorIdAndFileId(string $collaboratorId, int $collaboratorType, int $fileId): array {
$query = $this->connection->getQueryBuilder();
$rows = $query
->select("a.album_id", "name", "user", "location", "created", "last_added_photo")

View File

@ -25,6 +25,8 @@ declare(strict_types=1);
namespace OCA\Photos\AppInfo;
use OCA\DAV\Events\SabrePluginAuthInitEvent;
use OCA\Photos\Listener\SabrePluginAuthInitListener;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\Photos\Listener\CacheEntryRemovedListener;
use OCP\AppFramework\App;
@ -64,6 +66,7 @@ class Application extends App implements IBootstrap {
/** Register $principalBackend for the DAV collection */
$context->registerServiceAlias('principalBackend', Principal::class);
$context->registerEventListener(CacheEntryRemovedEvent::class, CacheEntryRemovedListener::class);
$context->registerEventListener(SabrePluginAuthInitEvent::class, SabrePluginAuthInitListener::class);
}
public function boot(IBootContext $context): void {

View File

@ -43,16 +43,16 @@ use OCP\IUserSession;
class PreviewController extends Controller {
private IUserSession $userSession;
private Folder $userFolder;
private ?Folder $userFolder;
private IRootFolder $rootFolder;
private AlbumMapper $albumMapper;
protected AlbumMapper $albumMapper;
private IPreview $preview;
private IGroupManager $groupManager;
public function __construct(
IRequest $request,
IUserSession $userSession,
Folder $userFolder,
?Folder $userFolder,
IRootFolder $rootFolder,
AlbumMapper $albumMapper,
IPreview $preview,
@ -67,7 +67,6 @@ class PreviewController extends Controller {
$this->preview = $preview;
$this->groupManager = $groupManager;
}
/**
* @NoAdminRequired
* @NoCSRFRequired
@ -85,25 +84,35 @@ class PreviewController extends Controller {
}
$user = $this->userSession->getUser();
if ($user === null || $this->userFolder === null) {
return new DataResponse([], Http::STATUS_FORBIDDEN);
}
$nodes = $this->userFolder->getById($fileId);
/** @var \OCA\Photos\Album\AlbumInfo[] */
$checkedAlbums = [];
if (\count($nodes) === 0) {
$albums = $this->albumMapper->getForUserAndFile($user->getUID(), $fileId);
$receivedAlbums = $this->albumMapper->getAlbumForCollaboratorIdAndFileId($user->getUID(), AlbumMapper::TYPE_USER, $fileId);
$albums = array_merge($albums, $receivedAlbums);
$albumsOfCurrentUser = $this->albumMapper->getForUserAndFile($user->getUID(), $fileId);
$nodes = $this->getFileIdForAlbums($fileId, $albumsOfCurrentUser);
$checkedAlbums = $albumsOfCurrentUser;
}
if (\count($nodes) === 0) {
$receivedAlbums = $this->albumMapper->getAlbumsForCollaboratorIdAndFileId($user->getUID(), AlbumMapper::TYPE_USER, $fileId);
$receivedAlbums = array_udiff($checkedAlbums, $receivedAlbums, fn ($a, $b) => strcmp($a->getId(), $b->getId()));
$nodes = $this->getFileIdForAlbums($fileId, $receivedAlbums);
$checkedAlbums = array_merge($checkedAlbums, $receivedAlbums);
}
if (\count($nodes) === 0) {
$userGroups = $this->groupManager->getUserGroupIds($user);
foreach ($userGroups as $groupId) {
$albumsForGroup = $this->albumMapper->getAlbumForCollaboratorIdAndFileId($groupId, AlbumMapper::TYPE_GROUP, $fileId);
$albumsForGroup = array_udiff($albumsForGroup, $albums, fn ($a, $b) => $a->getId() - $b->getId());
$albums = array_merge($albums, $albumsForGroup);
}
foreach ($albums as $album) {
$albumFile = $this->albumMapper->getForAlbumIdAndFileId($album->getId(), $fileId);
$nodes = $this->rootFolder
->getUserFolder($albumFile->getOwner())
->getById($fileId);
$albumsForGroup = $this->albumMapper->getAlbumsForCollaboratorIdAndFileId($groupId, AlbumMapper::TYPE_GROUP, $fileId);
$albumsForGroup = array_udiff($checkedAlbums, $albumsForGroup, fn ($a, $b) => strcmp($a->getId(), $b->getId()));
$nodes = $this->getFileIdForAlbums($fileId, $albumsForGroup);
$checkedAlbums = array_merge($checkedAlbums, $receivedAlbums);
if (\count($nodes) !== 0) {
break;
}
@ -119,10 +128,25 @@ class PreviewController extends Controller {
return $this->fetchPreview($node, $x, $y);
}
protected function getFileIdForAlbums($fileId, $albums) {
foreach ($albums as $album) {
$albumFile = $this->albumMapper->getForAlbumIdAndFileId($album->getId(), $fileId);
$nodes = $this->rootFolder
->getUserFolder($albumFile->getOwner())
->getById($fileId);
if (\count($nodes) !== 0) {
return $nodes;
}
}
return [];
}
/**
* @return DataResponse|FileDisplayResponse
*/
private function fetchPreview(
protected function fetchPreview(
Node $node,
int $x,
int $y

View File

@ -0,0 +1,77 @@
<?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\Controller;
use OCA\Photos\AppInfo\Application;
use OCA\Viewer\Event\LoadViewer;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\Template\PublicTemplateResponse;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\AppFramework\Services\IInitialState;
use OCP\IRequest;
use OCP\Util;
class PublicAlbumController extends Controller {
private IEventDispatcher $eventDispatcher;
private IInitialState $initialState;
public function __construct(
IRequest $request,
IEventDispatcher $eventDispatcher,
IInitialState $initialState
) {
parent::__construct(Application::APP_ID, $request);
$this->eventDispatcher = $eventDispatcher;
$this->initialState = $initialState;
}
/**
* @PublicPage
* @NoCSRFRequired
*/
public function get(): PublicTemplateResponse {
$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', false);
$this->initialState->provideInitialState('recognize', false);
$this->initialState->provideInitialState('systemtags', false);
Util::addScript(Application::APP_ID, 'photos-public');
Util::addStyle(Application::APP_ID, 'icons');
$response = new PublicTemplateResponse(Application::APP_ID, 'public');
$policy = new ContentSecurityPolicy();
$policy->addAllowedWorkerSrcDomain("'self'");
$policy->addAllowedScriptDomain("'self'");
$response->setContentSecurityPolicy($policy);
return $response;
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022, Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <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 OCA\Photos\Album\AlbumMapper;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
class PublicPreviewController extends PreviewController {
/**
* @NoAdminRequired
* @NoCSRFRequired
* @PublicPage
* Render default index template
*
* @return DataResponse|FileDisplayResponse
*/
public function index(
int $fileId = -1,
int $x = 32,
int $y = 32,
string $token = null
) {
if ($fileId === -1 || $x === 0 || $y === 0) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
if ($token === null) {
return new DataResponse([], Http::STATUS_FORBIDDEN);
}
$publicAlbums = $this->albumMapper->getAlbumsForCollaboratorIdAndFileId($token, AlbumMapper::TYPE_LINK, $fileId);
$nodes = $this->getFileIdForAlbums($fileId, $publicAlbums);
if (\count($nodes) === 0) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
$node = array_pop($nodes);
return $this->fetchPreview($node, $x, $y);
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022, Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @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\Listener;
use OCA\DAV\Events\SabrePluginAuthInitEvent;
use OCA\Photos\Sabre\PublicAlbumAuthBackend;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
class SabrePluginAuthInitListener implements IEventListener {
private PublicAlbumAuthBackend $publicAlbumAuthBackend;
public function __construct(PublicAlbumAuthBackend $publicAlbumAuthBackend) {
$this->publicAlbumAuthBackend = $publicAlbumAuthBackend;
}
public function handle(Event $event): void {
if (!($event instanceof SabrePluginAuthInitEvent)) {
return;
}
$server = $event->getServer();
$authPlugin = $server->getPlugin('auth');
$authPlugin->addBackend($this->publicAlbumAuthBackend);
}
}

View File

@ -137,6 +137,9 @@ class AlbumPhoto implements IFile {
public function isFavorite(): bool {
$tagManager = \OCP\Server::get(\OCP\ITagManager::class);
$tagger = $tagManager->load('files');
if ($tagger === null) {
return false;
}
$tags = $tagger->getTagsForObjects([$this->getFileId()]);
if ($tags === false || empty($tags)) {

View File

@ -31,7 +31,6 @@ use OCA\Photos\Service\UserConfigService;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IUser;
use Sabre\DAV\Exception\Conflict;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\NotFound;
@ -43,22 +42,19 @@ class AlbumRoot implements ICollection, ICopyTarget {
protected AlbumMapper $albumMapper;
protected AlbumWithFiles $album;
protected IRootFolder $rootFolder;
protected Folder $userFolder;
protected IUser $user;
protected string $userId;
public function __construct(
AlbumMapper $albumMapper,
AlbumWithFiles $album,
IRootFolder $rootFolder,
Folder $userFolder,
IUser $user,
string $userId,
UserConfigService $userConfigService
) {
$this->albumMapper = $albumMapper;
$this->album = $album;
$this->rootFolder = $rootFolder;
$this->userFolder = $userFolder;
$this->user = $user;
$this->userId = $userId;
$this->userConfigService = $userConfigService;
}
@ -80,20 +76,31 @@ class AlbumRoot implements ICollection, ICopyTarget {
$this->albumMapper->rename($this->album->getAlbum()->getId(), $name);
}
protected function getPhotosLocationInfo() {
$photosLocation = $this->userConfigService->getUserConfig('photosLocation');
$userFolder = $this->rootFolder->getUserFolder($this->userId);
return [$photosLocation, $userFolder];
}
/**
* 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
* @param string $name
* @param null|resource|string $data
* @return void
*/
public function createFile($name, $data = null) {
try {
// userConfigService->getUserConfig handle the path creation if missing
$photosLocation = $this->userConfigService->getUserConfig('photosLocation');
[$photosLocation, $userFolder] = $this->getPhotosLocationInfo();
// If the folder does not exists, create it.
if (!$userFolder->nodeExists($photosLocation)) {
return $userFolder->newFolder($photosLocation);
}
$photosFolder = $userFolder->get($photosLocation);
$photosFolder = $this->userFolder->get($photosLocation);
if (!($photosFolder instanceof Folder)) {
throw new Conflict('The destination exists and is not a folder');
}
@ -148,23 +155,22 @@ class AlbumRoot implements ICollection, ICopyTarget {
}
public function copyInto($targetName, $sourcePath, INode $sourceNode): bool {
$uid = $this->user->getUID();
if ($sourceNode instanceof File) {
$sourceId = $sourceNode->getId();
$ownerUID = $sourceNode->getFileInfo()->getOwner()->getUID();
return $this->addFile($sourceId, $ownerUID);
}
$uid = $this->userId;
throw new \Exception("Can't add file to album, only files from $uid can be added");
}
protected function addFile(int $sourceId, string $ownerUID): bool {
$uid = $this->user->getUID();
if (in_array($sourceId, $this->album->getFileIds())) {
throw new Conflict("File $sourceId is already in the folder");
}
if ($ownerUID === $uid) {
if ($ownerUID === $this->userId) {
$this->albumMapper->addFile($this->album->getAlbum()->getId(), $sourceId, $ownerUID);
$node = current($this->userFolder->getById($sourceId));
$node = current($this->rootFolder->getUserFolder($ownerUID)->getById($sourceId));
$this->album->addFile(new AlbumFile($sourceId, $node->getName(), $node->getMimetype(), $node->getSize(), $node->getMTime(), $node->getEtag(), $node->getCreationTime(), $ownerUID));
return true;
}
@ -212,12 +218,21 @@ class AlbumRoot implements ICollection, ICopyTarget {
}
/**
* @return array
* @return array{array{'nc:collaborator': array{'id': string, 'label': string, 'type': int}}}
*/
public function getCollaborators() {
public function getCollaborators(): array {
return array_map(
fn (array $collaborator) => [ 'nc:collaborator' => $collaborator ],
$this->albumMapper->getCollaborators($this->album->getAlbum()->getId()),
);
}
/**
* @param array{'id': string, 'type': int} $collaborators
* @return array{array{'nc:collaborator': array{'id': string, 'label': string, 'type': int}}}
*/
public function setCollaborators($collaborators): array {
$this->albumMapper->setCollaborators($this->getAlbum()->getAlbum()->getId(), $collaborators);
return $this->getCollaborators();
}
}

View File

@ -27,9 +27,7 @@ use OCA\Photos\Album\AlbumInfo;
use OCA\Photos\Album\AlbumMapper;
use OCA\Photos\Album\AlbumWithFiles;
use OCA\Photos\Service\UserConfigService;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\IUser;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\ICollection;
@ -37,11 +35,12 @@ use Sabre\DAV\ICollection;
class AlbumsHome implements ICollection {
protected AlbumMapper $albumMapper;
protected array $principalInfo;
protected IUser $user;
protected string $userId;
protected IRootFolder $rootFolder;
protected Folder $userFolder;
protected UserConfigService $userConfigService;
public const NAME = 'albums';
/**
* @var AlbumRoot[]
*/
@ -50,15 +49,14 @@ class AlbumsHome implements ICollection {
public function __construct(
array $principalInfo,
AlbumMapper $albumMapper,
IUser $user,
string $userId,
IRootFolder $rootFolder,
UserConfigService $userConfigService
) {
$this->principalInfo = $principalInfo;
$this->albumMapper = $albumMapper;
$this->user = $user;
$this->userId = $userId;
$this->rootFolder = $rootFolder;
$this->userFolder = $rootFolder->getUserFolder($user->getUID());
$this->userConfigService = $userConfigService;
}
@ -70,7 +68,7 @@ class AlbumsHome implements ICollection {
}
public function getName(): string {
return 'albums';
return self::NAME;
}
/**
@ -88,8 +86,7 @@ class AlbumsHome implements ICollection {
* @return void
*/
public function createDirectory($name) {
$uid = $this->user->getUID();
$this->albumMapper->create($uid, $name);
$this->albumMapper->create($this->userId, $name);
}
public function getChild($name) {
@ -107,9 +104,9 @@ class AlbumsHome implements ICollection {
*/
public function getChildren(): array {
if ($this->children === null) {
$albumInfos = $this->albumMapper->getForUser($this->user->getUID());
$albumInfos = $this->albumMapper->getForUser($this->userId);
$this->children = array_map(function (AlbumInfo $albumInfo) {
return new AlbumRoot($this->albumMapper, new AlbumWithFiles($albumInfo, $this->albumMapper), $this->rootFolder, $this->userFolder, $this->user, $this->userConfigService);
return new AlbumRoot($this->albumMapper, new AlbumWithFiles($albumInfo, $this->albumMapper), $this->rootFolder, $this->userId, $this->userConfigService);
}, $albumInfos);
}

View File

@ -28,15 +28,16 @@ use OCA\DAV\Connector\Sabre\FilesPlugin;
use OCA\Photos\Album\AlbumMapper;
use OCP\IConfig;
use OCP\IPreview;
use OCP\Files\NotFoundException;
use Sabre\DAV\INode;
use Sabre\DAV\PropFind;
use Sabre\DAV\PropPatch;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\DAV\Tree;
use OCP\Files\NotFoundException;
class PropFindPlugin extends ServerPlugin {
public const ORIGINAL_NAME_PROPERTYNAME = '{http://nextcloud.org/ns}original-name';
public const FILE_NAME_PROPERTYNAME = '{http://nextcloud.org/ns}file-name';
public const FAVORITE_PROPERTYNAME = '{http://owncloud.org/ns}favorite';
public const DATE_RANGE_PROPERTYNAME = '{http://nextcloud.org/ns}dateRange';
@ -67,6 +68,19 @@ class PropFindPlugin extends ServerPlugin {
$this->metadataEnabled = $this->config->getSystemValueBool('enable_file_metadata', true);
}
/**
* Returns a plugin name.
*
* Using this name other plugins will be able to access other plugins
* using DAV\Server::getPlugin
*
* @return string
*/
public function getPluginName() {
return 'photosDavPlugin';
}
/**
* @return void
*/
@ -78,7 +92,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();
@ -90,9 +104,7 @@ class PropFindPlugin extends ServerPlugin {
$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::HAS_PREVIEW_PROPERTYNAME, fn () => json_encode($this->previewManager->isAvailable($fileInfo)));
if ($this->metadataEnabled) {
$propFind->handle(FilesPlugin::FILE_METADATA_SIZE, function () use ($node) {
@ -111,7 +123,8 @@ class PropFindPlugin extends ServerPlugin {
}
}
if ($node instanceof AlbumRoot || $node instanceof SharedAlbumRoot) {
if ($node instanceof AlbumRoot) {
$propFind->handle(self::ORIGINAL_NAME_PROPERTYNAME, fn () => $node->getAlbum()->getAlbum()->getTitle());
$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());
@ -141,7 +154,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;
});
}

View File

@ -0,0 +1,68 @@
<?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\INode;
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');
}
public function copyInto($targetName, $sourcePath, INode $sourceNode): bool {
throw new Forbidden('Not allowed to copy into a public album');
}
protected function getPhotosLocationInfo() {
$albumOwner = $this->album->getAlbum()->getUserId();
$photosLocation = $this->userConfigService->getConfigForUser($albumOwner, 'photosLocation');
$userFolder = $this->rootFolder->getUserFolder($albumOwner);
return [$photosLocation, $userFolder];
}
protected function addFile(int $sourceId, string $ownerUID): bool {
throw new Forbidden('Not allowed to create a file in a public album');
}
// Do not reveal collaborators for public albums.
public function getCollaborators(): array {
/** @var array{array{'nc:collaborator': array{'id': string, 'label': string, 'type': int}}} */
return [];
}
public function setCollaborators($collaborators): array {
throw new Forbidden('Not allowed to collaborators a public album');
}
}

View File

@ -32,7 +32,7 @@ class SharedAlbumRoot extends AlbumRoot {
* @return void
*/
public function delete() {
$this->albumMapper->deleteUserFromAlbumCollaboratorsList($this->user->getUID(), $this->album->getAlbum()->getId());
$this->albumMapper->deleteUserFromAlbumCollaboratorsList($this->userId, $this->album->getAlbum()->getId());
}
/**
@ -51,12 +51,23 @@ class SharedAlbumRoot extends AlbumRoot {
fn ($collaborator) => $collaborator['type'].':'.$collaborator['id'],
$this->albumMapper->getCollaborators($this->album->getAlbum()->getId()),
);
$uid = $this->user->getUID();
if (!in_array(AlbumMapper::TYPE_USER.':'.$uid, $collaboratorIds)) {
if (!in_array(AlbumMapper::TYPE_USER.':'.$this->userId, $collaboratorIds)) {
return false;
}
$this->albumMapper->addFile($this->album->getAlbum()->getId(), $sourceId, $ownerUID);
return true;
}
/**
* Do not reveal collaborators for shared albums.
*/
public function getCollaborators(): array {
/** @var array{array{'nc:collaborator': array{'id': string, 'label': string, 'type': int}}} */
return [];
}
public function setCollaborators($collaborators): array {
throw new Forbidden('Not allowed to collaborators a public album');
}
}

View File

@ -26,38 +26,38 @@ namespace OCA\Photos\Sabre\Album;
use OCA\Photos\Album\AlbumWithFiles;
use OCA\Photos\Service\UserConfigService;
use Sabre\DAV\Exception\Forbidden;
use OCP\IUserManager;
use OCP\IGroupManager;
use OCA\Photos\Album\AlbumMapper;
use OCP\Files\IRootFolder;
use OCP\IUser;
class SharedAlbumsHome extends AlbumsHome {
private IUserManager $userManager;
private IGroupManager $groupManager;
public const NAME = 'sharedalbums';
public function __construct(
array $principalInfo,
AlbumMapper $albumMapper,
IUser $user,
string $userId,
IRootFolder $rootFolder,
IUserManager $userManager,
IGroupManager $groupManager,
UserConfigService $userConfigService
) {
parent::__construct(
$principalInfo,
$albumMapper,
$user,
$userId,
$rootFolder,
$userConfigService
);
$this->userManager = $userManager;
$this->groupManager = $groupManager;
}
public function getName(): string {
return 'sharedalbums';
}
/**
* @return never
*/
@ -66,23 +66,23 @@ class SharedAlbumsHome extends AlbumsHome {
}
/**
* @return AlbumRoot[]
* @return SharedAlbumRoot[]
*/
public function getChildren(): array {
if ($this->children === null) {
$albums = $this->albumMapper->getSharedAlbumsForCollaboratorWithFiles($this->user->getUID(), AlbumMapper::TYPE_USER);
$albums = $this->albumMapper->getSharedAlbumsForCollaboratorWithFiles($this->userId, AlbumMapper::TYPE_USER);
$userGroups = $this->groupManager->getUserGroupIds($this->user);
$user = $this->userManager->get($this->userId);
$userGroups = $this->groupManager->getUserGroupIds($user);
foreach ($userGroups as $groupId) {
$albumsForGroup = $this->albumMapper->getSharedAlbumsForCollaboratorWithFiles($groupId, AlbumMapper::TYPE_GROUP);
$albumsForGroup = array_udiff($albumsForGroup, $albums, fn ($a, $b) => $a->getAlbum()->getId() - $b->getAlbum()->getId());
$albums = array_merge($albums, $albumsForGroup);
}
$this->children = array_map(function (AlbumWithFiles $folder) {
return new SharedAlbumRoot($this->albumMapper, $folder, $this->rootFolder, $this->userFolder, $this->user, $this->userConfigService);
$this->children = array_map(function (AlbumWithFiles $album) {
return new SharedAlbumRoot($this->albumMapper, $album, $this->rootFolder, $this->userId, $this->userConfigService);
}, $albums);
;
}
return $this->children;

View File

@ -28,7 +28,7 @@ use OCA\Photos\Sabre\Album\AlbumsHome;
use OCA\Photos\Sabre\Album\SharedAlbumsHome;
use OCA\Photos\Service\UserConfigService;
use OCP\Files\IRootFolder;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IGroupManager;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\NotFound;
@ -37,23 +37,26 @@ use Sabre\DAV\ICollection;
class PhotosHome implements ICollection {
private AlbumMapper $albumMapper;
private array $principalInfo;
private IUser $user;
private string $userId;
private IRootFolder $rootFolder;
private IUserManager $userManager;
private IGroupManager $groupManager;
private UserConfigService $userConfigService;
public function __construct(
array $principalInfo,
AlbumMapper $albumMapper,
IUser $user,
string $userId,
IRootFolder $rootFolder,
IUserManager $userManager,
IGroupManager $groupManager,
UserConfigService $userConfigService
) {
$this->principalInfo = $principalInfo;
$this->albumMapper = $albumMapper;
$this->user = $user;
$this->userId = $userId;
$this->rootFolder = $rootFolder;
$this->userManager = $userManager;
$this->groupManager = $groupManager;
$this->userConfigService = $userConfigService;
}
@ -89,24 +92,28 @@ class PhotosHome implements ICollection {
}
public function getChild($name) {
if ($name === 'albums') {
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);
switch ($name) {
case AlbumsHome::NAME:
return new AlbumsHome($this->principalInfo, $this->albumMapper, $this->userId, $this->rootFolder, $this->userConfigService);
case SharedAlbumsHome::NAME:
return new SharedAlbumsHome($this->principalInfo, $this->albumMapper, $this->userId, $this->rootFolder, $this->userManager, $this->groupManager, $this->userConfigService);
}
throw new NotFound();
}
/**
* @return (AlbumsHome|SharedAlbumsHome)[]
* @return (AlbumsHome)[]
*/
public function getChildren(): array {
return [new AlbumsHome($this->principalInfo, $this->albumMapper, $this->user, $this->rootFolder, $this->userConfigService)];
return [
new AlbumsHome($this->principalInfo, $this->albumMapper, $this->userId, $this->rootFolder, $this->userConfigService),
new SharedAlbumsHome($this->principalInfo, $this->albumMapper, $this->userId, $this->rootFolder, $this->userManager, $this->groupManager, $this->userConfigService),
];
}
public function childExists($name): bool {
return $name === 'albums' || $name === 'sharedalbums';
return $name === AlbumsHome::NAME || $name === SharedAlbumsHome::NAME;
}
public function getLastModified(): int {

View File

@ -0,0 +1,80 @@
<?php
/**
* @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* 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, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\Photos\Sabre;
use OC\Security\Bruteforce\Throttler;
use OCA\Photos\Album\AlbumMapper;
use OCA\Photos\Album\AlbumWithFiles;
use OCP\IRequest;
use Sabre\DAV\Auth\Backend\AbstractBasic;
class PublicAlbumAuthBackend extends AbstractBasic {
private const BRUTEFORCE_ACTION = 'public_webdav_auth';
private ?AlbumWithFiles $album = null;
private IRequest $request;
private AlbumMapper $albumMapper;
private Throttler $throttler;
public function __construct(
IRequest $request,
AlbumMapper $albumMapper,
Throttler $throttler
) {
$this->request = $request;
$this->albumMapper = $albumMapper;
$this->throttler = $throttler;
// setup realm
$defaults = new \OCP\Defaults();
$this->realm = $defaults->getName();
}
/**
* Validates the token.
*
* @param string $username
* @return bool
* @throws \Sabre\DAV\Exception\NotAuthenticated
*/
protected function validateUserPass($username, $password) {
$this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), self::BRUTEFORCE_ACTION);
$albums = $this->albumMapper->getSharedAlbumsForCollaboratorWithFiles($username, AlbumMapper::TYPE_LINK);
if (count($albums) !== 1) {
$this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress());
return false;
}
$this->album = $albums[0];
\OC_User::setIncognitoMode(true);
return true;
}
public function getShare(): AlbumWithFiles {
assert($this->album !== null);
return $this->album;
}
}

View File

@ -0,0 +1,87 @@
<?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;
use OCA\Photos\Album\AlbumMapper;
use OCA\Photos\Sabre\Album\PublicAlbumRoot;
use OCA\Photos\Service\UserConfigService;
use OCP\Files\IRootFolder;
use Sabre\DAVACL\AbstractPrincipalCollection;
use Sabre\DAVACL\PrincipalBackend;
use Sabre\DAV\Exception\NotFound;
class PublicRootCollection extends AbstractPrincipalCollection {
private AlbumMapper $albumMapper;
private IRootFolder $rootFolder;
private UserConfigService $userConfigService;
public function __construct(
AlbumMapper $albumMapper,
IRootFolder $rootFolder,
PrincipalBackend\BackendInterface $principalBackend,
UserConfigService $userConfigService
) {
parent::__construct($principalBackend, 'principals/token');
$this->albumMapper = $albumMapper;
$this->rootFolder = $rootFolder;
$this->userConfigService = $userConfigService;
}
public function getName(): string {
return 'photospublic';
}
/**
* Child are retrieved directly by getChild.
* This should never be called.
* @param array $principalInfo
*/
public function getChildForPrincipal(array $principalInfo): PublicAlbumRoot {
throw new \Sabre\DAV\Exception\Forbidden();
}
/**
* Returns a child object, by its token.
*
* @param string $token
*
* @throws NotFound
*
* @return DAV\INode
*/
public function getChild($token) {
if (is_null($token)) {
throw new \Sabre\DAV\Exception\Forbidden();
}
$albums = $this->albumMapper->getSharedAlbumsForCollaboratorWithFiles($token, AlbumMapper::TYPE_LINK);
if (count($albums) !== 1) {
throw new NotFound("Unable to find public album");
}
return new PublicAlbumRoot($this->albumMapper, $albums[0], $this->rootFolder, $albums[0]->getAlbum()->getUserId(), $this->userConfigService);
}
}

View File

@ -29,12 +29,14 @@ use OCP\Files\IRootFolder;
use OCP\IUserSession;
use Sabre\DAVACL\AbstractPrincipalCollection;
use Sabre\DAVACL\PrincipalBackend;
use OCP\IUserManager;
use OCP\IGroupManager;
class RootCollection extends AbstractPrincipalCollection {
private AlbumMapper $folderMapper;
private IUserSession $userSession;
private IRootFolder $rootFolder;
private IUserManager $userManager;
private IGroupManager $groupManager;
private UserConfigService $userConfigService;
@ -43,6 +45,7 @@ class RootCollection extends AbstractPrincipalCollection {
IUserSession $userSession,
IRootFolder $rootFolder,
PrincipalBackend\BackendInterface $principalBackend,
IUserManager $userManager,
IGroupManager $groupManager,
UserConfigService $userConfigService
) {
@ -51,6 +54,7 @@ class RootCollection extends AbstractPrincipalCollection {
$this->folderMapper = $folderMapper;
$this->userSession = $userSession;
$this->rootFolder = $rootFolder;
$this->userManager = $userManager;
$this->groupManager = $groupManager;
$this->userConfigService = $userConfigService;
}
@ -70,7 +74,7 @@ class RootCollection extends AbstractPrincipalCollection {
if (is_null($user) || $name !== $user->getUID()) {
throw new \Sabre\DAV\Exception\Forbidden();
}
return new PhotosHome($principalInfo, $this->folderMapper, $user, $this->rootFolder, $this->groupManager, $this->userConfigService);
return new PhotosHome($principalInfo, $this->folderMapper, $name, $this->rootFolder, $this->userManager, $this->groupManager, $this->userConfigService);
}
public function getName(): string {

View File

@ -51,22 +51,18 @@ class UserConfigService {
$this->rootFolder = $rootFolder;
}
public function getUserConfig(string $key) {
public function getUserConfig(string $key): string {
$user = $this->userSession->getUser();
return $this->getConfigForUser($user->getUid(), $key);
}
public function getConfigForUser(string $userId, string $key): string {
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);
// If the config is a path, make sure it exists
if (str_starts_with($default, '/')) {
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
// If the folder does not exists, create it
if (!$userFolder->nodeExists($value)) {
$userFolder->newFolder($value);
}
}
$default = self::DEFAULT_CONFIGS[$key];
$value = $this->config->getUserValue($userId, Application::APP_ID, $key, $default);
return $value;
}

17
package-lock.json generated
View File

@ -21,6 +21,7 @@
"@nextcloud/moment": "^1.2.1",
"@nextcloud/paths": "^2.1.0",
"@nextcloud/router": "^2.0.0",
"@nextcloud/sharing": "^0.1.0",
"@nextcloud/upload": "^1.0.0-beta.8",
"@nextcloud/vue": "^7.0.0-beta.4",
"camelcase": "^7.0.0",
@ -3395,6 +3396,14 @@
"core-js": "^3.6.4"
}
},
"node_modules/@nextcloud/sharing": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@nextcloud/sharing/-/sharing-0.1.0.tgz",
"integrity": "sha512-Cv4uc1aFrA18w0dltq7a5om/EbJSXf36rtO0LP3vi42E6l8ZDVCZwHLKrsZZa/TXNLeYErs1g/6tmWx5xiSSow==",
"dependencies": {
"core-js": "^3.6.4"
}
},
"node_modules/@nextcloud/stylelint-config": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nextcloud/stylelint-config/-/stylelint-config-2.2.0.tgz",
@ -22612,6 +22621,14 @@
"core-js": "^3.6.4"
}
},
"@nextcloud/sharing": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@nextcloud/sharing/-/sharing-0.1.0.tgz",
"integrity": "sha512-Cv4uc1aFrA18w0dltq7a5om/EbJSXf36rtO0LP3vi42E6l8ZDVCZwHLKrsZZa/TXNLeYErs1g/6tmWx5xiSSow==",
"requires": {
"core-js": "^3.6.4"
}
},
"@nextcloud/stylelint-config": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nextcloud/stylelint-config/-/stylelint-config-2.2.0.tgz",

View File

@ -49,6 +49,7 @@
"@nextcloud/moment": "^1.2.1",
"@nextcloud/paths": "^2.1.0",
"@nextcloud/router": "^2.0.0",
"@nextcloud/sharing": "^0.1.0",
"@nextcloud/upload": "^1.0.0-beta.8",
"@nextcloud/vue": "^7.0.0-beta.4",
"camelcase": "^7.0.0",

View File

@ -1,13 +1,7 @@
<?xml version="1.0"?>
<psalm
errorLevel="4"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
errorBaseline="tests/psalm-baseline.xml"
>
<psalm errorLevel="4" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" errorBaseline="tests/psalm-baseline.xml">
<stubs>
<file name="tests/stub.phpstub" preloadClasses="true"/>
<file name="tests/stub.phpstub" preloadClasses="true" />
</stubs>
<projectFiles>
<directory name="lib" />
@ -28,6 +22,9 @@
<referencedClass name="OCA\DAV\Connector\Sabre\Principal" />
<referencedClass name="OCA\DAV\Connector\Sabre\File" />
<referencedClass name="OCA\DAV\Connector\Sabre\FilesPlugin" />
<referencedClass name="OCA\DAV\Events\SabrePluginAuthInitEvent" />
<referencedClass name="OC\Security\Bruteforce\Throttler" />
<referencedClass name="OC_User" />
</errorLevel>
</UndefinedClass>
<UndefinedDocblockClass>

View File

@ -122,6 +122,7 @@ import videoplaceholder from './assets/video.svg'
import areTagsInstalled from './services/AreTagsInstalled.js'
import isMapsInstalled from './services/IsMapsInstalled.js'
import isRecognizeInstalled from './services/IsRecognizeInstalled.js'
import logger from './services/logger.js'
export default {
name: 'Photos',
@ -172,14 +173,14 @@ export default {
}), {
scope: generateUrl('/apps/photos'),
}).then(registration => {
console.debug('SW registered: ', registration)
logger.debug('SW registered: ', { registration })
}).catch(registrationError => {
console.error('SW registration failed: ', registrationError)
logger.error('SW registration failed: ', { registrationError })
})
})
} else {
console.debug('Service Worker is not enabled on this browser.')
logger.debug('Service Worker is not enabled on this browser.')
}
const files = loadState('photos', 'nomedia-paths', [])

100
src/PhotosPublic.vue Normal file
View File

@ -0,0 +1,100 @@
<!--
- @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
-
- @author Louis Chmn <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>
<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 logger from './services/logger.js'
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', async () => {
try {
const url = generateUrl('/apps/photos/service-worker.js', {}, { noRewrite: true })
const registration = await navigator.serviceWorker.register(url, { scope: generateUrl('/apps/photos') })
logger.debug('SW registered: ', { registration })
} catch (error) {
logger.error('SW registration failed: ', { error })
}
})
} else {
logger.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>

View File

@ -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,10 @@
<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')"
:disabled="publicLink.id === ''"
@click="copyPublicLink">
<template v-if="publicLinkCopied">
{{ t('photos', 'Public link copied!') }}
@ -101,8 +103,12 @@
<ContentCopy v-else />
</template>
</NcButton>
<NcButton @click="deletePublicLink">
<Close slot="icon" />
<NcButton type="tertiary"
:aria-label="t('photos', 'Delete the public link')"
:disabled="publicLink.id === ''"
@click="deletePublicLink">
<NcLoadingIcon v-if="publicLink.id === ''" slot="icon" />
<Close v-else slot="icon" />
</NcButton>
</template>
<NcButton v-else
@ -119,28 +125,33 @@
</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 { Type } from '@nextcloud/sharing'
import logger from '../../services/logger.js'
import AbortControllerMixin from '../../mixins/AbortControllerMixin.js'
import { fetchAlbum } from '../../services/Albums.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 {Type.SHARE_TYPE_USER|Type.SHARE_TYPE_GROUP|Type.SHARE_TYPE_LINK} type - The type of the collaborator.
*/
export default {
name: 'CollaboratorsSelectionForm',
@ -149,6 +160,8 @@ export default {
Magnify,
Close,
AccountGroup,
ContentCopy,
Check,
Earth,
NcLoadingIcon,
NcButton,
@ -158,22 +171,20 @@ export default {
NcEmptyContent,
},
mixins: [AbortControllerMixin],
props: {
albumName: {
type: String,
required: true,
},
/** @type {import('vue').PropType<Collaborator[]>} */
collaborators: {
type: Array,
default: () => [],
},
publicLink: {
type: String,
default: '',
},
allowPublicLink: {
type: Boolean,
default: true,
@ -183,9 +194,14 @@ export default {
data() {
return {
searchText: '',
/** @type {Object<string, Collaborator>} */
availableCollaborators: {},
/** @type {string[]} */
selectedCollaboratorsKeys: [],
/** @type {Collaborator[]} */
currentSearchResults: [],
loadingAlbum: false,
errorFetchingAlbum: null,
loadingCollaborators: false,
randomId: Math.random().toString().substring(2, 10),
publicLinkCopied: false,
@ -203,29 +219,52 @@ 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 !== Type.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.selectedCollaboratorsKeys.includes(`${Type.SHARE_TYPE_LINK}`)
},
/** @return {Collaborator} */
publicLink() {
return this.availableCollaborators[Type.SHARE_TYPE_LINK]
},
},
watch: {
collaborators(collaborators) {
this.populateCollaborators(collaborators)
},
},
mounted() {
this.searchCollaborators()
this.selectedCollaboratorsKeys = this.collaborators.map(({ type, id }) => `${type}:${id}`)
this.availableCollaborators = {
...this.availableCollaborators,
...this.collaborators
.reduce((collaborators, collaborator) => ({ ...collaborators, [`${collaborator.type}:${collaborator.id}`]: collaborator }), {}),
}
this.populateCollaborators(this.collaborators)
},
methods: {
...mapActions(['updateAlbum', 'addAlbums']),
/**
* Fetch possible collaborators.
*/
@ -241,30 +280,27 @@ export default {
search: this.searchText,
itemType: 'share-recipients',
shareTypes: [
SHARE.TYPE.USER,
SHARE.TYPE.GROUP,
Type.SHARE_TYPE_USER,
Type.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: Type.SHARE_TYPE_USER }
case 'groups':
type = OC.Share.SHARE_TYPE_GROUP
break
return { id: collaborator.id, label: collaborator.label, type: Type.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 +311,89 @@ export default {
}
},
// TODO: implement public sharing
/**
* Populate selectedCollaboratorsKeys and availableCollaborators.
*
* @param {Collaborator[]} collaborators
*/
populateCollaborators(collaborators) {
const initialCollaborators = collaborators.reduce(this.indexCollaborators, {})
this.selectedCollaboratorsKeys = Object.keys(initialCollaborators)
this.availableCollaborators = {
3: {
id: '',
label: t('photos', 'Public link'),
type: Type.SHARE_TYPE_LINK,
},
...this.availableCollaborators,
...initialCollaborators,
}
},
/**
* @param {Object<string, Collaborator>} collaborators - Index of collaborators
* @param {Collaborator} collaborator - A collaborator
*/
indexCollaborators(collaborators, collaborator) {
return { ...collaborators, [`${collaborator.type}${collaborator.type === Type.SHARE_TYPE_LINK ? '' : ':'}${collaborator.type === Type.SHARE_TYPE_LINK ? '' : collaborator.id}`]: collaborator }
},
async createPublicLinkForAlbum() {
return axios.put(generateOcsUrl(`apps/photos/createPublicLink/${this.albumName}`))
this.selectEntity(`${Type.SHARE_TYPE_LINK}`)
await this.updateAlbumCollaborators()
try {
this.loadingAlbum = true
this.errorFetchingAlbum = null
const album = await fetchAlbum(
`/photos/${getCurrentUser().uid}/albums/${this.albumName}`,
{ signal: this.abortController.signal }
)
this.addAlbums({ albums: [album] })
} 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 album.'))
} finally {
this.loadingAlbum = false
}
},
async deletePublicLink() {
return axios.delete(generateOcsUrl(`apps/photos/createPublicLink/${this.albumName}`))
this.unselectEntity(`${Type.SHARE_TYPE_LINK}`)
this.availableCollaborators[3] = {
id: '',
label: t('photos', 'Public link'),
type: Type.SHARE_TYPE_LINK,
}
this.publicLinkCopied = false
await this.updateAlbumCollaborators()
},
async updateAlbumCollaborators() {
try {
await this.updateAlbum({
albumName: this.albumName,
properties: {
collaborators: this.selectedCollaborators,
},
})
} catch (error) {
logger.error('[PublicAlbumContent] Error updating album', { error })
showError(this.t('photos', 'Failed to update album.'))
} finally {
this.loadingAlbum = false
}
},
async copyPublicLink() {
await navigator.clipboard.writeText(this.publicLink)
await navigator.clipboard.writeText(`${window.location.protocol}//${window.location.host}${generateUrl(`apps/photos/public/${this.publicLink.id}`)}`)
this.publicLinkCopied = true
setTimeout(() => {
this.publicLinkCopied = false
@ -303,12 +411,16 @@ export default {
unselectEntity(collaboratorKey) {
const index = this.selectedCollaboratorsKeys.indexOf(collaboratorKey)
if (index === -1) {
return
}
this.selectedCollaboratorsKeys.splice(index, 1)
},
},
}
</script>
<style lang="scss" scoped>
.manage-collaborators {
display: flex;
@ -396,6 +508,10 @@ export default {
&__public-link {
display: flex;
align-items: center;
button {
margin-left: 8px;
}
}
&__slot {

View File

@ -21,10 +21,10 @@
-->
<template>
<!-- Errors handlers-->
<NcEmptyContent v-if="collection === undefined && !loading"
<NcEmptyContent v-if="(collection === undefined && !loading) || error === 404"
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: {

View File

@ -201,7 +201,12 @@ export default {
},
getItemURL(size) {
return generateUrl(`/apps/photos/api/v1/preview/${this.file.fileid}?x=${size}&y=${size}`)
const token = this.$route.params.token
if (token) {
return generateUrl(`/apps/photos/api/v1/publicPreview/${this.file.fileid}?x=${size}&y=${size}&token=${token}`)
} else {
return generateUrl(`/apps/photos/api/v1/preview/${this.file.fileid}?x=${size}&y=${size}`)
}
},

View File

@ -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
}
},
},
}

View File

@ -65,7 +65,7 @@ export default {
computed: {
/** @return {import('../services/TiledLayout.js').TiledRow[]} */
rows() {
logger.debug('[TiledLayout] Computing rows', this.items)
logger.debug('[TiledLayout] Computing rows', { items: this.items })
return splitItemsInRows(this.items, this.containerWidth, this.baseHeight)
},

View File

@ -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 = await fetchAlbums(`/photos/${getCurrentUser()?.uid}/albums`, 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) }
}
})
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
}

View File

@ -88,7 +88,7 @@ export default {
this.errorFetchingFaces = error
}
}
logger.error(t('photos', 'Failed to fetch faces list.'), error)
logger.error(t('photos', 'Failed to fetch faces list.'), { error })
showError(t('photos', 'Failed to fetch faces list.'))
} finally {
this.loadingFaces = false
@ -140,7 +140,7 @@ export default {
}
// cancelled request, moving on...
logger.error('Error fetching face files', error)
logger.error('Error fetching face files', { error })
} finally {
this.loadingFiles = false
}

View File

@ -110,7 +110,7 @@ export default {
}
// cancelled request, moving on...
logger.error('Error fetching files', error)
logger.error('Error fetching files', { error })
console.error(error)
} finally {
this.loadingFiles = false

View File

@ -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 = await fetchAlbums(`/photos/${getCurrentUser()?.uid}/sharedalbums`, 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) }
}
})
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
}

View File

@ -34,8 +34,8 @@ export default {
return {
croppedLayout: croppedLayoutLocalStorage !== null
? croppedLayoutLocalStorage === 'true'
: loadState('photos', 'croppedLayout') === 'true',
photosLocation: loadState('photos', 'photosLocation'),
: loadState('photos', 'croppedLayout', 'false') === 'true',
photosLocation: loadState('photos', 'photosLocation', ''),
}
},

67
src/public.js Normal file
View File

@ -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),
})

View File

@ -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')
@ -118,6 +119,14 @@ const router = new Router({
albumName: route.params.albumName,
}),
},
{
path: '/public/:token',
component: PublicAlbumContent,
name: 'publicAlbums',
props: route => ({
token: route.params.token,
}),
},
{
path: '/folders/:path*',
component: Folders,

View File

@ -20,8 +20,13 @@
*
*/
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import moment from '@nextcloud/moment'
import { translate } from '@nextcloud/l10n'
import defaultClient 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 +40,153 @@ import { generateUrl } from '@nextcloud/router'
*/
/**
* List albums.
*
* @return {Promise<Album[]>} the album list
* @param {string} extraProps - Extra properties to add to the DAV request.
* @return {string}
*/
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',
}))
function getDavRequest(extraProps = '') {
return `<?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 />
${extraProps}
</d:prop>
</d:propfind>`
}
/**
*
* @param {string} path - Albums' root path.
* @param {import('webdav').StatOptions} options - Options to forward to the webdav client.
* @param {string} extraProps - Extra properties to add to the DAV request.
* @param {import('webdav').WebDAVClient} client - The DAV client to use.
* @return {Promise<Album|null>}
*/
export async function fetchAlbum(path, options, extraProps = '', client = defaultClient) {
try {
const response = await client.stat(path, {
data: getDavRequest(extraProps),
details: true,
...options,
})
logger.debug('[Albums] Fetched an album: ', { data: response.data })
return formatAlbum(response.data)
} catch (error) {
if (error.code === 'ERR_CANCELED') {
return null
}
throw error
}
}
/**
*
* @param {string} path - Albums' root path.
* @param {import('webdav').StatOptions} options - Options to forward to the webdav client.
* @param {string} extraProps - Extra properties to add to the DAV request.
* @param {import('webdav').WebDAVClient} client - The DAV client to use.
* @return {Promise<Album[]>}
*/
export async function fetchAlbums(path, options, extraProps = '', client = defaultClient) {
try {
const response = await client.getDirectoryContents(path, {
data: getDavRequest(extraProps),
details: true,
...options,
})
logger.debug(`[Albums] Fetched ${response.data.length} albums: `, { data: 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 dateRangeFormatted = {
startDate: moment.unix(dateRange.start).format('MMMM YYYY'),
endDate: moment.unix(dateRange.end).format('MMMM YYYY'),
}
if (dateRangeFormatted.startDate === dateRangeFormatted.endDate) {
album.date = dateRangeFormatted.startDate
} else {
album.date = translate('photos', '{startDate} to {endDate}', dateRangeFormatted)
}
return album
}
/**
*
* @param {string} path - Albums' root path.
* @param {import('webdav').StatOptions} options - Options to forward to the webdav client.
* @param {import('webdav').WebDAVClient} client - The DAV client to use.
* @return {Promise<Array>}
*/
export async function fetchAlbumContent(path, options, client = defaultClient) {
try {
const response = await client.getDirectoryContents(path, {
data: DavRequest,
details: true,
...options,
})
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
}
}

View File

@ -159,7 +159,7 @@ const actions = {
if (error.response.status !== 409) { // Already in the album.
context.commit('removeFilesFromAlbum', { albumName, fileIdsToRemove: [fileId] })
logger.error(t('photos', 'Failed to add {fileBaseName} to album {albumName}.', { fileBaseName: file.basename, albumName }), error)
logger.error(t('photos', 'Failed to add {fileBaseName} to album {albumName}.', { fileBaseName: file.basename, albumName }), { error })
showError(t('photos', 'Failed to add {fileBaseName} to album {albumName}.', { fileBaseName: file.basename, albumName }))
}
} finally {
@ -193,7 +193,7 @@ const actions = {
} catch (error) {
context.commit('addFilesToAlbum', { albumName, fileIdsToAdd: [fileId] })
logger.error(t('photos', 'Failed to delete {fileBaseName}.', { fileBaseName: file.basename }), error)
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)
@ -216,7 +216,7 @@ const actions = {
context.commit('addAlbums', { albums: [album] })
return album
} catch (error) {
logger.error(t('photos', 'Failed to create {albumName}.', { albumName: album.basename }), error)
logger.error(t('photos', 'Failed to create {albumName}.', { albumName: album.basename }), { error })
showError(t('photos', 'Failed to create {albumName}.', { albumName: album.basename }))
}
},
@ -244,7 +244,7 @@ const actions = {
return newAlbum
} catch (error) {
context.commit('removeAlbums', { albumNames: [newAlbumName] })
logger.error(t('photos', 'Failed to rename {currentAlbumName} to {newAlbumName}.', { currentAlbumName, newAlbumName }), error)
logger.error(t('photos', 'Failed to rename {currentAlbumName} to {newAlbumName}.', { currentAlbumName, newAlbumName }), { error })
showError(t('photos', 'Failed to rename {currentAlbumName} to {newAlbumName}.', { currentAlbumName, newAlbumName }))
return album
}
@ -301,7 +301,7 @@ const actions = {
return updatedAlbum
} catch (error) {
context.commit('updateAlbum', { album })
logger.error(t('photos', 'Failed to update properties of {albumName} with {properties}.', { albumName, properties: JSON.stringify(properties) }), error)
logger.error(t('photos', 'Failed to update properties of {albumName} with {properties}.', { albumName, properties: JSON.stringify(properties) }), { error })
showError(t('photos', 'Failed to update properties of {albumName} with {properties}.', { albumName, properties: JSON.stringify(properties) }))
return album
}
@ -320,7 +320,7 @@ const actions = {
await client.deleteFile(album.filename)
context.commit('removeAlbums', { albumNames: [albumName] })
} catch (error) {
logger.error(t('photos', 'Failed to delete {albumName}.', { albumName }), error)
logger.error(t('photos', 'Failed to delete {albumName}.', { albumName }), { error })
showError(t('photos', 'Failed to delete {albumName}.', { albumName }))
}
},

View File

@ -0,0 +1,211 @@
/**
* @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]: [...new Set([...collectionFiles, ...fileIdsToAdd])],
}
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 }
}

View File

@ -138,7 +138,7 @@ const actions = {
await context.commit('removeFilesFromFace', { faceName: oldFace, fileIdsToRemove: [fileId] })
semaphore.release(symbol)
} catch (error) {
logger.error(t('photos', 'Failed to move {fileBaseName} to person {faceName}.', { fileBaseName, faceName }), error)
logger.error(t('photos', 'Failed to move {fileBaseName} to person {faceName}.', { fileBaseName, faceName }), { error })
showError(t('photos', 'Failed to move {fileBaseName} to person {faceName}.', { fileBaseName, faceName }))
semaphore.release(symbol)
throw error
@ -171,7 +171,7 @@ const actions = {
} catch (error) {
context.commit('addFilesToFace', { faceName, fileIdsToAdd: [fileId] })
logger.error(t('photos', 'Failed to remove {fileBaseName}.', { fileBaseName }), error)
logger.error(t('photos', 'Failed to remove {fileBaseName}.', { fileBaseName }), { error })
showError(t('photos', 'Failed to remove {fileBaseName}.', { fileBaseName }))
} finally {
semaphore.release(symbol)
@ -200,7 +200,7 @@ const actions = {
context.commit('removeFaces', { faceNames: [oldName] })
face = { ...face, basename: faceName }
} catch (error) {
logger.error(t('photos', 'Failed to rename {oldName} to {faceName}.', { oldName, faceName }), error)
logger.error(t('photos', 'Failed to rename {oldName} to {faceName}.', { oldName, faceName }), { error })
showError(t('photos', 'Failed to rename {oldName} to {faceName}.', { oldName, faceName }))
} finally {
context.commit('addFaces', { faces: [face] })
@ -219,7 +219,7 @@ const actions = {
await client.deleteFile(`/recognize/${getCurrentUser()?.uid}/faces/${faceName}`)
context.commit('removeFaces', { faceNames: [faceName] })
} catch (error) {
logger.error(t('photos', 'Failed to delete {faceName}.', { faceName }), error)
logger.error(t('photos', 'Failed to delete {faceName}.', { faceName }), { error })
showError(t('photos', 'Failed to delete {faceName}.', { faceName }))
}
},

View File

@ -182,7 +182,7 @@ const actions = {
try {
await client.deleteFile(file.filename)
} catch (error) {
logger.error(t('photos', 'Failed to delete {fileId}.', { fileId }), error)
logger.error(t('photos', 'Failed to delete {fileId}.', { fileId }), { error })
showError(t('photos', 'Failed to delete {fileName}.', { fileName: file.basename }))
console.error(error)
context.dispatch('appendFiles', [file])
@ -231,7 +231,7 @@ const actions = {
)
} catch (error) {
context.commit('favoriteFile', { fileId, favoriteState: favoriteState === 0 ? 1 : 0 })
logger.error(t('photos', 'Failed to set favorite state for {fileId}.', { fileId: file.fileid }), error)
logger.error(t('photos', 'Failed to set favorite state for {fileId}.', { fileId: file.fileid }), { error })
showError(t('photos', 'Failed to set favorite state for {fileName}.', { fileName: file.basename }))
}

View File

@ -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',

View File

@ -147,7 +147,7 @@ const actions = {
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)
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 {
@ -181,7 +181,7 @@ const actions = {
} catch (error) {
context.commit('addFilesToSharedAlbum', { albumName, fileIdsToAdd: [fileId] })
logger.error(t('photos', 'Failed to delete {fileBaseName}.', { fileBaseName: file.basename }), error)
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)
@ -204,7 +204,7 @@ const actions = {
await client.deleteFile(album.filename)
context.commit('removeSharedAlbums', { albumNames: [albumName] })
} catch (error) {
logger.error(t('photos', 'Failed to delete {albumName}.', { albumName }), error)
logger.error(t('photos', 'Failed to delete {albumName}.', { albumName }), { error })
showError(t('photos', 'Failed to delete {albumName}.', { albumName }))
}
},

View File

@ -288,8 +288,10 @@ export default {
},
watch: {
album() {
this.fetchAlbumContent()
album(newAlbum, oldAlbum) {
if (newAlbum.filename !== oldAlbum.filename) {
this.fetchAlbumContent()
}
},
},
@ -361,9 +363,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)

View File

@ -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>

View File

@ -0,0 +1,326 @@
<!--
- @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 ref="collectionContent"
:collection="album"
:collection-file-ids="albumFileIds"
:semaphore="semaphore"
:loading="loadingAlbum || loadingFiles"
:error="errorFetchingAlbum || errorFetchingFiles">
<!-- Header -->
<HeaderNavigation v-if="albumOriginalName !== ''"
key="navigation"
slot="header"
slot-scope="{selectedFileIds}"
:loading="loadingAlbum || loadingFiles"
:params="{ token }"
path="/"
:root-title="albumOriginalName"
:title="albumOriginalName"
@refresh="fetchAlbumContent">
<div v-if="album.location !== ''" slot="subtitle" class="album__location">
<MapMarker />{{ album.location }}
</div>
<template v-if="album !== undefined" slot="right">
<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">
<!-- TODO: enable download on public albums -->
<!-- <NcActionSeparator />
<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>
</div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex'
import { createClient, getPatcher } from 'webdav'
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, NcEmptyContent, /** NcActionSeparator, */ isMobile } from '@nextcloud/vue'
import { showError } from '@nextcloud/dialogs'
import axios from '@nextcloud/axios'
import { generateRemoteUrl } from '@nextcloud/router'
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 { fetchAlbum, fetchAlbumContent } from '../services/Albums.js'
import logger from '../services/logger.js'
const publicRootPath = 'dav'
// force our axios
const patcher = getPatcher()
patcher.patch('request', axios)
// init webdav client on default dav endpoint
const remote = generateRemoteUrl(publicRootPath)
const publicRemote = remote
export default {
name: 'PublicAlbumContent',
components: {
MapMarker,
Plus,
Close,
// Download,
// DownloadMultiple,
ImagePlus,
NcEmptyContent,
NcActions,
NcActionButton,
// NcActionSeparator,
NcButton,
CollectionContent,
// ActionDownload,
HeaderNavigation,
},
mixins: [
FetchFilesMixin,
AbortControllerMixin,
isMobile,
],
props: {
token: {
type: String,
required: true,
},
},
data() {
return {
showAddPhotosModal: false,
loadingAlbum: false,
errorFetchingAlbum: null,
loadingCount: 0,
loadingAddFilesToAlbum: false,
albumOriginalName: '',
publicClient: createClient(publicRemote, {
username: this.token,
password: null,
}),
}
},
computed: {
...mapGetters([
'files',
'publicAlbums',
'publicAlbumsFiles',
]),
/**
* @return {object} The album information for the current albumName.
*/
album() {
return this.publicAlbums[this.albumName] || {}
},
/**
* @return {string} The album's name is the token.
*/
albumName() {
return this.token
},
/**
* @return {string[]} The list of files for the current albumName.
*/
albumFileIds() {
return this.publicAlbumsFiles[this.albumName] || []
},
},
async beforeMount() {
await this.fetchAlbumInfo()
await 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(
`/photospublic/${this.token}`,
this.abortController.signal,
'<nc:original-name />',
this.publicClient,
)
this.addPublicAlbums({ collections: [album] })
this.albumOriginalName = album.originalName
} catch (error) {
if (error.response?.status === 404) {
this.errorFetchingAlbum = 404
return
}
this.errorFetchingAlbum = error
logger.error('[PublicAlbumContent] Error fetching album', { error })
showError(this.t('photos', 'Failed to fetch album.'))
} 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(
`/photospublic/${this.token}`,
this.abortController.signal,
this.publicClient,
)
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({ collectionId: 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({ collectionId: 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>

View File

@ -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)

View File

@ -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>

26
templates/public.php Normal file
View File

@ -0,0 +1,26 @@
<?php
/**
* @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/>.
*
*/
?>
<input type="hidden" id="isPublic" name="isPublic" value="1">
<div id="content"></div>

View File

@ -20,4 +20,10 @@
<code>SearchQuery</code>
</UndefinedClass>
</file>
</files>
<file src="lib/Controller/PublicAlbumController.php">
<UndefinedClass occurrences="10">
<code>LoadViewer</code>
<code>LoadViewer</code>
</UndefinedClass>
</file>
</files>

View File

@ -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')