You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

SyncLivePhotosListener.php 8.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * @copyright Copyright (c) 2023 Louis Chemineau <louis@chmn.me>
  5. *
  6. * @author Louis Chemineau <louis@chmn.me>
  7. *
  8. * @license GNU AGPL version 3 or any later version
  9. *
  10. * This program is free software: you can redistribute it and/or modify
  11. * it under the terms of the GNU Affero General Public License as
  12. * published by the Free Software Foundation, either version 3 of the
  13. * License, or (at your option) any later version.
  14. *
  15. * This program is distributed in the hope that it will be useful,
  16. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  17. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  18. * GNU Affero General Public License for more details.
  19. *
  20. * You should have received a copy of the GNU Affero General Public License
  21. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  22. */
  23. namespace OCA\Files\Listener;
  24. use OCA\Files_Trashbin\Events\BeforeNodeRestoredEvent;
  25. use OCA\Files_Trashbin\Trash\ITrashItem;
  26. use OCA\Files_Trashbin\Trash\ITrashManager;
  27. use OCP\EventDispatcher\Event;
  28. use OCP\EventDispatcher\IEventListener;
  29. use OCP\Files\Cache\CacheEntryRemovedEvent;
  30. use OCP\Files\Events\Node\BeforeNodeDeletedEvent;
  31. use OCP\Files\Events\Node\BeforeNodeRenamedEvent;
  32. use OCP\Files\Folder;
  33. use OCP\Files\Node;
  34. use OCP\Files\NotFoundException;
  35. use OCP\Files\NotPermittedException;
  36. use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException;
  37. use OCP\FilesMetadata\IFilesMetadataManager;
  38. use OCP\IUserSession;
  39. /**
  40. * @template-implements IEventListener<Event>
  41. */
  42. class SyncLivePhotosListener implements IEventListener {
  43. /** @var Array<int, string> */
  44. private array $pendingRenames = [];
  45. /** @var Array<int, bool> */
  46. private array $pendingDeletion = [];
  47. /** @var Array<int, bool> */
  48. private array $pendingRestores = [];
  49. public function __construct(
  50. private ?Folder $userFolder,
  51. private ?IUserSession $userSession,
  52. private ITrashManager $trashManager,
  53. private IFilesMetadataManager $filesMetadataManager,
  54. ) {
  55. }
  56. public function handle(Event $event): void {
  57. if ($this->userFolder === null || $this->userSession === null) {
  58. return;
  59. }
  60. $peerFile = null;
  61. if ($event instanceof BeforeNodeRenamedEvent) {
  62. $peerFile = $this->getLivePhotoPeer($event->getSource()->getId());
  63. } elseif ($event instanceof BeforeNodeRestoredEvent) {
  64. $peerFile = $this->getLivePhotoPeer($event->getSource()->getId());
  65. } elseif ($event instanceof BeforeNodeDeletedEvent) {
  66. $peerFile = $this->getLivePhotoPeer($event->getNode()->getId());
  67. } elseif ($event instanceof CacheEntryRemovedEvent) {
  68. $peerFile = $this->getLivePhotoPeer($event->getFileId());
  69. }
  70. if ($peerFile === null) {
  71. return; // not a Live Photo
  72. }
  73. if ($event instanceof BeforeNodeRenamedEvent) {
  74. $this->handleMove($event, $peerFile);
  75. } elseif ($event instanceof BeforeNodeDeletedEvent) {
  76. $this->handleDeletion($event, $peerFile);
  77. } elseif ($event instanceof CacheEntryRemovedEvent) {
  78. $peerFile->delete();
  79. } elseif ($event instanceof BeforeNodeRestoredEvent) {
  80. $this->handleRestore($event, $peerFile);
  81. }
  82. }
  83. /**
  84. * During rename events, which also include move operations,
  85. * we rename the peer file using the same name.
  86. * The event listener being singleton, we can store the current state
  87. * of pending renames inside the 'pendingRenames' property,
  88. * to prevent infinite recursive.
  89. */
  90. private function handleMove(BeforeNodeRenamedEvent $event, Node $peerFile): void {
  91. $sourceFile = $event->getSource();
  92. $targetFile = $event->getTarget();
  93. $targetParent = $targetFile->getParent();
  94. $sourceExtension = $sourceFile->getExtension();
  95. $peerFileExtension = $peerFile->getExtension();
  96. $targetName = $targetFile->getName();
  97. $targetPath = $targetFile->getPath();
  98. if (!str_ends_with($targetName, ".".$sourceExtension)) {
  99. $event->abortOperation(new NotPermittedException("Cannot change the extension of a Live Photo"));
  100. }
  101. try {
  102. $targetParent->get($targetName);
  103. $event->abortOperation(new NotPermittedException("A file already exist at destination path of the Live Photo"));
  104. } catch (NotFoundException $ex) {
  105. }
  106. $peerTargetName = substr($targetName, 0, -strlen($sourceExtension)) . $peerFileExtension;
  107. try {
  108. $targetParent->get($peerTargetName);
  109. $event->abortOperation(new NotPermittedException("A file already exist at destination path of the Live Photo"));
  110. } catch (NotFoundException $ex) {
  111. }
  112. // in case the rename was initiated from this listener, we stop right now
  113. if (array_key_exists($peerFile->getId(), $this->pendingRenames)) {
  114. return;
  115. }
  116. $this->pendingRenames[$sourceFile->getId()] = $targetPath;
  117. try {
  118. $peerFile->move($targetParent->getPath() . '/' . $peerTargetName);
  119. } catch (\Throwable $ex) {
  120. $event->abortOperation($ex);
  121. }
  122. unset($this->pendingRenames[$sourceFile->getId()]);
  123. }
  124. /**
  125. * During deletion event, we trigger another recursive delete on the peer file.
  126. * Delete operations on the .mov file directly are currently blocked.
  127. * The event listener being singleton, we can store the current state
  128. * of pending deletions inside the 'pendingDeletions' property,
  129. * to prevent infinite recursivity.
  130. */
  131. private function handleDeletion(BeforeNodeDeletedEvent $event, Node $peerFile): void {
  132. $deletedFile = $event->getNode();
  133. if ($deletedFile->getMimetype() === 'video/quicktime') {
  134. if (isset($this->pendingDeletion[$peerFile->getId()])) {
  135. unset($this->pendingDeletion[$peerFile->getId()]);
  136. return;
  137. } else {
  138. $event->abortOperation(new NotPermittedException("Cannot delete the video part of a live photo"));
  139. }
  140. } else {
  141. $this->pendingDeletion[$deletedFile->getId()] = true;
  142. try {
  143. $peerFile->delete();
  144. } catch (\Throwable $ex) {
  145. $event->abortOperation($ex);
  146. }
  147. }
  148. return;
  149. }
  150. /**
  151. * During restore event, we trigger another recursive restore on the peer file.
  152. * Restore operations on the .mov file directly are currently blocked.
  153. * The event listener being singleton, we can store the current state
  154. * of pending restores inside the 'pendingRestores' property,
  155. * to prevent infinite recursivity.
  156. */
  157. private function handleRestore(BeforeNodeRestoredEvent $event, Node $peerFile): void {
  158. $sourceFile = $event->getSource();
  159. if ($sourceFile->getMimetype() === 'video/quicktime') {
  160. if (isset($this->pendingRestores[$peerFile->getId()])) {
  161. unset($this->pendingRestores[$peerFile->getId()]);
  162. return;
  163. } else {
  164. $event->abortOperation(new NotPermittedException("Cannot restore the video part of a live photo"));
  165. }
  166. } else {
  167. $user = $this->userSession->getUser();
  168. if ($user === null) {
  169. return;
  170. }
  171. $peerTrashItem = $this->trashManager->getTrashNodeById($user, $peerFile->getId());
  172. // Peer file is not in the bin, no need to restore it.
  173. if ($peerTrashItem === null) {
  174. return;
  175. }
  176. $trashRoot = $this->trashManager->listTrashRoot($user);
  177. $trashItem = $this->getTrashItem($trashRoot, $peerFile->getInternalPath());
  178. if ($trashItem === null) {
  179. $event->abortOperation(new NotFoundException("Couldn't find peer file in trashbin"));
  180. }
  181. $this->pendingRestores[$sourceFile->getId()] = true;
  182. try {
  183. $this->trashManager->restoreItem($trashItem);
  184. } catch (\Throwable $ex) {
  185. $event->abortOperation($ex);
  186. }
  187. }
  188. }
  189. /**
  190. * Helper method to get the associated live photo file.
  191. * We first look for it in the user folder, and if we
  192. * cannot find it here, we look for it in the user's trashbin.
  193. */
  194. private function getLivePhotoPeer(int $nodeId): ?Node {
  195. if ($this->userFolder === null || $this->userSession === null) {
  196. return null;
  197. }
  198. try {
  199. $metadata = $this->filesMetadataManager->getMetadata($nodeId);
  200. } catch (FilesMetadataNotFoundException $ex) {
  201. return null;
  202. }
  203. if (!$metadata->hasKey('files-live-photo')) {
  204. return null;
  205. }
  206. $peerFileId = (int)$metadata->getString('files-live-photo');
  207. // Check the user's folder.
  208. $node = $this->userFolder->getFirstNodeById($peerFileId);
  209. if ($node) {
  210. return $node;
  211. }
  212. // Check the user's trashbin.
  213. $user = $this->userSession->getUser();
  214. if ($user !== null) {
  215. $peerFile = $this->trashManager->getTrashNodeById($user, $peerFileId);
  216. if ($peerFile !== null) {
  217. return $peerFile;
  218. }
  219. }
  220. $metadata->unset('files-live-photo');
  221. return null;
  222. }
  223. /**
  224. * There is currently no method to restore a file based on its fileId or path.
  225. * So we have to manually find a ITrashItem from the trash item list.
  226. * TODO: This should be replaced by a proper method in the TrashManager.
  227. */
  228. private function getTrashItem(array $trashFolder, string $path): ?ITrashItem {
  229. foreach($trashFolder as $trashItem) {
  230. if (str_starts_with($path, "files_trashbin/files".$trashItem->getTrashPath())) {
  231. if ($path === "files_trashbin/files".$trashItem->getTrashPath()) {
  232. return $trashItem;
  233. }
  234. if ($trashItem instanceof Folder) {
  235. $node = $this->getTrashItem($trashItem->getDirectoryListing(), $path);
  236. if ($node !== null) {
  237. return $node;
  238. }
  239. }
  240. }
  241. }
  242. return null;
  243. }
  244. }