migrate therinaldos.com data
Build & Deploy to DigitalOcean Space / build (push) Failing after 2m38s

This commit is contained in:
2024-05-05 15:50:45 -04:00
commit ef1ff240d4
23182 changed files with 3801898 additions and 0 deletions
@@ -0,0 +1,62 @@
<?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\Album;
use OCA\Photos\DB\PhotosFile;
class AlbumFile extends PhotosFile {
private int $added;
private string $owner;
public function __construct(
int $fileId,
string $name,
string $mimeType,
int $size,
int $mtime,
string $etag,
int $added,
string $owner
) {
parent::__construct(
$fileId,
$name,
$mimeType,
$size,
$mtime,
$etag
);
$this->added = $added;
$this->owner = $owner;
}
public function getAdded(): int {
return $this->added;
}
public function getOwner(): string {
return $this->owner;
}
}
@@ -0,0 +1,80 @@
<?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\Album;
class AlbumInfo {
private int $id;
private string $userId;
private string $title;
private string $location;
private int $created;
private int $lastAdded;
private ?int $receivedFrom;
public function __construct(
int $id,
string $userId,
string $title,
string $location,
int $created,
int $lastAdded,
?int $receivedFrom = null,
) {
$this->id = $id;
$this->userId = $userId;
$this->title = $title;
$this->location = $location;
$this->created = $created;
$this->lastAdded = $lastAdded;
$this->receivedFrom = $receivedFrom;
}
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;
}
public function getReceivedFrom(): ?int {
return $this->receivedFrom;
}
}
@@ -0,0 +1,618 @@
<?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\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;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IUserManager;
use OCP\Security\ISecureRandom;
class AlbumMapper {
private IDBConnection $connection;
private IMimeTypeLoader $mimeTypeLoader;
private ITimeFactory $timeFactory;
private IUserManager $userManager;
private IGroupManager $groupManager;
protected IL10N $l;
protected ISecureRandom $random;
// Same mapping as IShare.
public const TYPE_USER = 0;
public const TYPE_GROUP = 1;
public const TYPE_LINK = 3;
public function __construct(
IDBConnection $connection,
IMimeTypeLoader $mimeTypeLoader,
ITimeFactory $timeFactory,
IUserManager $userManager,
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 {
$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);
}
/**
* @param int $fileId
* @return AlbumInfo[]
*/
public function getForFile(int $fileId): array {
$query = $this->connection->getQueryBuilder();
$query->select("a.album_id", "name", "user", "location", "created", "last_added_photo")
->from("photos_albums", "a")
->leftJoin("a", "photos_albums_files", "p", $query->expr()->eq("a.album_id", "p.album_id"))
->where($query->expr()->eq('file_id', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
$rows = $query->executeQuery()->fetchAll();
return array_map(function (array $row) {
return new AlbumInfo((int)$row['album_id'], $row['user'], $row['name'], $row['location'], (int)$row['created'], (int)$row['last_added_photo']);
}, $rows);
}
/**
* @param string $userId
* @param int $fileId
* @return AlbumInfo[]
*/
public function getForUserAndFile(string $userId, int $fileId): array {
$query = $this->connection->getQueryBuilder();
$query->select("a.album_id", "name", "user", "location", "created", "last_added_photo")
->from("photos_albums", "a")
->leftJoin("a", "photos_albums_files", "p", $query->expr()->eq("a.album_id", "p.album_id"))
->where($query->expr()->eq('user', $query->createNamedParameter($userId)))
->andWhere($query->expr()->eq('file_id', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
$rows = $query->executeQuery()->fetchAll();
return array_map(function (array $row) {
return new AlbumInfo((int)$row['album_id'], $row['user'], $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();
$query = $this->connection->getQueryBuilder();
$query->delete("photos_albums_collabs")
->where($query->expr()->eq('album_id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
$query->executeStatement();
$this->connection->commit();
}
/**
* @param int $albumId
* @param string $userId
* @return AlbumFile[]
*/
public function getForAlbumIdAndUserWithFiles(int $albumId, string $userId): array {
$query = $this->connection->getQueryBuilder();
$query->select("fileid", "mimetype", "a.album_id", "size", "mtime", "etag", "added", "owner")
->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('a.album_id', $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('user', $query->createNamedParameter($userId)));
$rows = $query->executeQuery()->fetchAll();
$files = [];
foreach ($rows as $row) {
$albumId = (int)$row['album_id'];
if ($row['fileid']) {
$mimeId = $row['mimetype'];
$mimeType = $this->mimeTypeLoader->getMimetypeById($mimeId);
$files[] = new AlbumFile((int)$row['fileid'], $row['file_name'], $mimeType, (int)$row['size'], (int)$row['mtime'], $row['etag'], (int)$row['added'], $row['owner']);
}
}
return $files;
}
/**
* @param int $albumId
* @param int $fileId
* @return AlbumFile
*/
public function getForAlbumIdAndFileId(int $albumId, int $fileId): AlbumFile {
$query = $this->connection->getQueryBuilder();
$query->select("fileid", "mimetype", "a.album_id", "user", "size", "mtime", "etag", "location", "created", "last_added_photo", "added", "owner")
->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('a.album_id', $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('file_id', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
$row = $query->executeQuery()->fetchAll()[0];
$mimeId = $row['mimetype'];
$mimeType = $this->mimeTypeLoader->getMimetypeById($mimeId);
return new AlbumFile((int)$row['fileid'], $row['file_name'], $mimeType, (int)$row['size'], (int)$row['mtime'], $row['etag'], (int)$row['added'], $row['owner']);
}
public function addFile(int $albumId, int $fileId, string $owner): 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),
"owner" => $query->createNamedParameter($owner),
]);
$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();
}
/**
* Remove all files added by a user from an album.
*/
public function removeFilesForUser(int $albumId, string $userId) {
// Remove all photos by this user from the album:
$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('owner', $query->createNamedParameter($userId)))
->executeStatement();
// Update the last added photo:
$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)))
->executeStatement();
}
/**
* Remove a given file from any albums in which it was added by a given user.
*/
public function removeFileWithOwner(int $fileId, string $ownerId): void {
// Get concerned albums before deleting them.
$query = $this->connection->getQueryBuilder();
$albumsRows = $query->select('album_id')
->from("photos_albums_files")
->where($query->expr()->eq("owner", $query->createNamedParameter($ownerId)))
->andWhere($query->expr()->eq("file_id", $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
->executeQuery()
->fetchAll();
// Remove any occurrence of fileId when owner is ownerId.
$query = $this->connection->getQueryBuilder();
$query->delete("photos_albums_files")
->where($query->expr()->eq("owner", $query->createNamedParameter($ownerId)))
->andWhere($query->expr()->eq("file_id", $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
->executeStatement();
// Update last_added_photo for concerned albums.
foreach ($albumsRows as $row) {
$query = $this->connection->getQueryBuilder();
$query->update("photos_albums")
->set('last_added_photo', $query->createNamedParameter($this->getLastAdded($row['album_id']), IQueryBuilder::PARAM_INT))
->where($query->expr()->eq('album_id', $query->createNamedParameter($row['album_id'], 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;
}
}
/**
* @param int $albumId
* @return array<array{'id': string, 'label': string, 'type': int}>
*/
public function getCollaborators(int $albumId): array {
$query = $this->connection->getQueryBuilder();
$query->select("collaborator_id", "collaborator_type")
->from("photos_albums_collabs")
->where($query->expr()->eq('album_id', $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT)));
$rows = $query->executeQuery()->fetchAll();
$collaborators = array_map(function (array $row) {
/** @var string|null */
$displayName = null;
switch ($row['collaborator_type']) {
case self::TYPE_USER:
$displayName = $this->userManager->get($row['collaborator_id'])?->getDisplayName();
break;
case self::TYPE_GROUP:
$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($displayName)) {
return null;
}
return [
'id' => $row['collaborator_id'],
'label' => $displayName,
'type' => (int)$row['collaborator_type'],
];
}, $rows);
return array_values(array_filter($collaborators, fn ($c) => $c !== null));
}
/**
* @param int $albumId
* @param string $userId
* @return bool
*/
public function isCollaborator(int $albumId, string $userId): bool {
$query = $this->connection->getQueryBuilder();
$query->select("collaborator_id", "collaborator_type")
->from("photos_albums_collabs")
->where($query->expr()->eq('album_id', $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT)));
$rows = $query->executeQuery()->fetchAll();
foreach ($rows as $row) {
switch ($row['collaborator_type']) {
case self::TYPE_USER:
if ($row['collaborator_id'] === $userId) {
return true;
}
break;
case self::TYPE_GROUP:
if ($this->groupManager->isInGroup($userId, $row['collaborator_id'])) {
return true;
}
break;
default:
break;
}
}
return false;
}
/**
* @param int $albumId
* @param array{'id': string, 'type': int} $collaborators
*/
public function setCollaborators(int $albumId, array $collaborators): void {
$existingCollaborators = $this->getCollaborators($albumId);
// 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();
foreach ($collaboratorsToAdd as $collaborator) {
switch ($collaborator['type']) {
case self::TYPE_USER:
if (is_null($this->userManager->get($collaborator['id']))) {
throw new \Exception('Unknown collaborator: ' . $collaborator['id']);
}
break;
case self::TYPE_GROUP:
if (is_null($this->groupManager->get($collaborator['id']))) {
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']);
}
$query = $this->connection->getQueryBuilder();
$query->insert('photos_albums_collabs')
->setValue('album_id', $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT))
->setValue('collaborator_id', $query->createNamedParameter($collaborator['id']))
->setValue('collaborator_type', $query->createNamedParameter($collaborator['type'], IQueryBuilder::PARAM_INT))
->executeStatement();
}
foreach ($collaboratorsToRemove as $collaborator) {
switch ($collaborator['type']) {
case self::TYPE_USER:
$this->deleteUserFromAlbumCollaboratorsList($collaborator['id'], $albumId);
break;
default:
$query = $this->connection->getQueryBuilder();
$query->delete('photos_albums_collabs')
->where($query->expr()->eq('album_id', $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('collaborator_id', $query->createNamedParameter($collaborator['id'])))
->andWhere($query->expr()->eq('collaborator_type', $query->createNamedParameter($collaborator['type'], IQueryBuilder::PARAM_INT)))
->executeStatement();
}
}
$this->connection->commit();
}
/**
* @param string $collaboratorId
* @param int $collaboratorType
* @return AlbumInfo[]
*/
public function getSharedAlbumsForCollaborator(string $collaboratorId, int $collaboratorType): array {
$query = $this->connection->getQueryBuilder();
$rows = $query
->select("a.album_id", "name", "user", "location", "created", "last_added_photo")
->from("photos_albums_collabs", "c")
->leftJoin("c", "photos_albums", "a", $query->expr()->eq("a.album_id", "c.album_id"))
->where($query->expr()->eq('collaborator_id', $query->createNamedParameter($collaboratorId)))
->andWhere($query->expr()->eq('collaborator_type', $query->createNamedParameter($collaboratorType, IQueryBuilder::PARAM_INT)))
->executeQuery()
->fetchAll();
return array_map(function (array $row) {
return new AlbumInfo(
(int)$row['album_id'],
$row['user'],
$row['name'].' ('.$row['user'].')',
$row['location'],
(int)$row['created'],
(int)$row['last_added_photo']
);
}, $rows);
}
/**
* @param string $collaboratorId
* @param string $collaboratorsType - The type of the collaborator, either a user or a group.
* @return AlbumWithFiles[]
*/
public function getSharedAlbumsForCollaboratorWithFiles(string $collaboratorId, int $collaboratorType): array {
$query = $this->connection->getQueryBuilder();
$rows = $query
->select("fileid", "mimetype", "a.album_id", "size", "mtime", "etag", "location", "created", "last_added_photo", "added", 'owner')
->selectAlias("f.name", "file_name")
->selectAlias("a.name", "album_name")
->selectAlias("a.user", "album_user")
->from("photos_albums_collabs", "c")
->leftJoin("c", "photos_albums", "a", $query->expr()->eq("a.album_id", "c.album_id"))
->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('collaborator_id', $query->createNamedParameter($collaboratorId)))
->andWhere($query->expr()->eq('collaborator_type', $query->createNamedParameter($collaboratorType, IQueryBuilder::PARAM_INT)))
->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'], $row['owner']);
}
if (!isset($albumsById[$albumId])) {
$albumName = $row['album_name'];
// Suffix album name with the album owner to prevent duplicates.
// Not done for public link as it would leak the owner's uid.
if ($collaboratorType !== self::TYPE_LINK) {
$albumName = $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'], $collaboratorType);
}
}
$result = [];
foreach ($albumsById as $id => $album) {
$result[] = new AlbumWithFiles($album, $this, $filesByAlbum[$id] ?? []);
}
return $result;
}
/**
* @param string $userId
* @param int $albumId
* @return void
*/
public function deleteUserFromAlbumCollaboratorsList(string $userId, int $albumId): void {
$query = $this->connection->getQueryBuilder();
$query->delete('photos_albums_collabs')
->where($query->expr()->eq('album_id', $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('collaborator_id', $query->createNamedParameter($userId)))
->andWhere($query->expr()->eq('collaborator_type', $query->createNamedParameter(self::TYPE_USER, IQueryBuilder::PARAM_INT)))
->executeStatement();
// Remove all photos by this user from the album:
$this->removeFilesForUser($albumId, $userId);
}
/**
* @param string $groupId
* @param int $albumId
* @return void
*/
public function deleteGroupFromAlbumCollaboratorsList(string $groupId, int $albumId): void {
$query = $this->connection->getQueryBuilder();
$query->delete('photos_albums_collabs')
->where($query->expr()->eq('album_id', $query->createNamedParameter($albumId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('collaborator_id', $query->createNamedParameter($groupId)))
->andWhere($query->expr()->eq('collaborator_type', $query->createNamedParameter(self::TYPE_GROUP, IQueryBuilder::PARAM_INT)))
->executeStatement();
}
/**
* @param string $collaboratorId
* @param int $collaboratorType
* @param int $fileId
* @return AlbumInfo[]
*/
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")
->from("photos_albums_collabs", "c")
->leftJoin("c", "photos_albums", "a", $query->expr()->eq("a.album_id", "c.album_id"))
->leftJoin("a", "photos_albums_files", "p", $query->expr()->eq("a.album_id", "p.album_id"))
->where($query->expr()->eq('collaborator_id', $query->createNamedParameter($collaboratorId)))
->andWhere($query->expr()->eq('collaborator_type', $query->createNamedParameter($collaboratorType, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('file_id', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
->groupBy('a.album_id')
->executeQuery()
->fetchAll();
return array_map(function (array $row) {
return new AlbumInfo(
(int)$row['album_id'],
$row['user'],
$row['name'].' ('.$row['user'].')',
$row['location'],
(int)$row['created'],
(int)$row['last_added_photo']
);
}, $rows);
}
}
@@ -0,0 +1,83 @@
<?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\Album;
class AlbumWithFiles {
private AlbumInfo $info;
private AlbumMapper $albumMapper;
/** @var AlbumFile[] */
private array $files;
public function __construct(
AlbumInfo $info,
AlbumMapper $albumMapper,
array $files = []) {
$this->info = $info;
$this->albumMapper = $albumMapper;
$this->files = $files;
}
public function getAlbum(): AlbumInfo {
return $this->info;
}
/**
* @return AlbumFile[]
*/
public function getFiles(): array {
if (empty($this->files)) {
$this->files = $this->fetchFiles();
}
return $this->files;
}
/**
* @return AlbumFile[]
*/
public function addFile(AlbumFile $file): array {
if (empty($this->files)) {
$this->files = $this->fetchFiles();
}
array_push($this->files, $file);
return $this->files;
}
/**
* @return int[]
*/
public function getFileIds(): array {
return array_map(function (AlbumFile $file) {
return $file->getFileId();
}, $this->getFiles());
}
/**
* @return AlbumFile[]
*/
private function fetchFiles(): array {
return $this->albumMapper->getForAlbumIdAndUserWithFiles($this->info->getId(), $this->info->getUserId());
}
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
/**
* @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/>.
*
*/
namespace OCA\Photos\AppInfo;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\Events\SabrePluginAuthInitEvent;
use OCA\Files\Event\LoadSidebar;
use OCA\Photos\Listener\AlbumsManagementEventListener;
use OCA\Photos\Listener\CSPListener;
use OCA\Photos\Listener\ExifMetadataProvider;
use OCA\Photos\Listener\LoadSidebarScripts;
use OCA\Photos\Listener\OriginalDateTimeMetadataProvider;
use OCA\Photos\Listener\PlaceMetadataProvider;
use OCA\Photos\Listener\SabrePluginAuthInitListener;
use OCA\Photos\Listener\SizeMetadataProvider;
use OCA\Photos\Listener\TagListener;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Files\Events\Node\NodeDeletedEvent;
use OCP\FilesMetadata\Event\MetadataBackgroundEvent;
use OCP\FilesMetadata\Event\MetadataLiveEvent;
use OCP\FilesMetadata\IFilesMetadataManager;
use OCP\FilesMetadata\Model\IMetadataValueWrapper;
use OCP\Group\Events\GroupDeletedEvent;
use OCP\Group\Events\UserRemovedEvent;
use OCP\Security\CSP\AddContentSecurityPolicyEvent;
use OCP\Share\Events\ShareDeletedEvent;
use OCP\SystemTag\MapperEvent;
use OCP\User\Events\UserDeletedEvent;
class Application extends App implements IBootstrap {
public const APP_ID = 'photos';
public const IMAGE_MIMES = [
'image/png',
'image/jpeg',
'image/heic',
'image/webp',
// 'image/gif', // too rarely used for photos
// 'image/x-xbitmap', // too rarely used for photos
// 'image/bmp', // too rarely used for photos
// 'image/svg+xml', // too rarely used for photos
];
public const VIDEO_MIMES = [
// 'video/mpeg', // too rarely used for photos
// 'video/ogg', // too rarely used for photos
// 'video/webm', // too rarely used for photos
'video/mp4',
// 'video/x-m4v', // too rarely used for photos
'video/quicktime',
// 'video/x-matroska' // too rarely used for photos
];
public function __construct() {
parent::__construct(self::APP_ID);
}
public function register(IRegistrationContext $context): void {
/** Register $principalBackend for the DAV collection */
$context->registerServiceAlias('principalBackend', Principal::class);
$context->registerEventListener(LoadSidebar::class, LoadSidebarScripts::class);
$context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class);
// Metadata
$context->registerEventListener(MetadataLiveEvent::class, ExifMetadataProvider::class);
$context->registerEventListener(MetadataBackgroundEvent::class, ExifMetadataProvider::class);
$context->registerEventListener(MetadataLiveEvent::class, SizeMetadataProvider::class);
$context->registerEventListener(MetadataBackgroundEvent::class, SizeMetadataProvider::class);
$context->registerEventListener(MetadataLiveEvent::class, OriginalDateTimeMetadataProvider::class);
$context->registerEventListener(MetadataBackgroundEvent::class, OriginalDateTimeMetadataProvider::class);
$context->registerEventListener(MetadataLiveEvent::class, PlaceMetadataProvider::class);
$context->registerEventListener(MetadataBackgroundEvent::class, PlaceMetadataProvider::class);
$context->registerEventListener(NodeDeletedEvent::class, AlbumsManagementEventListener::class);
$context->registerEventListener(UserRemovedEvent::class, AlbumsManagementEventListener::class);
$context->registerEventListener(GroupDeletedEvent::class, AlbumsManagementEventListener::class);
$context->registerEventListener(UserDeletedEvent::class, AlbumsManagementEventListener::class);
$context->registerEventListener(ShareDeletedEvent::class, AlbumsManagementEventListener::class);
$context->registerEventListener(SabrePluginAuthInitEvent::class, SabrePluginAuthInitListener::class);
$context->registerEventListener(MapperEvent::EVENT_ASSIGN, TagListener::class);
$context->registerEventListener(MapperEvent::EVENT_UNASSIGN, TagListener::class);
}
public function boot(IBootContext $context): void {
/** @var IFilesMetadataManager $metadataManager */
$metadataManager = $context->getServerContainer()->get(IFilesMetadataManager::class);
$metadataManager->initMetadata('photos-original_date_time', IMetadataValueWrapper::TYPE_INT, true, IMetadataValueWrapper::EDIT_FORBIDDEN);
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/**
* @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/>.
*
*/
namespace OCA\Photos\Command;
use OCA\Photos\Service\ReverseGeoCoderService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class UpdateReverseGeocodingFilesCommand extends Command {
public function __construct(
private ReverseGeoCoderService $rgcService,
) {
parent::__construct();
}
/**
* Configure the command
*/
protected function configure(): void {
$this->setName('photos:update-1000-cities')
->setDescription('Update the list of 1000 and more inhabitant cities');
}
/**
* Execute the command
*/
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$this->rgcService->buildKDTree(true);
} catch (\Exception $ex) {
$output->writeln('<error>Failed to update reverse geocoding files</error>');
$output->writeln($ex->getMessage());
return 1;
}
return 0;
}
}
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @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\Files_Sharing\SharedStorage;
use OCA\Photos\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\Files\StorageNotAvailableException;
use OCP\IPreview;
use OCP\IRequest;
class AlbumsController extends Controller {
private string $userId;
private IRootFolder $rootFolder;
private IPreview $previewManager;
public function __construct(
string $userId,
IRequest $request,
IRootFolder $rootFolder,
IPreview $previewManager
) {
parent::__construct(Application::APP_ID, $request);
$this->userId = $userId;
$this->rootFolder = $rootFolder;
$this->previewManager = $previewManager;
}
/**
* @NoAdminRequired
*/
public function myAlbums(string $path = ''): JSONResponse {
return $this->generate($path, false);
}
/**
* @NoAdminRequired
*/
public function sharedAlbums(string $path = ''): JSONResponse {
return $this->generate($path, true);
}
private function generate(string $path, bool $shared): JSONResponse {
$userFolder = $this->rootFolder->getUserFolder($this->userId);
$folder = $userFolder;
if ($path !== '') {
try {
$folder = $userFolder->get($path);
} catch (NotFoundException $e) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
}
$data = $this->scanCurrentFolder($folder, $shared);
$result = $this->formatData($data);
return new JSONResponse($result, Http::STATUS_OK);
}
private function formatData(iterable $nodes): array {
$userFolder = $this->rootFolder->getUserFolder($this->userId);
$result = [];
/** @var Node $node */
foreach ($nodes as $node) {
// properly format full path and make sure
// we're relative to the user home folder
$isRoot = $node === $userFolder;
$path = $userFolder->getRelativePath($node->getPath());
$result[] = [
'basename' => $isRoot ? '' : $node->getName(),
'etag' => $node->getEtag(),
'fileid' => $node->getId(),
'filename' => $path,
'lastmod' => $node->getMTime(),
'mime' => $node->getMimetype(),
'size' => $node->getSize(),
'type' => $node->getType(),
'permissions' => $node->getPermissions(),
'hasPreview' => $this->previewManager->isAvailable($node),
];
}
return $result;
}
private function scanCurrentFolder(Folder $folder, bool $shared): iterable {
$nodes = $folder->getDirectoryListing();
// add current folder to iterable set
yield $folder;
foreach ($nodes as $node) {
if ($node instanceof Folder && $this->scanFolder($node, 0, $shared)) {
yield $node;
} elseif ($node instanceof File) {
if ($this->validFile($node, $shared)) {
yield $node;
}
}
}
}
private function validFile(File $file, bool $shared): bool {
$allowed_mimes = array_merge(Application::IMAGE_MIMES, Application::VIDEO_MIMES);
if (in_array($file->getMimeType(), $allowed_mimes) && $this->isShared($file) === $shared) {
return true;
}
return false;
}
private function isShared(Node $node): bool {
return $node->getStorage()->instanceOfStorage(SharedStorage::class) ||
$node->getStorage()->instanceOfStorage(\OCA\GroupFolders\Mount\GroupFolderStorage::class);
}
private function scanFolder(Folder $folder, int $depth, bool $shared): bool {
if ($depth > 4) {
return false;
}
try {
// Ignore folder with a .noimage or .nomedia node
if ($folder->nodeExists('.noimage') || $folder->nodeExists('.nomedia')) {
return false;
}
$nodes = $folder->getDirectoryListing();
} catch (StorageNotAvailableException $e) {
return false;
}
foreach ($nodes as $node) {
if ($node instanceof File) {
if ($this->validFile($node, $shared)) {
return true;
}
}
}
foreach ($nodes as $node) {
if ($node instanceof Folder && $this->isShared($node) === $shared) {
if ($this->scanFolder($node, $depth + 1, $shared)) {
return true;
}
}
}
return false;
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020 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/>.
*
*/
namespace OCA\Photos\Controller;
use OCA\Photos\AppInfo\Application;
use OCA\Photos\Service\UserConfigService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\StreamResponse;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IUserSession;
class ApiController extends Controller {
private IConfig $config;
private IUserSession $userSession;
public function __construct(
IRequest $request,
IConfig $config,
IUserSession $userSession
) {
parent::__construct(Application::APP_ID, $request);
$this->config = $config;
$this->userSession = $userSession;
}
/**
* @NoAdminRequired
*
* update preferences (user setting)
*
* @param string key the identifier to change
* @param string value the value to set
*
* @return JSONResponse an empty JSONResponse with respective http status code
*/
public function setUserConfig(string $key, string $value): JSONResponse {
$user = $this->userSession->getUser();
if (is_null($user)) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
}
if (!in_array($key, array_keys(UserConfigService::DEFAULT_CONFIGS))) {
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
}
$this->config->setUserValue($user->getUid(), Application::APP_ID, $key, $value);
return new JSONResponse([], Http::STATUS_OK);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function serviceWorker(): StreamResponse {
$response = new StreamResponse(__DIR__.'/../../js/photos-service-worker.js');
$response->setHeaders([
'Content-Type' => 'application/javascript',
'Service-Worker-Allowed' => '/'
]);
$policy = new ContentSecurityPolicy();
$policy->addAllowedWorkerSrcDomain("'self'");
$policy->addAllowedScriptDomain("'self'");
$policy->addAllowedConnectDomain("'self'");
$response->setContentSecurityPolicy($policy);
return $response;
}
}
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
/**
* @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/>.
*
*/
namespace OCA\Photos\Controller;
use OC\Files\Search\SearchBinaryOperator;
use OC\Files\Search\SearchComparison;
use OC\Files\Search\SearchQuery;
use OC\User\NoUserException;
use OCA\Files\Event\LoadSidebar;
use OCA\Photos\AppInfo\Application;
use OCA\Photos\Service\UserConfigService;
use OCA\Viewer\Event\LoadViewer;
use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\InvalidPathException;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\Search\ISearchBinaryOperator;
use OCP\Files\Search\ISearchComparison;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\Util;
use Psr\Log\LoggerInterface;
class PageController extends Controller {
private IAppManager $appManager;
private IEventDispatcher $eventDispatcher;
private UserConfigService $userConfig;
private IInitialState $initialState;
private IUserSession $userSession;
private IRootFolder $rootFolder;
private ICacheFactory $cacheFactory;
private IL10N $l10n;
private ICache $nomediaPathsCache;
private LoggerInterface $logger;
public function __construct(
IRequest $request,
IAppManager $appManager,
IEventDispatcher $eventDispatcher,
UserConfigService $userConfig,
IInitialState $initialState,
IUserSession $userSession,
IRootFolder $rootFolder,
ICacheFactory $cacheFactory,
LoggerInterface $logger,
private IConfig $config,
IL10N $l10n
) {
parent::__construct(Application::APP_ID, $request);
$this->appManager = $appManager;
$this->eventDispatcher = $eventDispatcher;
$this->userConfig = $userConfig;
$this->initialState = $initialState;
$this->userSession = $userSession;
$this->rootFolder = $rootFolder;
$this->cacheFactory = $cacheFactory;
$this->nomediaPathsCache = $this->cacheFactory->createLocal('photos:nomedia-paths');
$this->logger = $logger;
$this->l10n = $l10n;
}
/**
* @NoAdminRequired
* @NoCSRFRequired
* Render default index template
*
* @return TemplateResponse
*/
public function index(): TemplateResponse {
$user = $this->userSession->getUser();
$this->eventDispatcher->dispatch(LoadSidebar::class, new LoadSidebar());
$this->eventDispatcher->dispatch(LoadViewer::class, new LoadViewer());
$this->initialState->provideInitialState('image-mimes', Application::IMAGE_MIMES);
$this->initialState->provideInitialState('video-mimes', Application::VIDEO_MIMES);
$this->initialState->provideInitialState('maps', $this->appManager->isEnabledForUser('maps') === true);
$this->initialState->provideInitialState('recognize', $this->appManager->isEnabledForUser('recognize') === true);
$this->initialState->provideInitialState('systemtags', $this->appManager->isEnabledForUser('systemtags') === true);
$this->initialState->provideInitialState('showPeopleMenuEntry', $this->config->getAppValue('photos', 'showPeopleMenuEntry', 'true') === 'true');
// Provide user config
foreach (array_keys(UserConfigService::DEFAULT_CONFIGS) as $key) {
$this->initialState->provideInitialState($key, $this->userConfig->getUserConfig($key));
}
$paths = [];
try {
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$key = $user->getUID() . ':' . $userFolder->getEtag();
$paths = $this->nomediaPathsCache->get($key);
if ($paths === null) {
$search = $userFolder->search(new SearchQuery(new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, [
new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'name', '.nomedia'),
new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'name', '.noimage')
]), 0, 0, [], $user));
$paths = array_map(function (Node $node) use ($userFolder) {
return substr(dirname($node->getPath()), strlen($userFolder->getPath()));
}, $search);
$this->nomediaPathsCache->set($key, $paths, 60 * 60 * 24 * 28); // 28 days
}
} catch (InvalidPathException | NotFoundException | NotPermittedException | NoUserException $e) {
$this->logger->error($e->getMessage());
}
$this->initialState->provideInitialState('nomedia-paths', $paths);
Util::addScript(Application::APP_ID, 'photos-main');
if ($this->appManager->isEnabledForUser('recognize') === true) {
// Allow auto-translation of tags
Util::addTranslations('recognize');
}
$response = new TemplateResponse(Application::APP_ID, 'main', [
'pageTitle' => $this->l10n->t('Photos')
]);
$policy = new ContentSecurityPolicy();
$policy->addAllowedWorkerSrcDomain("'self'");
$policy->addAllowedScriptDomain("'self'");
$response->setContentSecurityPolicy($policy);
return $response;
}
}
@@ -0,0 +1,174 @@
<?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 OCA\Photos\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\IGroupManager;
use OCP\IPreview;
use OCP\IRequest;
use OCP\IUserSession;
class PreviewController extends Controller {
private IUserSession $userSession;
private ?Folder $userFolder;
private IRootFolder $rootFolder;
protected AlbumMapper $albumMapper;
private IPreview $preview;
private IGroupManager $groupManager;
public function __construct(
IRequest $request,
IUserSession $userSession,
?Folder $userFolder,
IRootFolder $rootFolder,
AlbumMapper $albumMapper,
IPreview $preview,
IGroupManager $groupManager
) {
parent::__construct(Application::APP_ID, $request);
$this->userSession = $userSession;
$this->userFolder = $userFolder;
$this->rootFolder = $rootFolder;
$this->albumMapper = $albumMapper;
$this->preview = $preview;
$this->groupManager = $groupManager;
}
/**
* @NoAdminRequired
* @NoCSRFRequired
* Render default index template
*
* @return DataResponse|FileDisplayResponse
*/
public function index(
int $fileId = -1,
int $x = 32,
int $y = 32
) {
if ($fileId === -1 || $x === 0 || $y === 0) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
$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) {
$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($receivedAlbums, $checkedAlbums, fn ($a, $b) => ($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->getAlbumsForCollaboratorIdAndFileId($groupId, AlbumMapper::TYPE_GROUP, $fileId);
$albumsForGroup = array_udiff($albumsForGroup, $checkedAlbums, fn ($a, $b) => ($a->getId() - $b->getId()));
$nodes = $this->getFileIdForAlbums($fileId, $albumsForGroup);
$checkedAlbums = array_merge($checkedAlbums, $receivedAlbums);
if (\count($nodes) !== 0) {
break;
}
}
}
if (\count($nodes) === 0) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
$node = array_pop($nodes);
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
*/
protected function fetchPreview(
Node $node,
int $x,
int $y
) : Http\Response {
if (!($node instanceof File) || !$this->preview->isAvailable($node)) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
if (!$node->isReadable()) {
return new DataResponse([], Http::STATUS_FORBIDDEN);
}
try {
$f = $this->preview->getPreview($node, $x, $y);
$response = new FileDisplayResponse($f, Http::STATUS_OK, [
'Content-Type' => $f->getMimeType(),
]);
$response->cacheFor(3600 * 24, false, true);
return $response;
} catch (NotFoundException $e) {
return new DataResponse([], Http::STATUS_NOT_FOUND);
} catch (\InvalidArgumentException $e) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
}
}
@@ -0,0 +1,76 @@
<?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 OCA\Photos\AppInfo\Application;
use OCA\Viewer\Event\LoadViewer;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\Template\PublicTemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\EventDispatcher\IEventDispatcher;
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');
$response = new PublicTemplateResponse(Application::APP_ID, 'public');
$policy = new ContentSecurityPolicy();
$policy->addAllowedWorkerSrcDomain("'self'");
$policy->addAllowedScriptDomain("'self'");
$response->setContentSecurityPolicy($policy);
return $response;
}
}
@@ -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);
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <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\DB;
class PhotosFile {
public function __construct(
private int $fileId,
private string $name,
private string $mimeType,
private int $size,
private int $mtime,
private string $etag,
) {
}
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(): string {
return $this->etag;
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/**
* @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/>.
*
*/
namespace OCA\Photos\DB\Place;
use OCA\Photos\DB\PhotosFile;
class PlaceFile extends PhotosFile {
public function __construct(
int $fileId,
string $name,
string $mimeType,
int $size,
int $mtime,
string $etag,
private string $place,
) {
parent::__construct(
$fileId,
$name,
$mimeType,
$size,
$mtime,
$etag,
);
}
public function getPlace(): string {
return $this->place;
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/**
* @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/>.
*
*/
namespace OCA\Photos\DB\Place;
class PlaceInfo {
public function __construct(
private string $userId,
private string $place
) {
}
public function getUserId(): string {
return $this->userId;
}
public function getPlace(): string {
return $this->place;
}
}
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
/**
* @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/>.
*
*/
namespace OCA\Photos\DB\Place;
use OCA\Photos\AppInfo\Application;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\IMimeTypeLoader;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\FilesMetadata\IFilesMetadataManager;
use OCP\IDBConnection;
class PlaceMapper {
public const METADATA_KEY = 'photos-place';
public function __construct(
private IDBConnection $connection,
private IMimeTypeLoader $mimeTypeLoader,
private IRootFolder $rootFolder,
private IFilesMetadataManager $filesMetadataManager,
) {
}
/** @return PlaceInfo[] */
public function findPlacesForUser(string $userId): array {
$storageId = $this->rootFolder
->getUserFolder($userId)
->getMountPoint()
->getNumericStorageId();
$mimetypes = array_map(fn ($mimetype) => $this->mimeTypeLoader->getId($mimetype), Application::IMAGE_MIMES);
$qb = $this->connection->getQueryBuilder();
$qb->selectDistinct('meta_value_string')
->from('filecache', 'file');
$metadataQuery = $this->filesMetadataManager->getMetadataQuery($qb, 'file', 'fileid');
$metadataQuery->joinIndex(self::METADATA_KEY);
$rows = $qb->where($qb->expr()->eq('file.storage', $qb->createNamedParameter($storageId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->in('file.mimetype', $qb->createNamedParameter($mimetypes, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($qb->expr()->isNotNull('meta_value_string'))
->executeQuery()
->fetchAll();
return array_map(fn ($row) => new PlaceInfo($userId, $row['meta_value_string']), $rows);
}
/** @return PlaceInfo */
public function findPlaceForUser(string $userId, string $place): PlaceInfo {
$storageId = $this->rootFolder
->getUserFolder($userId)
->getMountPoint()
->getNumericStorageId();
$mimetypes = array_map(fn ($mimetype) => $this->mimeTypeLoader->getId($mimetype), Application::IMAGE_MIMES);
$qb = $this->connection->getQueryBuilder();
$qb->selectDistinct('meta_value_string')
->from('filecache', 'file');
$metadataQuery = $this->filesMetadataManager->getMetadataQuery($qb, 'file', 'fileid');
$metadataQuery->joinIndex(self::METADATA_KEY);
$rows = $qb->where($qb->expr()->eq('file.storage', $qb->createNamedParameter($storageId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->in('file.mimetype', $qb->createNamedParameter($mimetypes, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($qb->expr()->eq('meta_value_string', $qb->createNamedParameter($place)))
->andWhere($qb->expr()->isNotNull('meta_value_string'))
->executeQuery()
->fetchAll();
if (count($rows) !== 1) {
throw new NotFoundException();
}
return new PlaceInfo($userId, $rows[0]['meta_value_string']);
}
/** @return PlaceFile[] */
public function findFilesForUserAndPlace(string $userId, string $place) {
$storageId = $this->rootFolder
->getUserFolder($userId)
->getMountPoint()
->getNumericStorageId();
$mimetypes = array_map(fn ($mimetype) => $this->mimeTypeLoader->getId($mimetype), Application::IMAGE_MIMES);
$qb = $this->connection->getQueryBuilder();
$rows = $qb->select('file.fileid', 'file.name', 'file.mimetype', 'file.size', 'file.mtime', 'file.etag', 'meta_value_string')
->from('filecache', 'file');
$metadataQuery = $this->filesMetadataManager->getMetadataQuery($qb, 'file', 'fileid');
$metadataQuery->joinIndex(self::METADATA_KEY);
$rows = $qb->where($qb->expr()->eq('file.storage', $qb->createNamedParameter($storageId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->in('file.mimetype', $qb->createNamedParameter($mimetypes, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($qb->expr()->eq('meta_value_string', $qb->createNamedParameter($place)))
->andWhere($qb->expr()->isNotNull('meta_value_string'))
->executeQuery()
->fetchAll();
return array_map(
fn ($row) => new PlaceFile(
(int)$row['fileid'],
$row['name'],
$this->mimeTypeLoader->getMimetypeById($row['mimetype']),
(int)$row['size'],
(int)$row['mtime'],
$row['etag'],
$row['meta_value_string']
),
$rows,
);
}
public function findFileForUserAndPlace(string $userId, string $place, string $fileId, string $fileName): PlaceFile {
$storageId = $this->rootFolder
->getUserFolder($userId)
->getMountPoint()
->getNumericStorageId();
$mimetypes = array_map(fn ($mimetype) => $this->mimeTypeLoader->getId($mimetype), Application::IMAGE_MIMES);
$qb = $this->connection->getQueryBuilder();
$rows = $qb->select('file.fileid', 'file.name', 'file.mimetype', 'file.size', 'file.mtime', 'file.etag', 'meta_value_string')
->from('filecache', 'file');
$metadataQuery = $this->filesMetadataManager->getMetadataQuery($qb, 'file', 'fileid');
$metadataQuery->joinIndex(self::METADATA_KEY);
$rows = $qb->where($qb->expr()->eq('file.storage', $qb->createNamedParameter($storageId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('file.fileid', $qb->createNamedParameter($fileId)))
->andWhere($qb->expr()->eq('file.name', $qb->createNamedParameter($fileName)))
->andWhere($qb->expr()->in('file.mimetype', $qb->createNamedParameter($mimetypes, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($qb->expr()->eq('meta_value_string', $qb->createNamedParameter($place)))
->andWhere($qb->expr()->isNotNull('meta_value_string'))
->executeQuery()
->fetchAll();
if (count($rows) !== 1) {
throw new NotFoundException();
}
return new PlaceFile(
(int)$rows[0]['fileid'],
$rows[0]['name'],
$this->mimeTypeLoader->getMimetypeById($rows[0]['mimetype']),
(int)$rows[0]['size'],
(int)$rows[0]['mtime'],
$rows[0]['etag'],
$rows[0]['meta_value_string']
);
}
public function setPlaceForFile(string $place, int $fileId): void {
$metadata = $this->filesMetadataManager->getMetadata($fileId, true);
$metadata->setString('gps', $place, true);
}
}
@@ -0,0 +1,27 @@
<?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\Exception;
class AlreadyInAlbumException extends \Exception {
}
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
/**
* @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/>.
*
*/
namespace OCA\Photos\Jobs;
use OCA\Photos\AppInfo\Application;
use OCA\Photos\Service\MediaPlaceManager;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\IConfig;
use OCP\IUserManager;
class AutomaticPlaceMapperJob extends TimedJob {
public function __construct(
ITimeFactory $time,
private IConfig $config,
private IRootFolder $rootFolder,
private IUserManager $userManager,
private MediaPlaceManager $mediaPlaceManager,
) {
parent::__construct($time);
$this->mediaPlaceManager = $mediaPlaceManager;
$this->setTimeSensitivity(\OCP\BackgroundJob\IJob::TIME_INSENSITIVE);
$this->setInterval(24 * 3600);
}
protected function run($argument) {
$placeMappingDone = $this->config->getAppValue(Application::APP_ID, 'lastPlaceMappingDone', 'false');
if ($placeMappingDone === 'true') {
return;
}
$users = $this->userManager->search('');
$lastMappedUser = $this->config->getAppValue(Application::APP_ID, 'lastPlaceMappedUser', '');
if ($lastMappedUser === '') {
$lastMappedUser = $users[array_key_first($users)]->getUID();
}
$startTime = null;
foreach ($users as $user) {
if ($startTime === null) {
// Skip all user before lastMappedUser.
if ($lastMappedUser !== $user->getUID()) {
continue;
}
$startTime = time();
}
// Stop if execution time is more than one hour.
if (time() - $startTime > 60 * 60) {
return;
}
$this->scanFilesForUser($user->getUID());
$this->config->setAppValue(Application::APP_ID, 'lastPlaceMappedUser', $user->getUID());
}
$this->config->setAppValue(Application::APP_ID, 'lastPlaceMappingDone', 'true');
}
private function scanFilesForUser(string $userId): void {
$userFolder = $this->rootFolder->getUserFolder($userId);
$this->scanFolder($userFolder);
}
private function scanFolder(Folder $folder): void {
// Do not scan share and other moveable mounts.
if ($folder->getMountPoint() instanceof \OC\Files\Mount\MoveableMount) {
return;
}
foreach ($folder->getDirectoryListing() as $node) {
if ($node instanceof Folder) {
$this->scanFolder($node);
continue;
}
if (!str_starts_with($node->getMimeType(), 'image')) {
continue;
}
$this->mediaPlaceManager->setPlaceForFile($node->getId());
}
}
}
@@ -0,0 +1,118 @@
<?php
namespace OCA\Photos\Listener;
use OCA\Photos\Album\AlbumMapper;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\Node\NodeDeletedEvent;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\Node;
use OCP\Group\Events\GroupDeletedEvent;
use OCP\Group\Events\UserRemovedEvent;
use OCP\Share\Events\ShareDeletedEvent;
use OCP\User\Events\UserDeletedEvent;
use Psr\Log\LoggerInterface;
/**
* @template-implements IEventListener<Event|NodeDeletedEvent|GroupDeletedEvent|ShareDeletedEvent|UserDeletedEvent>
*/
class AlbumsManagementEventListener implements IEventListener {
private AlbumMapper $albumMapper;
private LoggerInterface $logger;
public function __construct(
AlbumMapper $albumMapper,
LoggerInterface $logger,
) {
$this->albumMapper = $albumMapper;
$this->logger = $logger;
}
public function handle(Event $event): void {
if ($event instanceof NodeDeletedEvent) {
try {
// Remove node from all albums containing it.
$albums = $this->albumMapper->getForFile($event->getNode()->getId());
foreach ($albums as $album) {
$this->albumMapper->removeFile($album->getId(), $event->getNode()->getId());
}
} catch(\Throwable $ex) {
// If an error occur, return silently as we don't want to block the rest of the deletion process.
// It happened already during migrations when the albums table is not yet created, but a folder is deleted by the theming app.
$this->logger->error($ex->getMessage(), ['exception' => $ex]);
}
} elseif ($event instanceof UserDeletedEvent) {
// Delete all user's albums.
$albums = $this->albumMapper->getForUser($event->getUser()->getUID());
foreach ($albums as $album) {
$this->albumMapper->delete($album->getId());
}
$sharedAlbums = $this->albumMapper->getSharedAlbumsForCollaborator($event->getUser()->getUID(), AlbumMapper::TYPE_USER);
foreach ($sharedAlbums as $album) {
$this->albumMapper->deleteUserFromAlbumCollaboratorsList($event->getUser()->getUID(), $album->getId());
}
} elseif ($event instanceof ShareDeletedEvent) {
$receiverId = $event->getShare()->getSharedWith();
if ($receiverId !== null) { // null for public link shares
$this->forEachSubNode(
$event->getShare()->getNode(),
// Remove node from any album when the owner is $receiverId.
fn ($node) => $this->albumMapper->removeFileWithOwner($node->getId(), $receiverId),
);
}
} elseif ($event instanceof UserRemovedEvent) {
// Get all shared albums for this group:
$albums_group = $this->albumMapper->getSharedAlbumsForCollaborator($event->getGroup()->getGID(), AlbumMapper::TYPE_GROUP);
// Get all albums shared with this specific user:
$albums_user = $this->albumMapper->getSharedAlbumsForCollaborator($event->getUser()->getUID(), AlbumMapper::TYPE_USER);
// Get all group-shared albums that are not directly shared with the removed user in addition
$albums = array_udiff($albums_group, $albums_user, fn ($a, $b) => ($a->getId() - $b->getId()));
// Remove their photos from theses albums:
foreach ($albums as $album) {
$this->albumMapper->removeFilesForUser($album->getId(), $event->getUser()->getUID());
}
} elseif ($event instanceof GroupDeletedEvent) {
// Get all shared albums for this group:
$albums_group = $this->albumMapper->getSharedAlbumsForCollaborator($event->getGroup()->getGID(), AlbumMapper::TYPE_GROUP);
// Get all users of this group:
$users = $event->getGroup()->getUsers();
foreach ($users as $user) {
// Get all albums shared with this specific user:
$albums_user = $this->albumMapper->getSharedAlbumsForCollaborator($user->getUID(), AlbumMapper::TYPE_USER);
// Get all group-shared albums that are not directly shared with the removed user in addition
$albums = array_udiff($albums_group, $albums_user, fn ($a, $b) => ($a->getId() - $b->getId()));
// Remove their photos from theses albums:
foreach ($albums as $album) {
$this->albumMapper->removeFilesForUser($album->getId(), $user->getUID());
}
}
foreach ($albums_group as $album) {
$this->albumMapper->deleteGroupFromAlbumCollaboratorsList($event->getGroup()->getGID(), $album->getId());
}
}
}
private function forEachSubNode(Node $node, callable $callback): void {
if ($node instanceof Folder) {
foreach ($node->getDirectoryListing() as $subNode) {
$this->forEachSubNode($subNode, $callback);
}
} elseif ($node instanceof File) {
if (!str_starts_with($node->getMimeType(), 'image')) {
return;
}
$callback($node);
}
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023, 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 OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Security\CSP\AddContentSecurityPolicyEvent;
/**
* @template-implements IEventListener<AddContentSecurityPolicyEvent>
*/
class CSPListener implements IEventListener {
public function __construct(
) {
}
public function handle(Event $event): void {
if (!($event instanceof AddContentSecurityPolicyEvent)) {
return;
}
$csp = new ContentSecurityPolicy();
$csp->addAllowedImageDomain('https://*.tile.openstreetmap.org');
$event->addPolicy($csp);
}
}
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright 2022 Carl Schwan <carl@carlschwan.eu>
* @copyright Copyright 2022 Louis Chmn <louis@chmn.me>
* @license AGPL-3.0-or-later
*
* 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\Listener;
use OCA\Photos\AppInfo\Application;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\File;
use OCP\FilesMetadata\Event\MetadataBackgroundEvent;
use OCP\FilesMetadata\Event\MetadataLiveEvent;
use Psr\Log\LoggerInterface;
/**
* Extract EXIF, IFD0, and GPS data from a picture file.
* EXIF data reference: https://web.archive.org/web/20220428165430/exif.org/Exif2-2.PDF
*
* @template-implements IEventListener<MetadataLiveEvent>
*/
class ExifMetadataProvider implements IEventListener {
public function __construct(
private LoggerInterface $logger
) {
}
public function handle(Event $event): void {
if (!($event instanceof MetadataLiveEvent) && !($event instanceof MetadataBackgroundEvent)) {
return;
}
$node = $event->getNode();
if (!$node instanceof File || $node->getSize() === 0) {
return;
}
// We need the file content to extract the EXIF data.
// This can be slow for remote storage, so we do it in a background job.
if (!$node->getStorage()->isLocal() && $event instanceof MetadataLiveEvent) {
$event->requestBackgroundJob();
return;
}
if (!in_array($node->getMimeType(), Application::IMAGE_MIMES)) {
return;
}
if (!extension_loaded('exif')) {
return;
}
$fileDescriptor = $node->fopen('rb');
if ($fileDescriptor === false) {
return;
}
$rawExifData = null;
try {
// HACK: The stream_set_chunk_size call is needed to make reading exif data reliable.
// This is to trigger this condition: https://github.com/php/php-src/blob/d64aa6f646a7b5e58359dc79479860164239580a/main/streams/streams.c#L710
// But I don't understand yet why 1 as a special meaning.
$oldBufferSize = stream_set_chunk_size($fileDescriptor, 1);
$rawExifData = @exif_read_data($fileDescriptor, 'EXIF, GPS', true);
// We then revert the change after having read the exif data.
stream_set_chunk_size($fileDescriptor, $oldBufferSize);
} catch (\Exception $ex) {
$this->logger->info("Failed to extract metadata for " . $node->getId(), ['exception' => $ex]);
}
if ($rawExifData && array_key_exists('EXIF', $rawExifData)) {
$event->getMetadata()->setArray('photos-exif', $this->sanitizeEntries($rawExifData['EXIF'], $node));
}
if ($rawExifData && array_key_exists('IFD0', $rawExifData)) {
$event->getMetadata()->setArray('photos-ifd0', $this->sanitizeEntries($rawExifData['IFD0'], $node));
}
if (
$rawExifData &&
array_key_exists('GPS', $rawExifData)
) {
$gps = [];
if (
array_key_exists('GPSLatitude', $rawExifData['GPS']) && array_key_exists('GPSLatitudeRef', $rawExifData['GPS']) &&
array_key_exists('GPSLongitude', $rawExifData['GPS']) && array_key_exists('GPSLongitudeRef', $rawExifData['GPS'])
) {
$gps['latitude'] = $this->gpsDegreesToDecimal($rawExifData['GPS']['GPSLatitude'], $rawExifData['GPS']['GPSLatitudeRef']);
$gps['longitude'] = $this->gpsDegreesToDecimal($rawExifData['GPS']['GPSLongitude'], $rawExifData['GPS']['GPSLongitudeRef']);
}
if (array_key_exists('GPSAltitude', $rawExifData['GPS']) && array_key_exists('GPSAltitudeRef', $rawExifData['GPS'])) {
$gps['altitude'] = ($rawExifData['GPS']['GPSAltitudeRef'] === "\u{0000}" ? 1 : -1) * $this->parseGPSData($rawExifData['GPS']['GPSAltitude']);
}
if (!empty($gps)) {
$event->getMetadata()->setArray('photos-gps', $gps);
}
}
}
/**
* @param array|string $coordinates
*/
private function gpsDegreesToDecimal($coordinates, ?string $hemisphere): float {
if (is_string($coordinates)) {
$coordinates = array_map("trim", explode(",", $coordinates));
}
if (count($coordinates) !== 3) {
throw new \Exception('Invalid coordinate format: ' . json_encode($coordinates));
}
[$degrees, $minutes, $seconds] = array_map(fn ($rawDegree) => $this->parseGPSData($rawDegree), $coordinates);
$sign = ($hemisphere === 'W' || $hemisphere === 'S') ? -1 : 1;
return $sign * ($degrees + $minutes / 60 + $seconds / 3600);
}
private function parseGPSData(string $rawData): float {
$parts = explode('/', $rawData);
if ($parts[1] === '0') {
return 0;
}
return floatval($parts[0]) / floatval($parts[1] ?? 1);
}
/**
* Exif data can contain anything.
* This method will base 64 encode any non UTF-8 string in an array.
* This will also remove control characters from UTF-8 strings.
*
* @param array<string, string> $data
*/
private function sanitizeEntries(array $data, File $node): array {
$cleanData = [];
foreach ($data as $key => $value) {
if (is_string($value) && !mb_check_encoding($value, 'UTF-8')) {
$value = 'base64:'.base64_encode($value);
} elseif (is_string($value)) {
// TODO: Can be remove when the Sidebar use the @nextcloud/files to fetch and parse the DAV response.
$value = preg_replace('/[[:cntrl:]]/u', '', $value);
}
if (preg_match('/[^a-zA-Z]/', $key) !== 0) {
$key = preg_replace('/[^a-zA-Z]/', '_', $key);
}
// Arbitrary limit to filter out large EXIF entries.
if (is_string($value) && strlen($value) > 1000) {
$this->logger->info(
"EXIF entry ignored as it is too large",
[
'key' => $key,
'value' => $value,
'fileId' => $node->getId(),
]
);
} else {
$cleanData[$key] = $value;
}
}
return $cleanData;
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023, 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\Files\Event\LoadSidebar;
use OCA\Photos\AppInfo\Application;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IRequest;
use OCP\Util;
/**
* @template-implements IEventListener<LoadSidebar>
*/
class LoadSidebarScripts implements IEventListener {
public function __construct(
private IRequest $request,
) {
}
public function handle(Event $event): void {
if (!($event instanceof LoadSidebar)) {
return;
}
// Only load sidebar tab in the photos app.
if (!preg_match('/^photos\.page\..+/', $this->request->getParams()['_route'])) {
return;
}
Util::addScript(Application::APP_ID, 'photos-sidebar');
}
}
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright 2022 Carl Schwan <carl@carlschwan.eu>
* @copyright Copyright 2022 Louis Chmn <louis@chmn.me>
* @license AGPL-3.0-or-later
*
* 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\Listener;
use DateTime;
use OCA\Photos\AppInfo\Application;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\File;
use OCP\FilesMetadata\Event\MetadataBackgroundEvent;
use OCP\FilesMetadata\Event\MetadataLiveEvent;
use Psr\Log\LoggerInterface;
/**
* @template-implements IEventListener<MetadataLiveEvent>
*/
class OriginalDateTimeMetadataProvider implements IEventListener {
public function __construct(
private LoggerInterface $logger,
) {
}
public array $regexpToDateFormatMap = [
"/^IMG_([0-9]{8}_[0-9]{6})/" => "Ymd_Gis",
"/^PANO_([0-9]{8}_[0-9]{6})/" => "Ymd_Gis",
"/^PXL_([0-9]{8}_[0-9]{6})/" => "Ymd_Gis",
"/^([0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{4})/" => "Y-m-d-G-i-s",
];
private function dateToTimestamp(string $format, string $date, File $node): int|false {
try {
$dateTime = DateTime::createFromFormat($format, $date);
if ($dateTime !== false) {
return $dateTime->getTimestamp();
}
return false;
} catch (\Throwable $t) {
/* Date comes from user data and may trigger ValueError or DateRangeError */
$this->logger->warning(
'Failed to parse date {date} for file {path}',
[
'date' => $date,
'path' => $node->getPath(),
'exception' => $t,
]
);
return false;
}
}
public function handle(Event $event): void {
if (!($event instanceof MetadataLiveEvent) && !($event instanceof MetadataBackgroundEvent)) {
return;
}
$node = $event->getNode();
if (!$node instanceof File) {
return;
}
// We need the file content to extract the EXIF data.
// This can be slow for remote storage, so we do it in a background job.
if (!$node->getStorage()->isLocal() && $event instanceof MetadataLiveEvent) {
$event->requestBackgroundJob();
return;
}
if (!in_array($node->getMimeType(), Application::IMAGE_MIMES) && !in_array($node->getMimeType(), Application::VIDEO_MIMES)) {
return;
}
$metadata = $event->getMetadata();
// Try to use EXIF data.
if ($metadata->hasKey('photos-exif') && !empty($metadata->getArray('photos-exif')['DateTimeOriginal'])) {
$rawDateTimeOriginal = $metadata->getArray('photos-exif')['DateTimeOriginal'];
$timestampOriginal = $this->dateToTimestamp("Y:m:d G:i:s", $rawDateTimeOriginal, $node);
if ($timestampOriginal !== false) {
$metadata->setInt('photos-original_date_time', $timestampOriginal, true);
return;
}
}
// Try to parse the date out of the name.
$name = $node->getName();
$matches = [];
foreach ($this->regexpToDateFormatMap as $regexp => $format) {
$matchesCount = preg_match($regexp, $name, $matches);
if ($matchesCount === 0) {
continue;
}
$timestampOriginal = $this->dateToTimestamp($format, $matches[1], $node);
if ($timestampOriginal !== false) {
$metadata->setInt('photos-original_date_time', $timestampOriginal, true);
return;
}
}
// Fallback to the mtime.
$metadata->setInt('photos-original_date_time', $node->getMTime(), true);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright 2023 Louis Chmn <louis@chmn.me>
* @license AGPL-3.0-or-later
*
* 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\Listener;
use OCA\Photos\AppInfo\Application;
use OCA\Photos\Service\MediaPlaceManager;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\File;
use OCP\FilesMetadata\Event\MetadataBackgroundEvent;
use OCP\FilesMetadata\Event\MetadataLiveEvent;
/**
* @template-implements IEventListener<Event|MetadataLiveEvent|MetadataBackgroundEvent>
*/
class PlaceMetadataProvider implements IEventListener {
public function __construct(
private MediaPlaceManager $mediaPlaceManager
) {
}
public function handle(Event $event): void {
if ($event instanceof MetadataLiveEvent) {
$node = $event->getNode();
if (!$node instanceof File) {
return;
}
if (!in_array($node->getMimeType(), Application::IMAGE_MIMES)) {
return;
}
$event->requestBackgroundJob();
}
if ($event instanceof MetadataBackgroundEvent) {
$metadata = $event->getMetadata();
$place = $this->mediaPlaceManager->getPlaceForFile($event->getNode()->getId());
if ($place !== null) {
$metadata->setString('photos-place', $place, true);
}
}
}
}
@@ -0,0 +1,57 @@
<?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;
/**
* @template-implements IEventListener<SabrePluginAuthInitEvent>
*/
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();
if (!str_starts_with($server->getRequestUri(), 'photospublic/')) {
return;
}
$authPlugin = $server->getPlugin('auth');
$authPlugin->addBackend($this->publicAlbumAuthBackend);
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright 2022 Carl Schwan <carl@carlschwan.eu>
* @copyright Copyright 2022 Louis Chmn <louis@chmn.me>
* @license AGPL-3.0-or-later
*
* 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\Listener;
use OCA\Photos\AppInfo\Application;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\File;
use OCP\FilesMetadata\Event\MetadataBackgroundEvent;
use OCP\FilesMetadata\Event\MetadataLiveEvent;
use Psr\Log\LoggerInterface;
/**
* @template-implements IEventListener<MetadataLiveEvent>
*/
class SizeMetadataProvider implements IEventListener {
public function __construct(
private LoggerInterface $logger
) {
}
public function handle(Event $event): void {
if (!($event instanceof MetadataLiveEvent) && !($event instanceof MetadataBackgroundEvent)) {
return;
}
$node = $event->getNode();
if (!$node instanceof File || $node->getSize() === 0) {
return;
}
// We need the file content to extract the size.
// This can be slow for remote storage, so we do it in a background job.
if (!$node->getStorage()->isLocal() && $event instanceof MetadataLiveEvent) {
$event->requestBackgroundJob();
return;
}
if (!in_array($node->getMimeType(), Application::IMAGE_MIMES)) {
return;
}
$size = getimagesizefromstring($node->getContent());
if ($size === false) {
return;
}
$event->getMetadata()->setArray('photos-size', [
'width' => $size[0],
'height' => $size[1],
]);
}
}
@@ -0,0 +1,48 @@
<?php
namespace OCA\Photos\Listener;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Config\ICachedMountInfo;
use OCP\Files\Config\IUserMountCache;
use OCP\Files\IRootFolder;
use OCP\ICacheFactory;
use OCP\SystemTag\MapperEvent;
/**
* @template-implements IEventListener<MapperEvent>
*/
class TagListener implements IEventListener {
private \OCP\ICache $tagCountsCache;
private IRootFolder $rootFolder;
private IUserMountCache $userMountCache;
public function __construct(ICacheFactory $cacheFactory, IRootFolder $rootFolder, IUserMountCache $userMountCache) {
$this->tagCountsCache = $cacheFactory->createLocal('photos:tag-counts');
$this->rootFolder = $rootFolder;
$this->userMountCache = $userMountCache;
}
/**
* @inheritDoc
*/
public function handle(Event $event): void {
if ($event instanceof MapperEvent) {
if ($event->getObjectType() !== 'files') {
return;
}
$node = current($this->rootFolder->getById((int)$event->getObjectId()));
if (!$node) {
return;
}
$mounts = $this->userMountCache->getMountsForRootId($node->getMountPoint()->getStorageRootId());
$userIds = array_map(static function (ICachedMountInfo $mount) {
return $mount->getUser()->getUID();
}, $mounts);
foreach ($userIds as $userId) {
$this->tagCountsCache->remove($userId);
}
}
}
}
@@ -0,0 +1,94 @@
<?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\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
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 ISchemaWrapper $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;
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <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\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version20001Date20220830131446 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$modified = false;
/**
* Replaced by Version20003Date20221102170153
*
* if (!$schema->hasTable("photos_collaborators")) {
* $modified = true;
* $table = $schema->createTable("photos_collaborators");
* $table->addColumn('album_id', Types::BIGINT, [
* 'notnull' => true,
* 'length' => 20,
* ]);
* $table->addColumn('collaborator_id', Types::STRING, [
* 'notnull' => true,
* 'length' => 64,
* ]);
* $table->addColumn('collaborator_type', Types::INTEGER, [
* 'notnull' => true,
* ]);
*
* $table->addUniqueConstraint(['album_id', 'collaborator_id', 'collaborator_type'], 'collaborators_unique_idx');
* }
*/
if (!$schema->getTable("photos_albums_files")->hasColumn("owner")) {
$modified = true;
$table = $schema->getTable("photos_albums_files");
$table->addColumn('owner', Types::STRING, [
'notnull' => false,
'length' => 64,
]);
}
if ($modified) {
return $schema;
} else {
return null;
}
}
}
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <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\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\Types;
use OCP\IDBConnection;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version20003Date20221102170153 extends SimpleMigrationStep {
protected IDBConnection $connection;
public function __construct(IDBConnection $connection) {
$this->connection = $connection;
}
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if ($schema->hasTable("photos_albums_collabs")) {
return null;
}
$table = $schema->createTable("photos_albums_collabs");
$table->addColumn('id', Types::BIGINT, [
'autoincrement' => true,
'notnull' => true,
'length' => 20,
]);
$table->addColumn('album_id', Types::BIGINT, [
'notnull' => true,
'length' => 20,
]);
$table->addColumn('collaborator_id', Types::STRING, [
'notnull' => true,
'length' => 64,
]);
$table->addColumn('collaborator_type', Types::INTEGER, [
'notnull' => true,
]);
$table->setPrimaryKey(['id']);
$table->addUniqueIndex(['album_id', 'collaborator_id', 'collaborator_type'], 'album_collabs_uniq_collab');
return $schema;
}
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return void
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable('photos_collaborators')) {
return;
}
$insert = $this->connection->getQueryBuilder();
$insert->insert('photos_albums_collabs')
->values([
'album_id' => $insert->createParameter('album_id'),
'collaborator_id' => $insert->createParameter('collaborator_id'),
'collaborator_type' => $insert->createParameter('collaborator_type'),
]);
$select = $this->connection->getQueryBuilder();
$select->select('*')
->from('photos_collaborators')
->setMaxResults(1000);
$offset = 0;
$movedRows = 0;
do {
$movedRows = $this->chunkedCopying($insert, $select, $offset);
$offset += $movedRows;
} while ($movedRows !== 0);
}
protected function chunkedCopying(IQueryBuilder $insert, IQueryBuilder $select, int $offset): int {
$this->connection->beginTransaction();
$rowCount = 0;
$result = $select
->setFirstResult($offset)
->executeQuery();
while ($row = $result->fetch()) {
$rowCount += $insert
->setParameter('album_id', (int)$row['album_id'], IQueryBuilder::PARAM_INT)
->setParameter('collaborator_id', $row['collaborator_id'])
->setParameter('collaborator_type', (int) $row['collaborator_type'], IQueryBuilder::PARAM_INT)
->executeStatement();
}
$result->closeCursor();
$this->connection->commit();
// $result->rowCount() cannot be relied on.
// some database drivers may return the number of rows returned by that query
return $rowCount;
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <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\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version20003Date20221103094628 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if ($schema->hasTable('photos_collaborators')) {
$schema->dropTable('photos_collaborators');
return $schema;
}
return null;
}
}
@@ -0,0 +1,79 @@
<?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 OCA\Photos\Album\AlbumFile;
use OCA\Photos\Album\AlbumInfo;
use OCA\Photos\Album\AlbumMapper;
use OCA\Photos\Sabre\CollectionPhoto;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use Sabre\DAV\IFile;
class AlbumPhoto extends CollectionPhoto implements IFile {
public function __construct(
private AlbumMapper $albumMapper,
private AlbumInfo $album,
private AlbumFile $albumFile,
private IRootFolder $rootFolder,
Folder $userFolder,
) {
parent::__construct($albumFile, $userFolder);
}
/**
* @return void
*/
public function delete() {
$this->albumMapper->removeFile($this->album->getId(), $this->file->getFileId());
}
private function getNode(): Node {
$nodes = $this->rootFolder
->getUserFolder($this->albumFile->getOwner() ?: $this->album->getUserId())
->getById($this->file->getFileId());
$node = current($nodes);
if ($node) {
return $node;
} else {
throw new NotFoundException("Photo not found for user");
}
}
public function get() {
$node = $this->getNode();
if ($node instanceof File) {
return $node->fopen('r');
} else {
throw new NotFoundException("Photo is a folder");
}
}
public function getFileInfo(): Node {
return $this->getNode();
}
}
@@ -0,0 +1,244 @@
<?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 OCA\DAV\Connector\Sabre\File;
use OCA\Photos\Album\AlbumFile;
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\Files\NotFoundException;
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 {
protected AlbumMapper $albumMapper;
protected AlbumWithFiles $album;
protected IRootFolder $rootFolder;
protected string $userId;
public function __construct(
AlbumMapper $albumMapper,
AlbumWithFiles $album,
IRootFolder $rootFolder,
string $userId,
UserConfigService $userConfigService
) {
$this->albumMapper = $albumMapper;
$this->album = $album;
$this->rootFolder = $rootFolder;
$this->userId = $userId;
$this->userConfigService = $userConfigService;
}
/**
* @return void
*/
public function delete() {
$this->albumMapper->delete($this->album->getAlbum()->getId());
}
public function getName(): string {
return basename($this->album->getAlbum()->getTitle());
}
/**
* @return void
*/
public function setName($name) {
$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 string $name
* @param null|resource|string $data
* @return void
*/
public function createFile($name, $data = null) {
try {
[$photosLocation, $userFolder] = $this->getPhotosLocationInfo();
try {
$photosFolder = $userFolder->get($photosLocation);
} catch (NotFoundException $e) {
// If the folder does not exists, create it
$photosFolder = $userFolder->newFolder($photosLocation);
}
// If the node is not a folder, we throw
if (!($photosFolder instanceof Folder)) {
throw new Conflict('The destination exists and is not a folder');
}
// Check for conflict and rename the file accordingly
$newName = \basename(\OC_Helper::buildNotExistingFileName($photosLocation, $name));
$node = $photosFolder->newFile($newName, $data);
$this->addFile($node->getId(), $node->getOwner()->getUID());
// Cheating with header because we are using fileID-fileName
// https://github.com/nextcloud/server/blob/af29b978078ffd9169a9bd9146feccbb7974c900/apps/dav/lib/Connector/Sabre/FilesPlugin.php#L564-L585
\header('OC-FileId: ' . $node->getId());
return '"' . $node->getEtag() . '"';
} catch (\Exception $e) {
throw new Forbidden('Could not create file');
}
}
/**
* @return never
*/
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->rootFolder, $this->rootFolder->getUserFolder($this->userId));
}, $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->rootFolder, $this->rootFolder->getUserFolder($this->userId));
}
}
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 {
if (!$sourceNode instanceof File) {
throw new Forbidden("The source is not a file");
}
$sourceId = $sourceNode->getId();
$ownerUID = $sourceNode->getFileInfo()->getOwner()->getUID();
$uid = $this->userId;
if ($ownerUID !== $uid) {
throw new Forbidden("Can't add file to album, only files from $uid can be added");
}
return $this->addFile($sourceId, $ownerUID);
}
protected function addFile(int $sourceId, string $ownerUID): bool {
if (in_array($sourceId, $this->album->getFileIds())) {
throw new Conflict("File $sourceId is already in the folder");
}
if ($ownerUID === $this->userId) {
$this->albumMapper->addFile($this->album->getAlbum()->getId(), $sourceId, $ownerUID);
$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;
}
return false;
}
public function getAlbum(): AlbumWithFiles {
return $this->album;
}
public function getDateRange(): array {
$earliestDate = null;
$latestDate = null;
foreach ($this->getChildren() as $child) {
try {
$childCreationDate = $child->getFileInfo()->getMtime();
} catch (NotFoundException $e) {
continue;
}
if ($childCreationDate < $earliestDate || $earliestDate === null) {
$earliestDate = $childCreationDate;
}
if ($childCreationDate > $earliestDate || $latestDate === null) {
$latestDate = $childCreationDate;
}
}
return ['start' => $earliestDate, 'end' => $latestDate];
}
/**
* @return int|null
*/
public function getCover() {
$children = $this->getChildren();
if (count($children) > 0) {
return $children[0]->getFileId();
} else {
return null;
}
}
/**
* @return array{array{'nc:collaborator': array{'id': string, 'label': string, 'type': int}}}
*/
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();
}
}
@@ -0,0 +1,128 @@
<?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 OCA\Photos\Album\AlbumInfo;
use OCA\Photos\Album\AlbumMapper;
use OCA\Photos\Album\AlbumWithFiles;
use OCA\Photos\Service\UserConfigService;
use OCP\Files\IRootFolder;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\ICollection;
class AlbumsHome implements ICollection {
protected AlbumMapper $albumMapper;
protected array $principalInfo;
protected string $userId;
protected IRootFolder $rootFolder;
protected UserConfigService $userConfigService;
public const NAME = 'albums';
/**
* @var AlbumRoot[]
*/
protected ?array $children = null;
public function __construct(
array $principalInfo,
AlbumMapper $albumMapper,
string $userId,
IRootFolder $rootFolder,
UserConfigService $userConfigService
) {
$this->principalInfo = $principalInfo;
$this->albumMapper = $albumMapper;
$this->userId = $userId;
$this->rootFolder = $rootFolder;
$this->userConfigService = $userConfigService;
}
/**
* @return never
*/
public function delete() {
throw new Forbidden();
}
public function getName(): string {
return self::NAME;
}
/**
* @return never
*/
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');
}
/**
* @return void
*/
public function createDirectory($name) {
$this->albumMapper->create($this->userId, $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 {
if ($this->children === null) {
$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->userId, $this->userConfigService);
}, $albumInfos);
}
return $this->children;
}
public function childExists($name): bool {
try {
$this->getChild($name);
return true;
} catch (NotFound $e) {
return false;
}
}
public function getLastModified(): int {
return 0;
}
}
@@ -0,0 +1,39 @@
<?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 OCP\Files\NotFoundException;
use Sabre\DAV\IFile;
class PublicAlbumPhoto extends AlbumPhoto implements IFile {
/** @return void */
public function delete() {
throw new NotFoundException("Deleting photos from a public album is not allowed.");
}
/** @return void */
public function put($data) {
throw new NotFoundException("Changing a photo from a public album is not allowed.");
}
}
@@ -0,0 +1,91 @@
<?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 OCA\Photos\Album\AlbumFile;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\NotFound;
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];
}
public function createFile($name, $data = null) {
throw new Forbidden('Not allowed to create a file in a public album');
}
protected function addFile(int $sourceId, string $ownerUID): bool {
throw new Forbidden('Not allowed to add a file to 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');
}
/** @return never */
public function getChildren(): array {
return array_map(function (AlbumFile $file) {
return new PublicAlbumPhoto($this->albumMapper, $this->album->getAlbum(), $file, $this->rootFolder, $this->rootFolder->getUserFolder($this->userId));
}, $this->album->getFiles());
}
public function getChild($name): PublicAlbumPhoto {
foreach ($this->album->getFiles() as $file) {
if ($file->getFileId() . "-" . $file->getName() === $name) {
return new PublicAlbumPhoto($this->albumMapper, $this->album->getAlbum(), $file, $this->rootFolder, $this->rootFolder->getUserFolder($this->userId));
}
}
throw new NotFound("$name not found");
}
}
@@ -0,0 +1,100 @@
<?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 OCA\Photos\Album\AlbumMapper;
use OCA\Photos\Album\AlbumWithFiles;
use OCA\Photos\Service\UserConfigService;
use OCP\Files\IRootFolder;
use OCP\IUserManager;
use Sabre\DAV\Exception\Conflict;
use Sabre\DAV\Exception\Forbidden;
class SharedAlbumRoot extends AlbumRoot {
private IUserManager $userManager;
public function __construct(
AlbumMapper $albumMapper,
AlbumWithFiles $album,
IRootFolder $rootFolder,
string $userId,
UserConfigService $userConfigService,
IUserManager $userManager
) {
parent::__construct(
$albumMapper,
$album,
$rootFolder,
$userId,
$userConfigService,
$userManager
);
$this->userManager = $userManager;
}
/**
* @return void
*/
public function delete() {
$this->albumMapper->deleteUserFromAlbumCollaboratorsList($this->userId, $this->album->getAlbum()->getId());
}
/**
* @return void
*/
public function setName($name) {
throw new Forbidden('Not allowed to rename a shared album');
}
protected function addFile(int $sourceId, string $ownerUID): bool {
if (in_array($sourceId, $this->album->getFileIds())) {
throw new Conflict("File $sourceId is already in the folder");
}
if (!$this->albumMapper->isCollaborator($this->album->getAlbum()->getId(), $this->userId)) {
return false;
}
$this->albumMapper->addFile($this->album->getAlbum()->getId(), $sourceId, $ownerUID);
return true;
}
/**
* Return only the owner, and do not reveal other collaborators.
*/
public function getCollaborators(): array {
return [[
'nc:collaborator' => [
'id' => $this->album->getAlbum()->getUserId(),
'label' => $this->userManager->get($this->album->getAlbum()->getUserId())->getDisplayName(),
'type' => $this->album->getAlbum()->getReceivedFrom(),
],
]];
}
public function setCollaborators($collaborators): array {
throw new Forbidden('Not allowed to collaborators to a public album');
}
}
@@ -0,0 +1,90 @@
<?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 OCA\Photos\Album\AlbumMapper;
use OCA\Photos\Album\AlbumWithFiles;
use OCA\Photos\Service\UserConfigService;
use OCP\Files\IRootFolder;
use OCP\IGroupManager;
use OCP\IUserManager;
use Sabre\DAV\Exception\Forbidden;
class SharedAlbumsHome extends AlbumsHome {
private IUserManager $userManager;
private IGroupManager $groupManager;
public const NAME = 'sharedalbums';
public function __construct(
array $principalInfo,
AlbumMapper $albumMapper,
string $userId,
IRootFolder $rootFolder,
IUserManager $userManager,
IGroupManager $groupManager,
UserConfigService $userConfigService
) {
parent::__construct(
$principalInfo,
$albumMapper,
$userId,
$rootFolder,
$userConfigService
);
$this->userManager = $userManager;
$this->groupManager = $groupManager;
}
/**
* @return never
*/
public function createDirectory($name) {
throw new Forbidden('Not allowed to create folders in this folder');
}
/**
* @return SharedAlbumRoot[]
*/
public function getChildren(): array {
if ($this->children === null) {
$albums = $this->albumMapper->getSharedAlbumsForCollaboratorWithFiles($this->userId, AlbumMapper::TYPE_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 $album) {
return new SharedAlbumRoot($this->albumMapper, $album, $this->rootFolder, $this->userId, $this->userConfigService, $this->userManager);
}, $albums);
}
return $this->children;
}
}
@@ -0,0 +1,119 @@
<?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\DB\PhotosFile;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\ITags;
use Sabre\DAV\Exception\Forbidden;
class CollectionPhoto {
public function __construct(
protected PhotosFile $file,
protected Folder $userFolder,
) {
}
public function getName() {
return $this->file->getFileId() . "-" . $this->file->getName();
}
/**
* @return never
*/
public function setName($name) {
throw new Forbidden('Can\'t rename photos trough this api');
}
public function getLastModified() {
return $this->file->getMTime();
}
public function put($data) {
$nodes = $this->userFolder->getById($this->file->getFileId());
$node = current($nodes);
if ($node) {
/** @var Node $node */
if ($node instanceof File) {
return $node->putContent($data);
} else {
throw new NotFoundException("Photo is a folder");
}
} else {
throw new NotFoundException("Photo not found for user");
}
}
public function getFileId(): int {
return $this->file->getFileId();
}
public function getContentType() {
return $this->file->getMimeType();
}
public function getETag() {
return $this->file->getEtag();
}
public function getSize() {
return $this->file->getSize();
}
public function getFile(): PhotosFile {
return $this->file;
}
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)) {
return false;
}
return array_search(ITags::TAG_FAVORITE, current($tags)) !== false;
}
public function setFavoriteState($favoriteState): bool {
$tagManager = \OCP\Server::get(\OCP\ITagManager::class);
$tagger = $tagManager->load('files');
switch ($favoriteState) {
case "0":
return $tagger->removeFromFavorites($this->file->getFileId());
case "1":
return $tagger->addToFavorites($this->file->getFileId());
default:
new \Exception('Favorite state is invalide, should be 0 or 1.');
}
}
}
@@ -0,0 +1,115 @@
<?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\DB\Place\PlaceMapper;
use OCA\Photos\Sabre\Album\AlbumsHome;
use OCA\Photos\Sabre\Album\SharedAlbumsHome;
use OCA\Photos\Sabre\Place\PlacesHome;
use OCA\Photos\Service\ReverseGeoCoderService;
use OCA\Photos\Service\UserConfigService;
use OCP\Files\IRootFolder;
use OCP\IGroupManager;
use OCP\IUserManager;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\ICollection;
class PhotosHome implements ICollection {
public function __construct(
private array $principalInfo,
private AlbumMapper $albumMapper,
private PlaceMapper $placeMapper,
private ReverseGeoCoderService $reverseGeoCoderService,
private string $userId,
private IRootFolder $rootFolder,
private IUserManager $userManager,
private IGroupManager $groupManager,
private UserConfigService $userConfigService,
) {
}
/**
* @return never
*/
public function delete() {
throw new Forbidden();
}
public function getName(): string {
[, $name] = \Sabre\Uri\split($this->principalInfo['uri']);
return $name;
}
/**
* @return never
*/
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');
}
/**
* @return never
*/
public function createDirectory($name) {
throw new Forbidden('Permission denied to create folders in this folder');
}
public function getChild($name) {
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);
case PlacesHome::NAME:
return new PlacesHome($this->userId, $this->rootFolder, $this->reverseGeoCoderService, $this->placeMapper);
}
throw new NotFound();
}
/**
* @return (AlbumsHome)[]
*/
public function getChildren(): array {
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),
new PlacesHome($this->userId, $this->rootFolder, $this->reverseGeoCoderService, $this->placeMapper),
];
}
public function childExists($name): bool {
return $name === AlbumsHome::NAME || $name === SharedAlbumsHome::NAME || $name === PlacesHome::NAME;
}
public function getLastModified(): int {
return 0;
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <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\Sabre\Place;
use OCA\Photos\DB\Place\PlaceFile;
use OCA\Photos\DB\Place\PlaceInfo;
use OCA\Photos\Sabre\CollectionPhoto;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\IFile;
class PlacePhoto extends CollectionPhoto implements IFile {
public function __construct(
private PlaceInfo $placeInfo,
PlaceFile $file,
private IRootFolder $rootFolder,
Folder $userFolder
) {
parent::__construct($file, $userFolder);
}
/**
* @return void
*/
public function delete() {
throw new Forbidden('Cannot remove from a place');
}
private function getNode(): Node {
$nodes = $this->rootFolder
->getUserFolder($this->placeInfo->getUserId())
->getById($this->file->getFileId());
$node = current($nodes);
if ($node) {
return $node;
} else {
throw new NotFoundException("Photo not found for user");
}
}
public function get() {
$node = $this->getNode();
if ($node instanceof File) {
return $node->fopen('r');
} else {
throw new NotFoundException("Photo is a folder");
}
}
public function getFileInfo(): Node {
return $this->getNode();
}
}
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <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\Sabre\Place;
use OCA\Photos\DB\Place\PlaceFile;
use OCA\Photos\DB\Place\PlaceInfo;
use OCA\Photos\DB\Place\PlaceMapper;
use OCA\Photos\Service\ReverseGeoCoderService;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\ICollection;
class PlaceRoot implements ICollection {
/** @var PlaceFile[]|null */
protected ?array $children = null;
public function __construct(
protected PlaceMapper $placeMapper,
protected ReverseGeoCoderService $reverseGeoCoderService,
protected PlaceInfo $placeInfo,
protected string $userId,
protected IRootFolder $rootFolder,
) {
}
/**
* @return never
*/
public function delete() {
throw new Forbidden('Not allowed to delete a place collection');
}
public function getName(): string {
return $this->placeInfo->getPlace();
}
/**
* @return never
*/
public function setName($name) {
throw new Forbidden('Cannot change the place collection name');
}
/**
* @param string $name
* @param null|resource|string $data
* @return never
*/
public function createFile($name, $data = null) {
throw new Forbidden('Cannot create a file in a place collection');
}
/**
* @return never
*/
public function createDirectory($name) {
throw new Forbidden('Not allowed to create directories in this folder');
}
/**
* @return PlacePhoto[]
*/
public function getChildren(): array {
if ($this->children === null) {
$this->children = array_map(
fn (PlaceFile $file) => new PlacePhoto($this->placeInfo, $file, $this->rootFolder, $this->rootFolder->getUserFolder($this->userId)),
$this->placeMapper->findFilesForUserAndPlace($this->placeInfo->getUserId(), $this->placeInfo->getPlace())
);
}
return $this->children;
}
public function getChild($name): PlacePhoto {
try {
[$fileId, $fileName] = explode('-', $name, 2);
$placeFile = $this->placeMapper->findFileForUserAndPlace($this->placeInfo->getUserId(), $this->placeInfo->getPlace(), $fileId, $fileName);
return new PlacePhoto($this->placeInfo, $placeFile, $this->rootFolder, $this->rootFolder->getUserFolder($this->userId));
} catch (NotFoundException $ex) {
throw new NotFound("File $name not found", 0, $ex);
}
}
public function childExists($name): bool {
try {
$this->getChild($name);
return true;
} catch (NotFound $e) {
return false;
}
}
public function getLastModified(): int {
return 0;
}
public function getFirstPhoto(): int {
$children = $this->getChildren();
if (count($children) === 0) {
throw new \Exception('No children found for place');
}
return $children[0]->getFileId();
}
/**
* @return int[]
*/
public function getFileIds(): array {
return array_map(function (PlacePhoto $file) {
return $file->getFileId();
}, $this->getChildren());
}
/**
* @return int|null
*/
public function getCover() {
$children = $this->getChildren();
if (count($children) > 0) {
return $children[0]->getFileId();
} else {
return null;
}
}
}
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <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\Sabre\Place;
use OCA\Photos\DB\Place\PlaceInfo;
use OCA\Photos\DB\Place\PlaceMapper;
use OCA\Photos\Service\ReverseGeoCoderService;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\ICollection;
class PlacesHome implements ICollection {
public const NAME = 'places';
/**
* @var PlaceRoot[]
*/
protected ?array $children = null;
public function __construct(
protected string $userId,
protected IRootFolder $rootFolder,
protected ReverseGeoCoderService $reverseGeoCoderService,
protected PlaceMapper $placeMapper,
) {
}
/**
* @return never
*/
public function delete() {
throw new Forbidden();
}
public function getName(): string {
return self::NAME;
}
/**
* @return never
*/
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('Not allowed to create folder in this folder');
}
public function getChild($name): PlaceRoot {
try {
$placeInfo = $this->placeMapper->findPlaceForUser($this->userId, $name);
return new PlaceRoot($this->placeMapper, $this->reverseGeoCoderService, $placeInfo, $this->userId, $this->rootFolder);
} catch (NotFoundException $ex) {
throw new NotFound("Place $name does not exist", 0, $ex);
}
}
/**
* @return PlaceRoot[]
*/
public function getChildren(): array {
if ($this->children === null) {
$this->children = array_map(
fn (PlaceInfo $placeInfo) => new PlaceRoot($this->placeMapper, $this->reverseGeoCoderService, $placeInfo, $this->userId, $this->rootFolder),
$this->placeMapper->findPlacesForUser($this->userId)
);
}
return $this->children;
}
public function childExists($name): bool {
try {
$this->getChild($name);
return true;
} catch (NotFound $e) {
return false;
}
}
public function getLastModified(): int {
return 0;
}
}
@@ -0,0 +1,164 @@
<?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\DAV\Connector\Sabre\FilesPlugin;
use OCA\Photos\Album\AlbumMapper;
use OCA\Photos\Sabre\Album\AlbumPhoto;
use OCA\Photos\Sabre\Album\AlbumRoot;
use OCA\Photos\Sabre\Album\PublicAlbumPhoto;
use OCA\Photos\Sabre\Place\PlacePhoto;
use OCA\Photos\Sabre\Place\PlaceRoot;
use OCP\Files\DavUtil;
use OCP\Files\NotFoundException;
use OCP\FilesMetadata\IFilesMetadataManager;
use OCP\IPreview;
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 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';
public const LOCATION_PROPERTYNAME = '{http://nextcloud.org/ns}location';
public const LAST_PHOTO_PROPERTYNAME = '{http://nextcloud.org/ns}last-photo';
public const NBITEMS_PROPERTYNAME = '{http://nextcloud.org/ns}nbItems';
public const COLLABORATORS_PROPERTYNAME = '{http://nextcloud.org/ns}collaborators';
public const PERMISSIONS_PROPERTYNAME = '{http://owncloud.org/ns}permissions';
private IPreview $previewManager;
private ?Tree $tree;
private AlbumMapper $albumMapper;
public function __construct(
IPreview $previewManager,
AlbumMapper $albumMapper,
private IFilesMetadataManager $filesMetadataManager,
) {
$this->previewManager = $previewManager;
$this->albumMapper = $albumMapper;
}
/**
* 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
*/
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): void {
if ($node instanceof AlbumPhoto || $node instanceof PlacePhoto) {
// Checking if the node is truly available and ignoring if not
// Should be pre-emptively handled by the NodeDeletedEvent
try {
$fileInfo = $node->getFileInfo();
} catch (NotFoundException $e) {
return;
}
$propFind->handle(FilesPlugin::INTERNAL_FILEID_PROPERTYNAME, fn () => $node->getFile()->getFileId());
$propFind->handle(FilesPlugin::GETETAG_PROPERTYNAME, fn () => $node->getETag());
$propFind->handle(self::FILE_NAME_PROPERTYNAME, fn () => $node->getFile()->getName());
$propFind->handle(self::FAVORITE_PROPERTYNAME, fn () => $node->isFavorite() ? 1 : 0);
$propFind->handle(FilesPlugin::HAS_PREVIEW_PROPERTYNAME, fn () => json_encode($this->previewManager->isAvailable($fileInfo)));
$propFind->handle(FilesPlugin::PERMISSIONS_PROPERTYNAME, function () use ($node): string {
$permissions = DavUtil::getDavPermissions($node->getFileInfo());
$filteredPermissions = str_replace('R', '', $permissions);
if ($node instanceof PublicAlbumPhoto) {
$filteredPermissions = str_replace('D', '', $filteredPermissions);
$filteredPermissions = str_replace('NV', '', $filteredPermissions);
$filteredPermissions = str_replace('W', '', $filteredPermissions);
}
return $filteredPermissions;
});
foreach ($node->getFileInfo()->getMetadata() as $metadataKey => $metadataValue) {
$propFind->handle(FilesPlugin::FILE_METADATA_PREFIX.$metadataKey, $metadataValue);
}
$propFind->handle(FilesPlugin::HIDDEN_PROPERTYNAME, function () use ($node) {
$metadata = $this->filesMetadataManager->getMetadata((int)$node->getFileInfo()->getId(), true);
return $metadata->hasKey('files-live-photo') && $node->getFileInfo()->getMimetype() === 'video/quicktime' ? 'true' : 'false';
});
}
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());
$propFind->handle(self::DATE_RANGE_PROPERTYNAME, fn () => json_encode($node->getDateRange()));
$propFind->handle(self::COLLABORATORS_PROPERTYNAME, fn () => $node->getCollaborators());
}
if ($node instanceof PlaceRoot) {
$propFind->handle(self::LAST_PHOTO_PROPERTYNAME, fn () => $node->getFirstPhoto());
$propFind->handle(self::NBITEMS_PROPERTYNAME, fn () => count($node->getChildren()));
}
}
public function handleUpdateProperties($path, PropPatch $propPatch): void {
$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;
});
$propPatch->handle(self::COLLABORATORS_PROPERTYNAME, function ($collaborators) use ($node) {
$collaborators = $node->setCollaborators(json_decode($collaborators, true));
return true;
});
}
if ($node instanceof AlbumPhoto) {
$propPatch->handle(self::FAVORITE_PROPERTYNAME, function ($favoriteState) use ($node) {
$node->setFavoriteState($favoriteState);
return true;
});
}
}
}
@@ -0,0 +1,78 @@
<?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 Sabre\DAV\Auth\Backend\BackendInterface;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
class PublicAlbumAuthBackend implements BackendInterface {
public function __construct() {
}
/**
* When this method is called, the backend must check if authentication was
* successful.
*
* The returned value must be one of the following
*
* [true, "principals/username"]
* [false, "reason for failure"]
*
* If authentication was successful, it's expected that the authentication
* backend returns a so-called principal url.
*
* Examples of a principal url:
*
* principals/admin
* principals/user1
* principals/users/joe
* principals/uid/123457
*
* If you don't use WebDAV ACL (RFC3744) we recommend that you simply
* return a string such as:
*
* principals/users/[username]
*
* @return array
*/
public function check(RequestInterface $request, ResponseInterface $response) {
\OC_User::setIncognitoMode(true);
return [true, "principals/token"];
}
/**
* This method is called when a user could not be authenticated, and
* authentication was required for the current request.
*
* This gives you the opportunity to set authentication headers. The 401
* status code will already be set.
*
* Keep in mind that in the case of multiple authentication backends, other
* WWW-Authenticate headers may already have been set, and you'll want to
* append your own WWW-Authenticate header instead of overwriting the
* existing one.
*/
public function challenge(RequestInterface $request, ResponseInterface $response) {
// This is intended to be public - there is no need to set WWW-Authenticate header
}
}
@@ -0,0 +1,99 @@
<?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 OCP\IRequest;
use OCP\Security\Bruteforce\IThrottler;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAVACL\AbstractPrincipalCollection;
use Sabre\DAVACL\PrincipalBackend;
class PublicRootCollection extends AbstractPrincipalCollection {
private const BRUTEFORCE_ACTION = 'publicphotos_webdav_auth';
private AlbumMapper $albumMapper;
private IRootFolder $rootFolder;
private UserConfigService $userConfigService;
private IRequest $request;
private IThrottler $throttler;
public function __construct(
AlbumMapper $albumMapper,
IRootFolder $rootFolder,
PrincipalBackend\BackendInterface $principalBackend,
UserConfigService $userConfigService,
IRequest $request,
IThrottler $throttler
) {
parent::__construct($principalBackend, 'principals/token');
$this->albumMapper = $albumMapper;
$this->rootFolder = $rootFolder;
$this->userConfigService = $userConfigService;
$this->request = $request;
$this->throttler = $throttler;
}
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) {
$this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), self::BRUTEFORCE_ACTION);
if (is_null($token)) {
throw new \Sabre\DAV\Exception\Forbidden();
}
$albums = $this->albumMapper->getSharedAlbumsForCollaboratorWithFiles($token, AlbumMapper::TYPE_LINK);
if (count($albums) !== 1) {
$this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress());
throw new NotFound("Unable to find public album");
}
return new PublicAlbumRoot($this->albumMapper, $albums[0], $this->rootFolder, $albums[0]->getAlbum()->getUserId(), $this->userConfigService);
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Photos\Sabre;
use OCA\Photos\Album\AlbumMapper;
use OCA\Photos\DB\Place\PlaceMapper;
use OCA\Photos\Service\ReverseGeoCoderService;
use OCA\Photos\Service\UserConfigService;
use OCP\Files\IRootFolder;
use OCP\IGroupManager;
use OCP\IUserManager;
use OCP\IUserSession;
use Sabre\DAVACL\AbstractPrincipalCollection;
use Sabre\DAVACL\PrincipalBackend;
class RootCollection extends AbstractPrincipalCollection {
public function __construct(
private AlbumMapper $albumMapper,
private PlaceMapper $placeMapper,
private ReverseGeoCoderService $reverseGeoCoderService,
private IUserSession $userSession,
private IRootFolder $rootFolder,
PrincipalBackend\BackendInterface $principalBackend,
private IUserManager $userManager,
private IGroupManager $groupManager,
private UserConfigService $userConfigService,
) {
parent::__construct($principalBackend, 'principals/users');
}
/**
* 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
*/
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->albumMapper, $this->placeMapper, $this->reverseGeoCoderService, $name, $this->rootFolder, $this->userManager, $this->groupManager, $this->userConfigService);
}
public function getName(): string {
return 'photos';
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/**
* @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/>.
*
*/
namespace OCA\Photos\Service;
use OCA\Photos\DB\Place\PlaceMapper;
use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException;
use OCP\FilesMetadata\IFilesMetadataManager;
class MediaPlaceManager {
public function __construct(
private IFilesMetadataManager $filesMetadataManager,
private ReverseGeoCoderService $rgcService,
private PlaceMapper $placeMapper,
) {
}
public function setPlaceForFile(int $fileId): void {
$place = $this->getPlaceForFile($fileId);
if ($place === null) {
return;
}
$this->placeMapper->setPlaceForFile($place, $fileId);
}
public function getPlaceForFile(int $fileId): ?string {
try {
$metadata = $this->filesMetadataManager->getMetadata($fileId, true);
} catch (FilesMetadataNotFoundException) {
return null;
}
if (!$metadata->hasKey('photos-gps')) {
return null;
}
$coordinate = $metadata->getArray('photos-gps');
$latitude = $coordinate['latitude'] ?? null;
$longitude = $coordinate['longitude'] ?? null;
if ($latitude === null || $longitude === null) {
return null;
}
return $this->rgcService->getPlaceForCoordinates((float) $latitude, (float) $longitude);
}
}
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
/**
* @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/>.
*
*/
namespace OCA\Photos\Service;
use Hexogen\KDTree\FSKDTree;
use Hexogen\KDTree\FSTreePersister;
use Hexogen\KDTree\Item;
use Hexogen\KDTree\ItemFactory;
use Hexogen\KDTree\ItemList;
use Hexogen\KDTree\KDTree;
use Hexogen\KDTree\NearestSearch;
use Hexogen\KDTree\Point;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\Http\Client\IClientService;
class ReverseGeoCoderService {
private ?ISimpleFolder $geoNameFolderCache = null;
private ?NearestSearch $fsSearcher = null;
/** @var array<int, string> */
private ?array $citiesMapping = null;
public function __construct(
private IAppData $appData,
private IClientService $clientService,
) {
}
public function getPlaceForCoordinates(float $latitude, float $longitude): string {
$this->loadKdTree();
$result = $this->fsSearcher->search(new Point([$latitude, $longitude]), 1);
return $this->getPlaceNameForPlaceId($result[0]->getId());
}
private function geoNameFolder(): ISimpleFolder {
if ($this->geoNameFolderCache === null) {
try {
$this->geoNameFolderCache = $this->appData->getFolder("geonames");
} catch (NotFoundException $ex) {
$this->geoNameFolderCache = $this->appData->newFolder("geonames");
}
}
return $this->geoNameFolderCache;
}
private function getPlaceNameForPlaceId(int $placeId): string {
if ($this->citiesMapping === null) {
$this->downloadCities1000();
$cities1000 = $this->loadCities1000();
$this->citiesMapping = [];
foreach ($cities1000 as $city) {
$this->citiesMapping[$city['id']] = $city['name'];
}
}
return $this->citiesMapping[$placeId];
}
private function downloadCities1000(bool $force = false): void {
if ($this->geoNameFolder()->fileExists('cities1000.csv') && !$force) {
return;
}
// Download zip file to a tmp file.
$response = $this->clientService->newClient()->get("https://download.nextcloud.com/server/apps/photos/cities1000.zip");
$tmpFile = tmpfile();
$cities1000ZipTmpFileName = stream_get_meta_data($tmpFile)['uri'];
fclose($tmpFile);
file_put_contents($cities1000ZipTmpFileName, $response->getBody());
// Unzip the txt file into a stream.
$zip = new \ZipArchive;
$res = $zip->open($cities1000ZipTmpFileName);
if ($res !== true) {
throw new \Exception("Fail to unzip place file: $res", $res);
}
$cities1000TxtSteam = $zip->getStream('cities1000.txt');
// Dump the txt file info into a smaller csv file.
$destinationStream = $this->geoNameFolder()->newFile('cities1000.csv')->write();
while (($fields = fgetcsv($cities1000TxtSteam, 0, " ")) !== false) {
$result = fputcsv(
$destinationStream,
[
'id' => (int)$fields[0],
'name' => $fields[1],
'latitude' => (float)$fields[4],
'longitude' => (float)$fields[5],
]
);
if ($result === false) {
throw new \Exception('Failed to write csv line to tmp stream');
}
}
$zip->close();
}
private function loadCities1000(): array {
$csvStream = $this->geoNameFolder()->getFile('cities1000.csv')->read();
$cities = [];
while (($fields = fgetcsv($csvStream)) !== false) {
$cities[] = [
'id' => (int)$fields[0],
'name' => $fields[1],
'latitude' => (float)$fields[2],
'longitude' => (float)$fields[3],
];
}
return $cities;
}
public function buildKDTree($force = false): void {
if ($this->geoNameFolder()->fileExists('cities1000.bin') && !$force) {
return;
}
$this->downloadCities1000($force);
$cities1000 = $this->loadCities1000();
$itemList = new ItemList(2);
foreach ($cities1000 as $city) {
$itemList->addItem(new Item($city['id'], [$city['latitude'], $city['longitude']]));
}
$tree = new KDTree($itemList);
// Persiste KDTree in app data.
$persister = new FSTreePersister('/');
$kdTreeTmpFileName = tempnam(sys_get_temp_dir(), "nextcloud_photos_");
$persister->convert($tree, $kdTreeTmpFileName);
$kdTreeString = file_get_contents($kdTreeTmpFileName);
$this->geoNameFolder()->newFile('cities1000.bin', $kdTreeString);
}
private function loadKdTree(): void {
if ($this->fsSearcher !== null) {
return;
}
$this->buildKDTree();
$kdTreeFileContent = $this->geoNameFolder()->getFile("cities1000.bin")->getContent();
$kdTreeTmpFileName = tempnam(sys_get_temp_dir(), "nextcloud_photos_");
file_put_contents($kdTreeTmpFileName, $kdTreeFileContent);
$fsTree = new FSKDTree($kdTreeTmpFileName, new ItemFactory());
$this->fsSearcher = new NearestSearch($fsTree);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 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/>.
*
*/
namespace OCA\Photos\Service;
use Exception;
use OCA\Photos\AppInfo\Application;
use OCP\IConfig;
use OCP\IUserSession;
class UserConfigService {
public const DEFAULT_CONFIGS = [
'croppedLayout' => 'false',
'photosLocation' => '/Photos',
];
private IConfig $config;
private IUserSession $userSession;
public function __construct(
IConfig $config,
IUserSession $userSession
) {
$this->config = $config;
$this->userSession = $userSession;
}
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');
}
$default = self::DEFAULT_CONFIGS[$key];
$value = $this->config->getUserValue($userId, Application::APP_ID, $key, $default);
return $value;
}
}