Provider.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-License-Identifier: AGPL-3.0-or-later
  5. */
  6. namespace OCA\Files\Activity;
  7. use OCP\Activity\Exceptions\UnknownActivityException;
  8. use OCP\Activity\IEvent;
  9. use OCP\Activity\IEventMerger;
  10. use OCP\Activity\IManager;
  11. use OCP\Activity\IProvider;
  12. use OCP\Contacts\IManager as IContactsManager;
  13. use OCP\Federation\ICloudIdManager;
  14. use OCP\Files\Folder;
  15. use OCP\Files\InvalidPathException;
  16. use OCP\Files\IRootFolder;
  17. use OCP\Files\Node;
  18. use OCP\Files\NotFoundException;
  19. use OCP\IL10N;
  20. use OCP\IURLGenerator;
  21. use OCP\IUserManager;
  22. use OCP\L10N\IFactory;
  23. class Provider implements IProvider {
  24. /** @var IL10N */
  25. protected $l;
  26. /** @var IL10N */
  27. protected $activityLang;
  28. /** @var string[] cached displayNames - key is the cloud id and value the displayname */
  29. protected $displayNames = [];
  30. protected $fileIsEncrypted = false;
  31. public function __construct(
  32. protected IFactory $languageFactory,
  33. protected IURLGenerator $url,
  34. protected IManager $activityManager,
  35. protected IUserManager $userManager,
  36. protected IRootFolder $rootFolder,
  37. protected ICloudIdManager $cloudIdManager,
  38. protected IContactsManager $contactsManager,
  39. protected IEventMerger $eventMerger,
  40. ) {
  41. }
  42. /**
  43. * @param string $language
  44. * @param IEvent $event
  45. * @param IEvent|null $previousEvent
  46. * @return IEvent
  47. * @throws UnknownActivityException
  48. * @since 11.0.0
  49. */
  50. public function parse($language, IEvent $event, ?IEvent $previousEvent = null) {
  51. if ($event->getApp() !== 'files') {
  52. throw new UnknownActivityException();
  53. }
  54. $this->l = $this->languageFactory->get('files', $language);
  55. $this->activityLang = $this->languageFactory->get('activity', $language);
  56. if ($this->activityManager->isFormattingFilteredObject()) {
  57. try {
  58. return $this->parseShortVersion($event, $previousEvent);
  59. } catch (UnknownActivityException) {
  60. // Ignore and simply use the long version...
  61. }
  62. }
  63. return $this->parseLongVersion($event, $previousEvent);
  64. }
  65. protected function setIcon(IEvent $event, string $icon, string $app = 'files') {
  66. if ($this->activityManager->getRequirePNG()) {
  67. $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath($app, $icon . '.png')));
  68. } else {
  69. $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath($app, $icon . '.svg')));
  70. }
  71. }
  72. /**
  73. * @param IEvent $event
  74. * @param IEvent|null $previousEvent
  75. * @return IEvent
  76. * @throws UnknownActivityException
  77. * @since 11.0.0
  78. */
  79. public function parseShortVersion(IEvent $event, ?IEvent $previousEvent = null): IEvent {
  80. $parsedParameters = $this->getParameters($event);
  81. if ($event->getSubject() === 'created_by') {
  82. $subject = $this->l->t('Created by {user}');
  83. $this->setIcon($event, 'add-color');
  84. } elseif ($event->getSubject() === 'changed_by') {
  85. $subject = $this->l->t('Changed by {user}');
  86. $this->setIcon($event, 'change');
  87. } elseif ($event->getSubject() === 'deleted_by') {
  88. $subject = $this->l->t('Deleted by {user}');
  89. $this->setIcon($event, 'delete-color');
  90. } elseif ($event->getSubject() === 'restored_by') {
  91. $subject = $this->l->t('Restored by {user}');
  92. $this->setIcon($event, 'actions/history', 'core');
  93. } elseif ($event->getSubject() === 'renamed_by') {
  94. $subject = $this->l->t('Renamed by {user}');
  95. $this->setIcon($event, 'change');
  96. } elseif ($event->getSubject() === 'moved_by') {
  97. $subject = $this->l->t('Moved by {user}');
  98. $this->setIcon($event, 'change');
  99. } else {
  100. throw new UnknownActivityException();
  101. }
  102. if (!isset($parsedParameters['user'])) {
  103. // External user via public link share
  104. $subject = str_replace('{user}', $this->activityLang->t('"remote account"'), $subject);
  105. }
  106. $this->setSubjects($event, $subject, $parsedParameters);
  107. return $this->eventMerger->mergeEvents('user', $event, $previousEvent);
  108. }
  109. /**
  110. * @param IEvent $event
  111. * @param IEvent|null $previousEvent
  112. * @return IEvent
  113. * @throws UnknownActivityException
  114. * @since 11.0.0
  115. */
  116. public function parseLongVersion(IEvent $event, ?IEvent $previousEvent = null): IEvent {
  117. $this->fileIsEncrypted = false;
  118. $parsedParameters = $this->getParameters($event);
  119. if ($event->getSubject() === 'created_self') {
  120. $subject = $this->l->t('You created {file}');
  121. if ($this->fileIsEncrypted) {
  122. $subject = $this->l->t('You created an encrypted file in {file}');
  123. }
  124. $this->setIcon($event, 'add-color');
  125. } elseif ($event->getSubject() === 'created_by') {
  126. $subject = $this->l->t('{user} created {file}');
  127. if ($this->fileIsEncrypted) {
  128. $subject = $this->l->t('{user} created an encrypted file in {file}');
  129. }
  130. $this->setIcon($event, 'add-color');
  131. } elseif ($event->getSubject() === 'created_public') {
  132. $subject = $this->l->t('{file} was created in a public folder');
  133. $this->setIcon($event, 'add-color');
  134. } elseif ($event->getSubject() === 'changed_self') {
  135. $subject = $this->l->t('You changed {file}');
  136. if ($this->fileIsEncrypted) {
  137. $subject = $this->l->t('You changed an encrypted file in {file}');
  138. }
  139. $this->setIcon($event, 'change');
  140. } elseif ($event->getSubject() === 'changed_by') {
  141. $subject = $this->l->t('{user} changed {file}');
  142. if ($this->fileIsEncrypted) {
  143. $subject = $this->l->t('{user} changed an encrypted file in {file}');
  144. }
  145. $this->setIcon($event, 'change');
  146. } elseif ($event->getSubject() === 'deleted_self') {
  147. $subject = $this->l->t('You deleted {file}');
  148. if ($this->fileIsEncrypted) {
  149. $subject = $this->l->t('You deleted an encrypted file in {file}');
  150. }
  151. $this->setIcon($event, 'delete-color');
  152. } elseif ($event->getSubject() === 'deleted_by') {
  153. $subject = $this->l->t('{user} deleted {file}');
  154. if ($this->fileIsEncrypted) {
  155. $subject = $this->l->t('{user} deleted an encrypted file in {file}');
  156. }
  157. $this->setIcon($event, 'delete-color');
  158. } elseif ($event->getSubject() === 'restored_self') {
  159. $subject = $this->l->t('You restored {file}');
  160. $this->setIcon($event, 'actions/history', 'core');
  161. } elseif ($event->getSubject() === 'restored_by') {
  162. $subject = $this->l->t('{user} restored {file}');
  163. $this->setIcon($event, 'actions/history', 'core');
  164. } elseif ($event->getSubject() === 'renamed_self') {
  165. $oldFileName = $parsedParameters['oldfile']['name'];
  166. $newFileName = $parsedParameters['newfile']['name'];
  167. if ($this->isHiddenFile($oldFileName)) {
  168. if ($this->isHiddenFile($newFileName)) {
  169. $subject = $this->l->t('You renamed {oldfile} (hidden) to {newfile} (hidden)');
  170. } else {
  171. $subject = $this->l->t('You renamed {oldfile} (hidden) to {newfile}');
  172. }
  173. } else {
  174. if ($this->isHiddenFile($newFileName)) {
  175. $subject = $this->l->t('You renamed {oldfile} to {newfile} (hidden)');
  176. } else {
  177. $subject = $this->l->t('You renamed {oldfile} to {newfile}');
  178. }
  179. }
  180. $this->setIcon($event, 'change');
  181. } elseif ($event->getSubject() === 'renamed_by') {
  182. $oldFileName = $parsedParameters['oldfile']['name'];
  183. $newFileName = $parsedParameters['newfile']['name'];
  184. if ($this->isHiddenFile($oldFileName)) {
  185. if ($this->isHiddenFile($newFileName)) {
  186. $subject = $this->l->t('{user} renamed {oldfile} (hidden) to {newfile} (hidden)');
  187. } else {
  188. $subject = $this->l->t('{user} renamed {oldfile} (hidden) to {newfile}');
  189. }
  190. } else {
  191. if ($this->isHiddenFile($newFileName)) {
  192. $subject = $this->l->t('{user} renamed {oldfile} to {newfile} (hidden)');
  193. } else {
  194. $subject = $this->l->t('{user} renamed {oldfile} to {newfile}');
  195. }
  196. }
  197. $this->setIcon($event, 'change');
  198. } elseif ($event->getSubject() === 'moved_self') {
  199. $subject = $this->l->t('You moved {oldfile} to {newfile}');
  200. $this->setIcon($event, 'change');
  201. } elseif ($event->getSubject() === 'moved_by') {
  202. $subject = $this->l->t('{user} moved {oldfile} to {newfile}');
  203. $this->setIcon($event, 'change');
  204. } else {
  205. throw new UnknownActivityException();
  206. }
  207. if ($this->fileIsEncrypted) {
  208. $event->setSubject($event->getSubject() . '_enc', $event->getSubjectParameters());
  209. }
  210. if (!isset($parsedParameters['user'])) {
  211. // External user via public link share
  212. $subject = str_replace('{user}', $this->activityLang->t('"remote account"'), $subject);
  213. }
  214. $this->setSubjects($event, $subject, $parsedParameters);
  215. if ($event->getSubject() === 'moved_self' || $event->getSubject() === 'moved_by') {
  216. $event = $this->eventMerger->mergeEvents('oldfile', $event, $previousEvent);
  217. } else {
  218. $event = $this->eventMerger->mergeEvents('file', $event, $previousEvent);
  219. }
  220. if ($event->getChildEvent() === null) {
  221. // Couldn't group by file, maybe we can group by user
  222. $event = $this->eventMerger->mergeEvents('user', $event, $previousEvent);
  223. }
  224. return $event;
  225. }
  226. private function isHiddenFile(string $filename): bool {
  227. return strlen($filename) > 0 && $filename[0] === '.';
  228. }
  229. protected function setSubjects(IEvent $event, string $subject, array $parameters): void {
  230. $event->setRichSubject($subject, $parameters);
  231. }
  232. /**
  233. * @param IEvent $event
  234. * @return array
  235. * @throws UnknownActivityException
  236. */
  237. protected function getParameters(IEvent $event): array {
  238. $parameters = $event->getSubjectParameters();
  239. switch ($event->getSubject()) {
  240. case 'created_self':
  241. case 'created_public':
  242. case 'changed_self':
  243. case 'deleted_self':
  244. case 'restored_self':
  245. return [
  246. 'file' => $this->getFile($parameters[0], $event),
  247. ];
  248. case 'created_by':
  249. case 'changed_by':
  250. case 'deleted_by':
  251. case 'restored_by':
  252. if ($parameters[1] === '') {
  253. // External user via public link share
  254. return [
  255. 'file' => $this->getFile($parameters[0], $event),
  256. ];
  257. }
  258. return [
  259. 'file' => $this->getFile($parameters[0], $event),
  260. 'user' => $this->getUser($parameters[1]),
  261. ];
  262. case 'renamed_self':
  263. case 'moved_self':
  264. return [
  265. 'newfile' => $this->getFile($parameters[0]),
  266. 'oldfile' => $this->getFile($parameters[1]),
  267. ];
  268. case 'renamed_by':
  269. case 'moved_by':
  270. if ($parameters[1] === '') {
  271. // External user via public link share
  272. return [
  273. 'newfile' => $this->getFile($parameters[0]),
  274. 'oldfile' => $this->getFile($parameters[2]),
  275. ];
  276. }
  277. return [
  278. 'newfile' => $this->getFile($parameters[0]),
  279. 'user' => $this->getUser($parameters[1]),
  280. 'oldfile' => $this->getFile($parameters[2]),
  281. ];
  282. }
  283. return [];
  284. }
  285. /**
  286. * @param array|string $parameter
  287. * @param IEvent|null $event
  288. * @return array
  289. * @throws UnknownActivityException
  290. */
  291. protected function getFile($parameter, ?IEvent $event = null): array {
  292. if (is_array($parameter)) {
  293. $path = reset($parameter);
  294. $id = (string)key($parameter);
  295. } elseif ($event !== null) {
  296. // Legacy from before ownCloud 8.2
  297. $path = $parameter;
  298. $id = $event->getObjectId();
  299. } else {
  300. throw new UnknownActivityException('Could not generate file parameter');
  301. }
  302. $encryptionContainer = $this->getEndToEndEncryptionContainer($id, $path);
  303. if ($encryptionContainer instanceof Folder) {
  304. $this->fileIsEncrypted = true;
  305. try {
  306. $fullPath = rtrim($encryptionContainer->getPath(), '/');
  307. // Remove /user/files/...
  308. [,,, $path] = explode('/', $fullPath, 4);
  309. if (!$path) {
  310. throw new InvalidPathException('Path could not be split correctly');
  311. }
  312. return [
  313. 'type' => 'file',
  314. 'id' => $encryptionContainer->getId(),
  315. 'name' => $encryptionContainer->getName(),
  316. 'path' => $path,
  317. 'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $encryptionContainer->getId()]),
  318. ];
  319. } catch (\Exception $e) {
  320. // fall back to the normal one
  321. $this->fileIsEncrypted = false;
  322. }
  323. }
  324. return [
  325. 'type' => 'file',
  326. 'id' => $id,
  327. 'name' => basename($path),
  328. 'path' => trim($path, '/'),
  329. 'link' => $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $id]),
  330. ];
  331. }
  332. protected $fileEncrypted = [];
  333. /**
  334. * Check if a file is end2end encrypted
  335. * @param int $fileId
  336. * @param string $path
  337. * @return Folder|null
  338. */
  339. protected function getEndToEndEncryptionContainer($fileId, $path) {
  340. if (isset($this->fileEncrypted[$fileId])) {
  341. return $this->fileEncrypted[$fileId];
  342. }
  343. $fileName = basename($path);
  344. if (!preg_match('/^[0-9a-fA-F]{32}$/', $fileName)) {
  345. $this->fileEncrypted[$fileId] = false;
  346. return $this->fileEncrypted[$fileId];
  347. }
  348. $userFolder = $this->rootFolder->getUserFolder($this->activityManager->getCurrentUserId());
  349. $file = $userFolder->getFirstNodeById($fileId);
  350. if (!$file) {
  351. try {
  352. // Deleted, try with parent
  353. $file = $this->findExistingParent($userFolder, dirname($path));
  354. } catch (NotFoundException $e) {
  355. return null;
  356. }
  357. if (!$file instanceof Folder || !$file->isEncrypted()) {
  358. return null;
  359. }
  360. $this->fileEncrypted[$fileId] = $file;
  361. return $file;
  362. }
  363. if ($file instanceof Folder && $file->isEncrypted()) {
  364. // If the folder is encrypted, it is the Container,
  365. // but can be the name is just fine.
  366. $this->fileEncrypted[$fileId] = true;
  367. return null;
  368. }
  369. $this->fileEncrypted[$fileId] = $this->getParentEndToEndEncryptionContainer($userFolder, $file);
  370. return $this->fileEncrypted[$fileId];
  371. }
  372. /**
  373. * @param Folder $userFolder
  374. * @param string $path
  375. * @return Folder
  376. * @throws NotFoundException
  377. */
  378. protected function findExistingParent(Folder $userFolder, $path) {
  379. if ($path === '/') {
  380. throw new NotFoundException('Reached the root');
  381. }
  382. try {
  383. $folder = $userFolder->get(dirname($path));
  384. } catch (NotFoundException $e) {
  385. return $this->findExistingParent($userFolder, dirname($path));
  386. }
  387. return $folder;
  388. }
  389. /**
  390. * Check all parents until the user's root folder if one is encrypted
  391. *
  392. * @param Folder $userFolder
  393. * @param Node $file
  394. * @return Node|null
  395. */
  396. protected function getParentEndToEndEncryptionContainer(Folder $userFolder, Node $file) {
  397. try {
  398. $parent = $file->getParent();
  399. if ($userFolder->getId() === $parent->getId()) {
  400. return null;
  401. }
  402. } catch (\Exception $e) {
  403. return null;
  404. }
  405. if ($parent->isEncrypted()) {
  406. return $parent;
  407. }
  408. return $this->getParentEndToEndEncryptionContainer($userFolder, $parent);
  409. }
  410. /**
  411. * @param string $uid
  412. * @return array
  413. */
  414. protected function getUser($uid) {
  415. // First try local user
  416. $displayName = $this->userManager->getDisplayName($uid);
  417. if ($displayName !== null) {
  418. return [
  419. 'type' => 'user',
  420. 'id' => $uid,
  421. 'name' => $displayName,
  422. ];
  423. }
  424. // Then a contact from the addressbook
  425. if ($this->cloudIdManager->isValidCloudId($uid)) {
  426. $cloudId = $this->cloudIdManager->resolveCloudId($uid);
  427. return [
  428. 'type' => 'user',
  429. 'id' => $cloudId->getUser(),
  430. 'name' => $this->getDisplayNameFromAddressBook($cloudId->getDisplayId()),
  431. 'server' => $cloudId->getRemote(),
  432. ];
  433. }
  434. // Fallback to empty dummy data
  435. return [
  436. 'type' => 'user',
  437. 'id' => $uid,
  438. 'name' => $uid,
  439. ];
  440. }
  441. protected function getDisplayNameFromAddressBook(string $search): string {
  442. if (isset($this->displayNames[$search])) {
  443. return $this->displayNames[$search];
  444. }
  445. $addressBookContacts = $this->contactsManager->search($search, ['CLOUD'], [
  446. 'limit' => 1,
  447. 'enumeration' => false,
  448. 'fullmatch' => false,
  449. 'strict_search' => true,
  450. ]);
  451. foreach ($addressBookContacts as $contact) {
  452. if (isset($contact['isLocalSystemBook'])) {
  453. continue;
  454. }
  455. if (isset($contact['CLOUD'])) {
  456. $cloudIds = $contact['CLOUD'];
  457. if (is_string($cloudIds)) {
  458. $cloudIds = [$cloudIds];
  459. }
  460. $lowerSearch = strtolower($search);
  461. foreach ($cloudIds as $cloudId) {
  462. if (strtolower($cloudId) === $lowerSearch) {
  463. $this->displayNames[$search] = $contact['FN'] . " ($cloudId)";
  464. return $this->displayNames[$search];
  465. }
  466. }
  467. }
  468. }
  469. return $search;
  470. }
  471. }