diff --git a/appinfo/info.xml b/appinfo/info.xml index a7f23d205..e8b4dab6b 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -10,6 +10,9 @@ John Molakvoæ Photos multimedia + + + https://github.com/nextcloud/photos https://github.com/nextcloud/photos/issues @@ -25,4 +28,13 @@ 1 + + + + OCA\Photos\Sabre\RootCollection + + + OCA\Photos\Sabre\Album\PropFindPlugin + + diff --git a/lib/Album/AlbumFile.php b/lib/Album/AlbumFile.php new file mode 100644 index 000000000..86863e092 --- /dev/null +++ b/lib/Album/AlbumFile.php @@ -0,0 +1,96 @@ + + * + * @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 . + * + */ + +namespace OCA\Photos\Album; + +use OC\Metadata\FileMetadata; + +class AlbumFile { + private int $fileId; + private string $name; + private string $mimeType; + private int $size; + private int $mtime; + private string $etag; + private int $added; + /** @var array */ + private array $metaData = []; + + public function __construct( + int $fileId, + string $name, + string $mimeType, + int $size, + int $mtime, + string $etag, + int $added + ) { + $this->fileId = $fileId; + $this->name = $name; + $this->mimeType = $mimeType; + $this->size = $size; + $this->mtime = $mtime; + $this->etag = $etag; + $this->added = $added; + } + + public function getFileId(): int { + return $this->fileId; + } + + public function getName(): string { + return $this->name; + } + + public function getMimeType(): string { + return $this->mimeType; + } + + public function getSize(): int { + return $this->size; + } + + public function getMTime(): int { + return $this->mtime; + } + + public function getEtag() { + return $this->etag; + } + + public function setMetadata(string $key, FileMetadata $value): void { + $this->metaData[$key] = $value; + } + + public function hasMetadata(string $key): bool { + return isset($this->metaData[$key]); + } + + public function getMetadata(string $key): FileMetadata { + return $this->metaData[$key]; + } + + public function getAdded(): int { + return $this->added; + } +} diff --git a/lib/Album/AlbumInfo.php b/lib/Album/AlbumInfo.php new file mode 100644 index 000000000..315afde00 --- /dev/null +++ b/lib/Album/AlbumInfo.php @@ -0,0 +1,73 @@ + + * + * @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 . + * + */ + +namespace OCA\Photos\Album; + +class AlbumInfo { + private int $id; + private string $userId; + private string $title; + private string $location; + private int $created; + private int $lastAdded; + + public function __construct( + int $id, + string $userId, + string $title, + string $location, + int $created, + int $lastAdded + ) { + $this->id = $id; + $this->userId = $userId; + $this->title = $title; + $this->location = $location; + $this->created = $created; + $this->lastAdded = $lastAdded; + } + + public function getId(): int { + return $this->id; + } + + public function getUserId(): string { + return $this->userId; + } + + public function getTitle(): string { + return $this->title; + } + + public function getLocation(): string { + return $this->location; + } + + public function getCreated(): int { + return $this->created; + } + + public function getLastAddedPhoto(): int { + return $this->lastAdded; + } +} diff --git a/lib/Album/AlbumMapper.php b/lib/Album/AlbumMapper.php new file mode 100644 index 000000000..f06f54d35 --- /dev/null +++ b/lib/Album/AlbumMapper.php @@ -0,0 +1,207 @@ + + * + * @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 . + * + */ + +namespace OCA\Photos\Album; + +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use OCA\Photos\Exception\AlreadyInAlbumException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Files\IMimeTypeLoader; +use OCP\IDBConnection; + +class AlbumMapper { + private IDBConnection $connection; + private IMimeTypeLoader $mimeTypeLoader; + private ITimeFactory $timeFactory; + + public function __construct(IDBConnection $connection, IMimeTypeLoader $mimeTypeLoader, ITimeFactory $timeFactory) { + $this->connection = $connection; + $this->mimeTypeLoader = $mimeTypeLoader; + $this->timeFactory = $timeFactory; + } + + public function create(string $userId, string $name, string $location = ""): AlbumInfo { + $created = $this->timeFactory->getTime(); + $query = $this->connection->getQueryBuilder(); + $query->insert("photos_albums") + ->values([ + 'user' => $query->createNamedParameter($userId), + 'name' => $query->createNamedParameter($name), + 'location' => $query->createNamedParameter($location), + 'created' => $query->createNamedParameter($created, IQueryBuilder::PARAM_INT), + 'last_added_photo' => $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT), + ]); + $query->executeStatement(); + $id = $query->getLastInsertId(); + + return new AlbumInfo($id, $userId, $name, $location, $created, -1); + } + + public function get(int $id): ?AlbumInfo { + $query = $this->connection->getQueryBuilder(); + $query->select("name", "user", "location", "created", "last_added_photo") + ->from("photos_albums") + ->where($query->expr()->eq('album_id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + $row = $query->executeQuery()->fetch(); + if ($row) { + return new AlbumInfo($id, $row['user'], $row['name'], $row['location'], (int)$row['created'], (int)$row['last_added_photo']); + } else { + return null; + } + } + + /** + * @param string $userId + * @return AlbumInfo[] + */ + public function getForUser(string $userId): array { + $query = $this->connection->getQueryBuilder(); + $query->select("album_id", "name", "location", "created", "last_added_photo") + ->from("photos_albums") + ->where($query->expr()->eq('user', $query->createNamedParameter($userId))); + $rows = $query->executeQuery()->fetchAll(); + return array_map(function (array $row) use ($userId) { + return new AlbumInfo((int)$row['album_id'], $userId, $row['name'], $row['location'], (int)$row['created'], (int)$row['last_added_photo']); + }, $rows); + } + + public function rename(int $id, string $newName): void { + $query = $this->connection->getQueryBuilder(); + $query->update("photos_albums") + ->set("name", $query->createNamedParameter($newName)) + ->where($query->expr()->eq('album_id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + $query->executeStatement(); + } + + public function setLocation(int $id, string $newLocation): void { + $query = $this->connection->getQueryBuilder(); + $query->update("photos_albums") + ->set("location", $query->createNamedParameter($newLocation)) + ->where($query->expr()->eq('album_id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + $query->executeStatement(); + } + + public function delete(int $id): void { + $this->connection->beginTransaction(); + $query = $this->connection->getQueryBuilder(); + $query->delete("photos_albums") + ->where($query->expr()->eq('album_id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + $query->executeStatement(); + + + $query = $this->connection->getQueryBuilder(); + $query->delete("photos_albums_files") + ->where($query->expr()->eq('album_id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + $query->executeStatement(); + $this->connection->commit(); + } + + /** + * @param string $userId + * @return AlbumWithFiles[] + */ + public function getForUserWithFiles(string $userId): array { + $query = $this->connection->getQueryBuilder(); + $query->select("fileid", "mimetype", "a.album_id", "size", "mtime", "etag", "location", "created", "last_added_photo", "added") + ->selectAlias("f.name", "file_name") + ->selectAlias("a.name", "album_name") + ->from("photos_albums", "a") + ->leftJoin("a", "photos_albums_files", "p", $query->expr()->eq("a.album_id", "p.album_id")) + ->leftJoin("p", "filecache", "f", $query->expr()->eq("p.file_id", "f.fileid")) + ->where($query->expr()->eq('user', $query->createNamedParameter($userId))); + $rows = $query->executeQuery()->fetchAll(); + + $filesByAlbum = []; + $albumsById = []; + foreach ($rows as $row) { + $albumId = (int)$row['album_id']; + if ($row['fileid']) { + $mimeId = $row['mimetype']; + $mimeType = $this->mimeTypeLoader->getMimetypeById($mimeId); + $filesByAlbum[$albumId][] = new AlbumFile((int)$row['fileid'], $row['file_name'], $mimeType, (int)$row['size'], (int)$row['mtime'], $row['etag'], (int)$row['added']); + } + + if (!isset($albumsById[$albumId])) { + $albumsById[$albumId] = new AlbumInfo($albumId, $userId, $row['album_name'], $row['location'], (int)$row['created'], (int)$row['last_added_photo']); + } + } + + $result = []; + foreach ($albumsById as $id => $album) { + $result[] = new AlbumWithFiles($album, $filesByAlbum[$id] ?? []); + } + return $result; + } + + public function addFile(int $albumId, int $fileId): void { + $added = $this->timeFactory->getTime(); + try { + $query = $this->connection->getQueryBuilder(); + $query->insert("photos_albums_files") + ->values([ + "album_id" => $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT), + "file_id" => $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT), + "added" => $query->createNamedParameter($added, IQueryBuilder::PARAM_INT), + ]); + $query->executeStatement(); + } catch (UniqueConstraintViolationException $e) { + throw new AlreadyInAlbumException("File already in album", 0, $e); + } + + $query = $this->connection->getQueryBuilder(); + $query->update("photos_albums") + ->set('last_added_photo', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('album_id', $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT))); + $query->executeStatement(); + } + + public function removeFile(int $albumId, int $fileId): void { + $query = $this->connection->getQueryBuilder(); + $query->delete("photos_albums_files") + ->where($query->expr()->eq("album_id", $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq("file_id", $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); + $query->executeStatement(); + + $query = $this->connection->getQueryBuilder(); + $query->update("photos_albums") + ->set('last_added_photo', $query->createNamedParameter($this->getLastAdded($albumId), IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('album_id', $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT))); + $query->executeStatement(); + } + + private function getLastAdded(int $albumId): int { + $query = $this->connection->getQueryBuilder(); + $query->select("file_id") + ->from("photos_albums_files") + ->where($query->expr()->eq('album_id', $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT))) + ->orderBy("added", "DESC") + ->setMaxResults(1); + $id = $query->executeQuery()->fetchOne(); + if ($id === false) { + return -1; + } else { + return (int)$id; + } + } +} diff --git a/lib/Album/AlbumWithFiles.php b/lib/Album/AlbumWithFiles.php new file mode 100644 index 000000000..cc6d0d1aa --- /dev/null +++ b/lib/Album/AlbumWithFiles.php @@ -0,0 +1,55 @@ + + * + * @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 . + * + */ + +namespace OCA\Photos\Album; + +class AlbumWithFiles { + private AlbumInfo $info; + /** @var AlbumFile[] */ + private array $files; + + public function __construct(AlbumInfo $info, array $files) { + $this->info = $info; + $this->files = $files; + } + + public function getAlbum(): AlbumInfo { + return $this->info; + } + + /** + * @return AlbumFile[] + */ + public function getFiles(): array { + return $this->files; + } + + /** + * @return int[] + */ + public function getFileIds(): array { + return array_map(function(AlbumFile $file) { + return $file->getFileId(); + }, $this->files); + } +} diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 2123a2e42..6a69d59ea 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -25,6 +25,7 @@ namespace OCA\Photos\AppInfo; +use OCA\DAV\Connector\Sabre\Principal; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -58,6 +59,8 @@ public function __construct() { } public function register(IRegistrationContext $context): void { + /** Register $principalBackend for the DAV collection */ + $context->registerServiceAlias('principalBackend', Principal::class); } public function boot(IBootContext $context): void { diff --git a/lib/Exception/AlreadyInAlbumException.php b/lib/Exception/AlreadyInAlbumException.php new file mode 100644 index 000000000..9c7d7fe38 --- /dev/null +++ b/lib/Exception/AlreadyInAlbumException.php @@ -0,0 +1,28 @@ + + * + * @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 . + * + */ + +namespace OCA\Photos\Exception; + +class AlreadyInAlbumException extends \Exception { + +} diff --git a/lib/Migration/Version20000Date20220727125801.php b/lib/Migration/Version20000Date20220727125801.php new file mode 100644 index 000000000..81284e358 --- /dev/null +++ b/lib/Migration/Version20000Date20220727125801.php @@ -0,0 +1,95 @@ + + * + * @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 . + * + */ + +namespace OCA\Photos\Migration; + +use Closure; +use Doctrine\DBAL\Types\Types; +use OC\DB\SchemaWrapper; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Auto-generated migration step: Please modify to your needs! + */ +class Version20000Date20220727125801 extends SimpleMigrationStep { + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var SchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable("photos_albums")) { + $table = $schema->createTable("photos_albums"); + $table->addColumn('album_id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('name', 'string', [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('user', 'string', [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('created', 'bigint', [ + 'notnull' => true, + ]); + $table->addColumn('location', 'string', [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('last_added_photo', 'bigint', [ + 'notnull' => true, + ]); + $table->setPrimaryKey(['album_id']); + $table->addIndex(['user'], 'pa_user'); + } + + if (!$schema->hasTable('photos_albums_files')) { + $table = $schema->createTable('photos_albums_files'); + $table->addColumn('album_file_id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('album_id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('file_id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('added', 'bigint', [ + 'notnull' => true, + ]); + $table->setPrimaryKey(['album_file_id']); + $table->addIndex(['album_id'], 'paf_folder'); + $table->addUniqueIndex(['album_id', 'file_id'], 'paf_album_file'); + } + + return $schema; + } +} diff --git a/lib/Sabre/Album/AlbumPhoto.php b/lib/Sabre/Album/AlbumPhoto.php new file mode 100644 index 000000000..01532725a --- /dev/null +++ b/lib/Sabre/Album/AlbumPhoto.php @@ -0,0 +1,99 @@ + + * + * @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 . + * + */ + +namespace OCA\Photos\Sabre\Album; + +use OCA\Photos\Album\AlbumFile; +use OCA\Photos\Album\AlbumInfo; +use OCA\Photos\Album\AlbumMapper; +use OCP\Files\Folder; +use OCP\Files\Node; +use OCP\Files\File; +use OCP\Files\NotFoundException; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\IFile; + +class AlbumPhoto implements IFile { + private AlbumMapper $albumMapper; + private AlbumInfo $album; + private AlbumFile $file; + private Folder $userFolder; + + public function __construct(AlbumMapper $albumMapper, AlbumInfo $album, AlbumFile $file, Folder $userFolder) { + $this->albumMapper = $albumMapper; + $this->album = $album; + $this->file = $file; + $this->userFolder = $userFolder; + } + + public function delete() { + $this->albumMapper->removeFile($this->album->getId(), $this->file->getFileId()); + } + + public function getName() { + return $this->file->getFileId() . "-" . $this->file->getName(); + } + + public function setName($name) { + throw new Forbidden('Can\'t rename photos trough the album api'); + } + + public function getLastModified() { + return $this->file->getMTime(); + } + + public function put($data) { + throw new Forbidden('Can\'t write to photos trough the album api'); + } + + public function get() { + $nodes = $this->userFolder->getById($this->file->getFileId()); + $node = current($nodes); + if ($node) { + /** @var Node $node */ + if ($node instanceof File) { + return $node->fopen('r'); + } else { + throw new NotFoundException("Photo is a folder"); + } + } else { + throw new NotFoundException("Photo not found for user"); + } + } + + public function getContentType() { + return $this->file->getMimeType(); + } + + public function getETag() { + return $this->file->getEtag(); + } + + public function getSize() { + return $this->file->getSize(); + } + + public function getFile(): AlbumFile { + return $this->file; + } +} diff --git a/lib/Sabre/Album/AlbumRoot.php b/lib/Sabre/Album/AlbumRoot.php new file mode 100644 index 000000000..4cb132c05 --- /dev/null +++ b/lib/Sabre/Album/AlbumRoot.php @@ -0,0 +1,121 @@ + + * + * @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 . + * + */ + +namespace OCA\Photos\Sabre\Album; + +use OCA\DAV\Connector\Sabre\File; +use OCA\Photos\Album\AlbumFile; +use OCA\Photos\Album\AlbumMapper; +use OCA\Photos\Album\AlbumWithFiles; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\IUser; +use Sabre\DAV\Exception\Conflict; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; +use Sabre\DAV\ICopyTarget; +use Sabre\DAV\INode; + +class AlbumRoot implements ICollection, ICopyTarget { + private AlbumMapper $albumMapper; + private AlbumWithFiles $album; + private IRootFolder $rootFolder; + private Folder $userFolder; + private IUser $user; + + public function __construct(AlbumMapper $albumMapper, AlbumWithFiles $album, IRootFolder $rootFolder, Folder $userFolder, IUser $user) { + $this->albumMapper = $albumMapper; + $this->album = $album; + $this->rootFolder = $rootFolder; + $this->userFolder = $userFolder; + $this->user = $user; + } + + public function delete() { + $this->albumMapper->delete($this->album->getAlbum()->getId()); + } + + public function getName(): string { + return basename($this->album->getAlbum()->getTitle()); + } + + public function setName($name) { + $this->albumMapper->rename($this->album->getAlbum()->getId(), $name); + } + + public function createFile($name, $data = null) { + throw new Forbidden('Not allowed to create files in this folder, copy files into this folder instead'); + } + + public function createDirectory($name) { + throw new Forbidden('Not allowed to create directories in this folder'); + } + + public function getChildren(): array { + return array_map(function (AlbumFile $file) { + return new AlbumPhoto($this->albumMapper, $this->album->getAlbum(), $file, $this->userFolder); + }, $this->album->getFiles()); + } + + public function getChild($name): AlbumPhoto { + foreach ($this->album->getFiles() as $file) { + if ($file->getFileId() . "-" . $file->getName() === $name) { + return new AlbumPhoto($this->albumMapper, $this->album->getAlbum(), $file, $this->userFolder); + } + } + throw new NotFound("$name not found"); + } + + public function childExists($name): bool { + try { + $this->getChild($name); + return true; + } catch (NotFound $e) { + return false; + } + } + + public function getLastModified(): int { + return 0; + } + + public function copyInto($targetName, $sourcePath, INode $sourceNode): bool { + $uid = $this->user->getUID(); + if ($sourceNode instanceof File) { + $sourceId = $sourceNode->getId(); + if (in_array($sourceId, $this->album->getFileIds())) { + throw new Conflict("File $sourceId is already in the folder"); + } + if ($sourceNode->getFileInfo()->getOwner()->getUID() === $uid) { + $this->albumMapper->addFile($this->album->getAlbum()->getId(), $sourceId); + return true; + } + } + throw new \Exception("Can't add file to album, only files from $uid can be added"); + } + + public function getAlbum(): AlbumWithFiles { + return $this->album; + } +} diff --git a/lib/Sabre/Album/AlbumsHome.php b/lib/Sabre/Album/AlbumsHome.php new file mode 100644 index 000000000..1c070b59a --- /dev/null +++ b/lib/Sabre/Album/AlbumsHome.php @@ -0,0 +1,108 @@ + + * + * @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 . + * + */ + +namespace OCA\Photos\Sabre\Album; + +use OCA\Photos\Album\AlbumMapper; +use OCA\Photos\Album\AlbumWithFiles; +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; + +class AlbumsHome implements ICollection { + private AlbumMapper $albumMapper; + private array $principalInfo; + private IUser $user; + private IRootFolder $rootFolder; + private Folder $userFolder; + + public function __construct( + array $principalInfo, + AlbumMapper $albumMapper, + IUser $user, + IRootFolder $rootFolder + ) { + $this->principalInfo = $principalInfo; + $this->albumMapper = $albumMapper; + $this->user = $user; + $this->rootFolder = $rootFolder; + $this->userFolder = $rootFolder->getUserFolder($user->getUID()); + } + + public function delete() { + throw new Forbidden(); + } + + public function getName(): string { + return 'albums'; + } + + public function setName($name) { + throw new Forbidden('Permission denied to rename this folder'); + } + + public function createFile($name, $data = null) { + throw new Forbidden('Not allowed to create files in this folder'); + } + + public function createDirectory($name) { + $uid = $this->user->getUID(); + $this->albumMapper->create($uid, $name); + } + + public function getChild($name) { + foreach ($this->getChildren() as $child) { + if ($child->getName() === $name) { + return $child; + } + } + + throw new NotFound(); + } + + /** + * @return AlbumRoot[] + */ + public function getChildren(): array { + $folders = $this->albumMapper->getForUserWithFiles($this->user->getUID()); + return array_map(function (AlbumWithFiles $folder) { + return new AlbumRoot($this->albumMapper, $folder, $this->rootFolder, $this->userFolder, $this->user); + }, $folders); + } + + public function childExists($name): bool { + try { + $this->getChild($name); + return true; + } catch (NotFound $e) { + return false; + } + } + + public function getLastModified(): int { + return 0; + } +} diff --git a/lib/Sabre/Album/PropFindPlugin.php b/lib/Sabre/Album/PropFindPlugin.php new file mode 100644 index 000000000..ce7f0eff6 --- /dev/null +++ b/lib/Sabre/Album/PropFindPlugin.php @@ -0,0 +1,127 @@ + + * + * @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 . + * + */ + +namespace OCA\Photos\Sabre\Album; + +use OC\Metadata\IMetadataManager; +use OCA\DAV\Connector\Sabre\FilesPlugin; +use OCA\Photos\Album\AlbumMapper; +use OCP\IConfig; +use Sabre\DAV\INode; +use Sabre\DAV\PropFind; +use Sabre\DAV\PropPatch; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\DAV\Tree; + +class PropFindPlugin extends ServerPlugin { + public const FILE_NAME_PROPERTYNAME = '{http://nextcloud.org/ns}file-name'; + public const LOCATION_PROPERTYNAME = '{http://nextcloud.org/ns}location'; + public const LAST_PHOTO_PROPERTYNAME = '{http://nextcloud.org/ns}last-photo'; + + private IConfig $config; + private IMetadataManager $metadataManager; + private bool $metadataEnabled; + private ?Tree $tree; + private AlbumMapper $albumMapper; + + public function __construct( + IConfig $config, + IMetadataManager $metadataManager, + AlbumMapper $albumMapper + ) { + $this->config = $config; + $this->metadataManager = $metadataManager; + $this->albumMapper = $albumMapper; + $this->metadataEnabled = $this->config->getSystemValueBool('enable_file_metadata', true); + } + + + public function initialize(Server $server) { + $this->tree = $server->tree; + $server->on('propFind', [$this, 'propFind']); + $server->on('propPatch', [$this, 'handleUpdateProperties']); + } + + public function propFind(PropFind $propFind, INode $node) { + if ($node instanceof AlbumPhoto) { + $propFind->handle(self::FILE_NAME_PROPERTYNAME, function () use ($node) { + return $node->getFile()->getName(); + }); + $propFind->handle(FilesPlugin::INTERNAL_FILEID_PROPERTYNAME, function () use ($node) { + return $node->getFile()->getFileId(); + }); + $propFind->handle(FilesPlugin::GETETAG_PROPERTYNAME, function () use ($node): string { + return $node->getETag(); + }); + + if ($this->metadataEnabled) { + $propFind->handle(FilesPlugin::FILE_METADATA_SIZE, function () use ($node) { + if (!str_starts_with($node->getFile()->getMimetype(), 'image')) { + return json_encode((object)[]); + } + + if ($node->getFile()->hasMetadata('size')) { + $sizeMetadata = $node->getFile()->getMetadata('size'); + } else { + $sizeMetadata = $this->metadataManager->fetchMetadataFor('size', [$node->getFile()->getFileId()])[$node->getFile()->getFileId()]; + } + + return json_encode((object)$sizeMetadata->getMetadata()); + }); + } + } + + if ($node instanceof AlbumRoot) { + $propFind->handle(self::LOCATION_PROPERTYNAME, function () use ($node) { + return $node->getAlbum()->getAlbum()->getLocation(); + }); + $propFind->handle(self::LAST_PHOTO_PROPERTYNAME, function () use ($node) { + return $node->getAlbum()->getAlbum()->getLastAddedPhoto(); + }); + + // TODO detect dynamically which metadata groups are requested and + // preload all of them and not just size + if ($this->metadataEnabled && in_array(FilesPlugin::FILE_METADATA_SIZE, $propFind->getRequestedProperties(), true)) { + $fileIds = $node->getAlbum()->getFileIds(); + + $preloadedMetadata = $this->metadataManager->fetchMetadataFor('size', $fileIds); + foreach ($node->getAlbum()->getFiles() as $file) { + if (str_starts_with($file->getMimeType(), 'image')) { + $file->setMetadata('size', $preloadedMetadata[$file->getFileId()]); + } + } + } + } + } + + public function handleUpdateProperties($path, PropPatch $propPatch) { + $node = $this->tree->getNodeForPath($path); + if ($node instanceof AlbumRoot) { + $propPatch->handle(self::LOCATION_PROPERTYNAME, function ($location) use ($node) { + $this->albumMapper->setLocation($node->getAlbum()->getAlbum()->getId(), $location); + return true; + }); + } + } +} diff --git a/lib/Sabre/PhotosHome.php b/lib/Sabre/PhotosHome.php new file mode 100644 index 000000000..d37c8a11c --- /dev/null +++ b/lib/Sabre/PhotosHome.php @@ -0,0 +1,98 @@ + + * + * @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 . + * + */ + +namespace OCA\Photos\Sabre; + +use OCA\Photos\Album\AlbumMapper; +use OCA\Photos\Sabre\Album\AlbumsHome; +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; + +class PhotosHome implements ICollection { + private AlbumMapper $albumMapper; + private array $principalInfo; + private IUser $user; + private IRootFolder $rootFolder; + private Folder $userFolder; + + public function __construct( + array $principalInfo, + AlbumMapper $albumMapper, + IUser $user, + IRootFolder $rootFolder + ) { + $this->principalInfo = $principalInfo; + $this->albumMapper = $albumMapper; + $this->user = $user; + $this->rootFolder = $rootFolder; + $this->userFolder = $rootFolder->getUserFolder($user->getUID()); + } + + public function delete() { + throw new Forbidden(); + } + + public function getName(): string { + [, $name] = \Sabre\Uri\split($this->principalInfo['uri']); + return $name; + } + + public function setName($name) { + throw new Forbidden('Permission denied to rename this folder'); + } + + public function createFile($name, $data = null) { + throw new Forbidden('Not allowed to create files in this folder'); + } + + public function createDirectory($name) { + throw new Forbidden('Permission denied to create folders in this folder'); + } + + public function getChild($name) { + if ($name === 'albums') { + return new AlbumsHome($this->principalInfo, $this->albumMapper, $this->user, $this->rootFolder); + } + + throw new NotFound(); + } + + /** + * @return AlbumsHome[] + */ + public function getChildren(): array { + return [new AlbumsHome($this->principalInfo, $this->albumMapper, $this->user, $this->rootFolder)]; + } + + public function childExists($name): bool { + return $name === 'albums'; + } + + public function getLastModified(): int { + return 0; + } +} diff --git a/lib/Sabre/RootCollection.php b/lib/Sabre/RootCollection.php new file mode 100644 index 000000000..d2478991b --- /dev/null +++ b/lib/Sabre/RootCollection.php @@ -0,0 +1,73 @@ + + * + * @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 . + * + */ + +namespace OCA\Photos\Sabre; + +use OCA\Photos\Album\AlbumMapper; +use OCP\Files\IRootFolder; +use OCP\IUserSession; +use Sabre\DAV\INode; +use Sabre\DAVACL\AbstractPrincipalCollection; +use Sabre\DAVACL\PrincipalBackend; + +class RootCollection extends AbstractPrincipalCollection { + private AlbumMapper $folderMapper; + private IUserSession $userSession; + private IRootFolder $rootFolder; + + public function __construct( + AlbumMapper $folderMapper, + IUserSession $userSession, + IRootFolder $rootFolder, + PrincipalBackend\BackendInterface $principalBackend + ) { + parent::__construct($principalBackend, 'principals/users'); + + $this->folderMapper = $folderMapper; + $this->userSession = $userSession; + $this->rootFolder = $rootFolder; + } + + /** + * This method returns a node for a principal. + * + * The passed array contains principal information, and is guaranteed to + * at least contain a uri item. Other properties may or may not be + * supplied by the authentication backend. + * + * @param array $principalInfo + * @return INode + */ + public function getChildForPrincipal(array $principalInfo): PhotosHome { + [, $name] = \Sabre\Uri\split($principalInfo['uri']); + $user = $this->userSession->getUser(); + if (is_null($user) || $name !== $user->getUID()) { + throw new \Sabre\DAV\Exception\Forbidden(); + } + return new PhotosHome($principalInfo, $this->folderMapper, $user, $this->rootFolder); + } + + public function getName(): string { + return 'photos'; + } +} diff --git a/tests/Album/AlbumMapperTest.php b/tests/Album/AlbumMapperTest.php new file mode 100644 index 000000000..f0324ef5c --- /dev/null +++ b/tests/Album/AlbumMapperTest.php @@ -0,0 +1,299 @@ + + * + * @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 . + * + */ + +namespace OCA\Photos\Tests\Album; + +use OCA\Photos\Album\AlbumFile; +use OCA\Photos\Album\AlbumInfo; +use OCA\Photos\Album\AlbumMapper; +use OCA\Photos\Album\AlbumWithFiles; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Constants; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Files\IMimeTypeLoader; +use OCP\IDBConnection; +use Test\TestCase; + +/** + * @group DB + */ +class AlbumMapperTest extends TestCase { + /** @var IDBConnection */ + private $connection; + private array $createdFiles = []; + /** @var IMimeTypeLoader */ + private $mimeLoader; + /** @var AlbumMapper */ + private $mapper; + /** @var ITimeFactory|\PHPUnit_Framework_MockObject_MockObject */ + private $timeFactory; + private int $time = 100; + + protected function setUp(): void { + parent::setUp(); + + $this->createdFiles = []; + $this->connection = \OC::$server->get(IDBConnection::class); + $this->mimeLoader = \OC::$server->get(IMimeTypeLoader::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->timeFactory->method('getTime')->willReturnCallback(function() { + return $this->time; + }); + + $this->mapper = new AlbumMapper($this->connection, $this->mimeLoader, $this->timeFactory); + } + + protected function tearDown():void { + $query = $this->connection->getQueryBuilder(); + $query->delete('filecache') + ->where($query->expr()->eq('fileid', $query->createParameter("fileid"))); + foreach ($this->createdFiles as $createdFile) { + $query->setParameter("fileid", $createdFile); + $query->executeStatement(); + } + $this->createdFiles = []; + + $this->connection->getQueryBuilder()->delete('photos_albums')->executeStatement(); + $this->connection->getQueryBuilder()->delete('photos_albums_files')->executeStatement(); + + parent::tearDown(); + } + + private function createFile(string $name, string $mimeType, int $size = 10, int $mtime = 10000, int $permissions = Constants::PERMISSION_ALL): int { + $mimeId = $this->mimeLoader->getId($mimeType); + $mimePartId = $this->mimeLoader->getId(substr($mimeType, strpos($mimeType, '/'))); + $query = $this->connection->getQueryBuilder(); + $query->insert('filecache') + ->values([ + 'storage' => $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT), + 'parent' => $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT), + 'path' => $query->createNamedParameter("/dummy/" . $name), + 'path_hash' => $query->createNamedParameter(md5("dummy/" . $name)), + 'name' => $query->createNamedParameter($name), + 'mimetype' => $query->createNamedParameter($mimeId, IQueryBuilder::PARAM_INT), + 'mimepart' => $query->createNamedParameter($mimePartId, IQueryBuilder::PARAM_INT), + 'size' => $query->createNamedParameter($size, IQueryBuilder::PARAM_INT), + 'mtime' => $query->createNamedParameter($mtime, IQueryBuilder::PARAM_INT), + 'storage_mtime' => $query->createNamedParameter($mtime, IQueryBuilder::PARAM_INT), + 'permissions' => $query->createNamedParameter($permissions, IQueryBuilder::PARAM_INT), + 'etag' => $query->createNamedParameter('dummy'), + ]); + $query->executeStatement(); + $id = $query->getLastInsertId(); + $this->createdFiles[] = $id; + return $id; + } + + public function testCreateGet() { + $album = $this->mapper->create("user1", "album1"); + + $retrievedAlbum = $this->mapper->get($album->getId()); + $this->assertEquals($album, $retrievedAlbum); + $this->assertEquals(100, $retrievedAlbum->getCreated()); + $this->assertEquals("", $retrievedAlbum->getLocation()); + $this->assertEquals(-1, $retrievedAlbum->getLastAddedPhoto()); + } + + public function testCreateList() { + $album1 = $this->mapper->create("user1", "album1"); + $album2 = $this->mapper->create("user1", "album2"); + $this->mapper->create("user2", "album3"); + + $retrievedAlbums = $this->mapper->getForUser('user1'); + usort($retrievedAlbums, function(AlbumInfo $a, AlbumInfo $b) { + return $a->getId() <=> $b->getId(); + }); + $this->assertEquals([$album1, $album2], $retrievedAlbums); + } + + public function testCreateDeleteGet() { + $album = $this->mapper->create("user1", "album1"); + + $retrievedAlbum = $this->mapper->get($album->getId()); + $this->assertEquals($album, $retrievedAlbum); + + $this->mapper->delete($album->getId()); + + $this->assertEquals(null, $this->mapper->get($album->getId())); + } + + public function testCreateDeleteList() { + $album1 = $this->mapper->create("user1", "album1"); + $album2 = $this->mapper->create("user1", "album2"); + $this->mapper->create("user2", "album3"); + + $this->mapper->delete($album1->getId()); + + $retrievedAlbums = $this->mapper->getForUser('user1'); + usort($retrievedAlbums, function(AlbumInfo $a, AlbumInfo $b) { + return $a->getId() <=> $b->getId(); + }); + $this->assertEquals([$album2], $retrievedAlbums); + } + + public function testCreateRenameGet() { + $album = $this->mapper->create("user1", "album1"); + $this->mapper->rename($album->getId(),"renamed"); + + $retrievedAlbum = $this->mapper->get($album->getId()); + $this->assertEquals("renamed", $retrievedAlbum->getTitle()); + } + + public function testCreateUpdateGet() { + $album = $this->mapper->create("user1", "album1"); + $this->mapper->setLocation($album->getId(),"nowhere"); + + $retrievedAlbum = $this->mapper->get($album->getId()); + $this->assertEquals("nowhere", $retrievedAlbum->getLocation()); + } + + public function testEmptyFiles() { + $album1 = $this->mapper->create("user1", "album1"); + + $this->assertEquals([new AlbumWithFiles($album1, [])], $this->mapper->getForUserWithFiles("user1")); + } + + public function testAddFiles() { + $album1 = $this->mapper->create("user1", "album1"); + $album2 = $this->mapper->create("user1", "album2"); + + $fileId1 = $this->createFile("file1", "text/plain"); + $fileId2 = $this->createFile("file2", "image/png"); + + $this->mapper->addFile($album1->getId(), $fileId1); + $this->mapper->addFile($album1->getId(), $fileId2); + $this->mapper->addFile($album2->getId(), $fileId1); + + $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); + usort($albumsWithFiles, function(AlbumWithFiles $a, AlbumWithFiles $b) { + return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); + }); + $this->assertCount(2, $albumsWithFiles); + + $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); + $this->assertEquals($fileId2, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); + $files = $albumsWithFiles[0]->getFiles(); + usort($files, function(AlbumFile $a, AlbumFile $b) { + return $a->getFileId() <=> $b->getFileId(); + }); + $this->assertCount(2, $files); + $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 100), $albumsWithFiles[0]->getFiles()[0]); + $this->assertEquals(new AlbumFile($fileId2, "file2", "image/png", 10, 10000, "dummy", 100), $albumsWithFiles[0]->getFiles()[1]); + + $this->assertEquals($album2->getId(), $albumsWithFiles[1]->getAlbum()->getId()); + $this->assertEquals($fileId1, $albumsWithFiles[1]->getAlbum()->getLastAddedPhoto()); + + $files = $albumsWithFiles[1]->getFiles(); + usort($files, function(AlbumFile $a, AlbumFile $b) { + return $a->getFileId() <=> $b->getFileId(); + }); + $this->assertCount(1, $files); + $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 100), $albumsWithFiles[0]->getFiles()[0]); + } + + public function testAddRemoveFiles() { + $album1 = $this->mapper->create("user1", "album1"); + + $fileId1 = $this->createFile("file1", "text/plain"); + $fileId2 = $this->createFile("file2", "image/png"); + $fileId3 = $this->createFile("file3", "image/png"); + + $this->time = 110; + $this->mapper->addFile($album1->getId(), $fileId1); + $this->time = 120; + $this->mapper->addFile($album1->getId(), $fileId2); + $this->time = 130; + $this->mapper->addFile($album1->getId(), $fileId3); + + $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); + usort($albumsWithFiles, function(AlbumWithFiles $a, AlbumWithFiles $b) { + return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); + }); + $this->assertCount(1, $albumsWithFiles); + + $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); + $this->assertEquals($fileId3, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); + $files = $albumsWithFiles[0]->getFiles(); + usort($files, function(AlbumFile $a, AlbumFile $b) { + return $a->getFileId() <=> $b->getFileId(); + }); + $this->assertCount(3, $files); + $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 110), $albumsWithFiles[0]->getFiles()[0]); + $this->assertEquals(new AlbumFile($fileId2, "file2", "image/png", 10, 10000, "dummy", 120), $albumsWithFiles[0]->getFiles()[1]); + $this->assertEquals(new AlbumFile($fileId3, "file3", "image/png", 10, 10000, "dummy", 130), $albumsWithFiles[0]->getFiles()[2]); + + + + $this->mapper->removeFile($album1->getId(), $fileId2); + + $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); + usort($albumsWithFiles, function(AlbumWithFiles $a, AlbumWithFiles $b) { + return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); + }); + $this->assertCount(1, $albumsWithFiles); + + $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); + $this->assertEquals($fileId3, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); + $files = $albumsWithFiles[0]->getFiles(); + usort($files, function(AlbumFile $a, AlbumFile $b) { + return $a->getFileId() <=> $b->getFileId(); + }); + $this->assertCount(2, $files); + $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 110), $albumsWithFiles[0]->getFiles()[0]); + $this->assertEquals(new AlbumFile($fileId3, "file3", "image/png", 10, 10000, "dummy", 130), $albumsWithFiles[0]->getFiles()[1]); + + + + $this->mapper->removeFile($album1->getId(), $fileId3); + + $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); + usort($albumsWithFiles, function(AlbumWithFiles $a, AlbumWithFiles $b) { + return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); + }); + $this->assertCount(1, $albumsWithFiles); + + $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); + $this->assertEquals($fileId1, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); + $files = $albumsWithFiles[0]->getFiles(); + usort($files, function(AlbumFile $a, AlbumFile $b) { + return $a->getFileId() <=> $b->getFileId(); + }); + $this->assertCount(1, $files); + $this->assertEquals(new AlbumFile($fileId1, "file1", "text/plain", 10, 10000, "dummy", 110), $albumsWithFiles[0]->getFiles()[0]); + + + + $this->mapper->removeFile($album1->getId(), $fileId1); + + $albumsWithFiles = $this->mapper->getForUserWithFiles("user1"); + usort($albumsWithFiles, function(AlbumWithFiles $a, AlbumWithFiles $b) { + return $a->getAlbum()->getId() <=> $b->getAlbum()->getId(); + }); + $this->assertCount(1, $albumsWithFiles); + + $this->assertEquals($album1->getId(), $albumsWithFiles[0]->getAlbum()->getId()); + $this->assertEquals(-1, $albumsWithFiles[0]->getAlbum()->getLastAddedPhoto()); + $files = $albumsWithFiles[0]->getFiles(); + $this->assertCount(0, $files); + } +}