PhotoCache.php 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  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\DAV\CardDAV;
  7. use OCP\Files\IAppData;
  8. use OCP\Files\NotFoundException;
  9. use OCP\Files\NotPermittedException;
  10. use OCP\Files\SimpleFS\ISimpleFile;
  11. use OCP\Files\SimpleFS\ISimpleFolder;
  12. use OCP\Image;
  13. use Psr\Log\LoggerInterface;
  14. use Sabre\CardDAV\Card;
  15. use Sabre\VObject\Document;
  16. use Sabre\VObject\Parameter;
  17. use Sabre\VObject\Property\Binary;
  18. use Sabre\VObject\Reader;
  19. class PhotoCache {
  20. /** @var array */
  21. public const ALLOWED_CONTENT_TYPES = [
  22. 'image/png' => 'png',
  23. 'image/jpeg' => 'jpg',
  24. 'image/gif' => 'gif',
  25. 'image/vnd.microsoft.icon' => 'ico',
  26. 'image/webp' => 'webp',
  27. ];
  28. /**
  29. * PhotoCache constructor.
  30. */
  31. public function __construct(
  32. protected IAppData $appData,
  33. protected LoggerInterface $logger,
  34. ) {
  35. }
  36. /**
  37. * @throws NotFoundException
  38. */
  39. public function get(int $addressBookId, string $cardUri, int $size, Card $card): ISimpleFile {
  40. $folder = $this->getFolder($addressBookId, $cardUri);
  41. if ($this->isEmpty($folder)) {
  42. $this->init($folder, $card);
  43. }
  44. if (!$this->hasPhoto($folder)) {
  45. throw new NotFoundException();
  46. }
  47. if ($size !== -1) {
  48. $size = 2 ** ceil(log($size) / log(2));
  49. }
  50. return $this->getFile($folder, $size);
  51. }
  52. private function isEmpty(ISimpleFolder $folder): bool {
  53. return $folder->getDirectoryListing() === [];
  54. }
  55. /**
  56. * @throws NotPermittedException
  57. */
  58. private function init(ISimpleFolder $folder, Card $card): void {
  59. $data = $this->getPhoto($card);
  60. if ($data === false || !isset($data['Content-Type'])) {
  61. $folder->newFile('nophoto', '');
  62. return;
  63. }
  64. $contentType = $data['Content-Type'];
  65. $extension = self::ALLOWED_CONTENT_TYPES[$contentType] ?? null;
  66. if ($extension === null) {
  67. $folder->newFile('nophoto', '');
  68. return;
  69. }
  70. $file = $folder->newFile('photo.' . $extension);
  71. $file->putContent($data['body']);
  72. }
  73. private function hasPhoto(ISimpleFolder $folder): bool {
  74. return !$folder->fileExists('nophoto');
  75. }
  76. /**
  77. * @param float|-1 $size
  78. */
  79. private function getFile(ISimpleFolder $folder, $size): ISimpleFile {
  80. $ext = $this->getExtension($folder);
  81. if ($size === -1) {
  82. $path = 'photo.' . $ext;
  83. } else {
  84. $path = 'photo.' . $size . '.' . $ext;
  85. }
  86. try {
  87. $file = $folder->getFile($path);
  88. } catch (NotFoundException $e) {
  89. if ($size <= 0) {
  90. throw new NotFoundException;
  91. }
  92. $photo = new Image();
  93. /** @var ISimpleFile $file */
  94. $file = $folder->getFile('photo.' . $ext);
  95. $photo->loadFromData($file->getContent());
  96. $ratio = $photo->width() / $photo->height();
  97. if ($ratio < 1) {
  98. $ratio = 1 / $ratio;
  99. }
  100. $size = (int)($size * $ratio);
  101. if ($size !== -1) {
  102. $photo->resize($size);
  103. }
  104. try {
  105. $file = $folder->newFile($path);
  106. $file->putContent($photo->data());
  107. } catch (NotPermittedException $e) {
  108. }
  109. }
  110. return $file;
  111. }
  112. /**
  113. * @throws NotFoundException
  114. * @throws NotPermittedException
  115. */
  116. private function getFolder(int $addressBookId, string $cardUri, bool $createIfNotExists = true): ISimpleFolder {
  117. $hash = md5($addressBookId . ' ' . $cardUri);
  118. try {
  119. return $this->appData->getFolder($hash);
  120. } catch (NotFoundException $e) {
  121. if ($createIfNotExists) {
  122. return $this->appData->newFolder($hash);
  123. } else {
  124. throw $e;
  125. }
  126. }
  127. }
  128. /**
  129. * Get the extension of the avatar. If there is no avatar throw Exception
  130. *
  131. * @throws NotFoundException
  132. */
  133. private function getExtension(ISimpleFolder $folder): string {
  134. foreach (self::ALLOWED_CONTENT_TYPES as $extension) {
  135. if ($folder->fileExists('photo.' . $extension)) {
  136. return $extension;
  137. }
  138. }
  139. throw new NotFoundException('Avatar not found');
  140. }
  141. /**
  142. * @param Card $node
  143. * @return false|array{body: string, Content-Type: string}
  144. */
  145. private function getPhoto(Card $node) {
  146. try {
  147. $vObject = $this->readCard($node->get());
  148. return $this->getPhotoFromVObject($vObject);
  149. } catch (\Exception $e) {
  150. $this->logger->error('Exception during vcard photo parsing', [
  151. 'exception' => $e
  152. ]);
  153. }
  154. return false;
  155. }
  156. /**
  157. * @return false|array{body: string, Content-Type: string}
  158. */
  159. public function getPhotoFromVObject(Document $vObject) {
  160. try {
  161. if (!$vObject->PHOTO) {
  162. return false;
  163. }
  164. $photo = $vObject->PHOTO;
  165. $val = $photo->getValue();
  166. // handle data URI. e.g PHOTO;VALUE=URI:data:image/jpeg;base64,/9j/4AAQSkZJRgABAQE
  167. if ($photo->getValueType() === 'URI') {
  168. $parsed = \Sabre\URI\parse($val);
  169. // only allow data://
  170. if ($parsed['scheme'] !== 'data') {
  171. return false;
  172. }
  173. if (substr_count($parsed['path'], ';') === 1) {
  174. [$type] = explode(';', $parsed['path']);
  175. }
  176. $val = file_get_contents($val);
  177. } else {
  178. // get type if binary data
  179. $type = $this->getBinaryType($photo);
  180. }
  181. if (empty($type) || !isset(self::ALLOWED_CONTENT_TYPES[$type])) {
  182. $type = 'application/octet-stream';
  183. }
  184. return [
  185. 'Content-Type' => $type,
  186. 'body' => $val
  187. ];
  188. } catch (\Exception $e) {
  189. $this->logger->error('Exception during vcard photo parsing', [
  190. 'exception' => $e
  191. ]);
  192. }
  193. return false;
  194. }
  195. private function readCard(string $cardData): Document {
  196. return Reader::read($cardData);
  197. }
  198. /**
  199. * @param Binary $photo
  200. * @return string
  201. */
  202. private function getBinaryType(Binary $photo) {
  203. $params = $photo->parameters();
  204. if (isset($params['TYPE']) || isset($params['MEDIATYPE'])) {
  205. /** @var Parameter $typeParam */
  206. $typeParam = isset($params['TYPE']) ? $params['TYPE'] : $params['MEDIATYPE'];
  207. $type = (string)$typeParam->getValue();
  208. if (str_starts_with($type, 'image/')) {
  209. return $type;
  210. } else {
  211. return 'image/' . strtolower($type);
  212. }
  213. }
  214. return '';
  215. }
  216. /**
  217. * @param int $addressBookId
  218. * @param string $cardUri
  219. * @throws NotPermittedException
  220. */
  221. public function delete($addressBookId, $cardUri) {
  222. try {
  223. $folder = $this->getFolder($addressBookId, $cardUri, false);
  224. $folder->delete();
  225. } catch (NotFoundException $e) {
  226. // that's OK, nothing to do
  227. }
  228. }
  229. }