AvatarController.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  5. * SPDX-License-Identifier: AGPL-3.0-only
  6. */
  7. namespace OC\Core\Controller;
  8. use OC\AppFramework\Utility\TimeFactory;
  9. use OCP\AppFramework\Controller;
  10. use OCP\AppFramework\Http;
  11. use OCP\AppFramework\Http\Attribute\FrontpageRoute;
  12. use OCP\AppFramework\Http\Attribute\NoAdminRequired;
  13. use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
  14. use OCP\AppFramework\Http\Attribute\PublicPage;
  15. use OCP\AppFramework\Http\DataDisplayResponse;
  16. use OCP\AppFramework\Http\FileDisplayResponse;
  17. use OCP\AppFramework\Http\JSONResponse;
  18. use OCP\AppFramework\Http\Response;
  19. use OCP\Files\File;
  20. use OCP\Files\IRootFolder;
  21. use OCP\IAvatarManager;
  22. use OCP\ICache;
  23. use OCP\IL10N;
  24. use OCP\IRequest;
  25. use OCP\IUserManager;
  26. use Psr\Log\LoggerInterface;
  27. /**
  28. * Class AvatarController
  29. *
  30. * @package OC\Core\Controller
  31. */
  32. class AvatarController extends Controller {
  33. public function __construct(
  34. string $appName,
  35. IRequest $request,
  36. protected IAvatarManager $avatarManager,
  37. protected ICache $cache,
  38. protected IL10N $l10n,
  39. protected IUserManager $userManager,
  40. protected IRootFolder $rootFolder,
  41. protected LoggerInterface $logger,
  42. protected ?string $userId,
  43. protected TimeFactory $timeFactory,
  44. protected GuestAvatarController $guestAvatarController,
  45. ) {
  46. parent::__construct($appName, $request);
  47. }
  48. /**
  49. * @NoSameSiteCookieRequired
  50. *
  51. * Get the dark avatar
  52. *
  53. * @param string $userId ID of the user
  54. * @param int $size Size of the avatar
  55. * @param bool $guestFallback Fallback to guest avatar if not found
  56. * @return FileDisplayResponse<Http::STATUS_OK|Http::STATUS_CREATED, array{Content-Type: string, X-NC-IsCustomAvatar: int}>|JSONResponse<Http::STATUS_NOT_FOUND, array<empty>, array{}>|Response<Http::STATUS_INTERNAL_SERVER_ERROR, array{}>
  57. *
  58. * 200: Avatar returned
  59. * 201: Avatar returned
  60. * 404: Avatar not found
  61. */
  62. #[NoCSRFRequired]
  63. #[PublicPage]
  64. #[FrontpageRoute(verb: 'GET', url: '/avatar/{userId}/{size}/dark')]
  65. public function getAvatarDark(string $userId, int $size, bool $guestFallback = false) {
  66. if ($size <= 64) {
  67. if ($size !== 64) {
  68. $this->logger->debug('Avatar requested in deprecated size ' . $size);
  69. }
  70. $size = 64;
  71. } else {
  72. if ($size !== 512) {
  73. $this->logger->debug('Avatar requested in deprecated size ' . $size);
  74. }
  75. $size = 512;
  76. }
  77. try {
  78. $avatar = $this->avatarManager->getAvatar($userId);
  79. $avatarFile = $avatar->getFile($size, true);
  80. $response = new FileDisplayResponse(
  81. $avatarFile,
  82. Http::STATUS_OK,
  83. ['Content-Type' => $avatarFile->getMimeType(), 'X-NC-IsCustomAvatar' => (int)$avatar->isCustomAvatar()]
  84. );
  85. } catch (\Exception $e) {
  86. if ($guestFallback) {
  87. return $this->guestAvatarController->getAvatarDark($userId, (string)$size);
  88. }
  89. return new JSONResponse([], Http::STATUS_NOT_FOUND);
  90. }
  91. // Cache for 1 day
  92. $response->cacheFor(60 * 60 * 24, false, true);
  93. return $response;
  94. }
  95. /**
  96. * @NoSameSiteCookieRequired
  97. *
  98. * Get the avatar
  99. *
  100. * @param string $userId ID of the user
  101. * @param int $size Size of the avatar
  102. * @param bool $guestFallback Fallback to guest avatar if not found
  103. * @return FileDisplayResponse<Http::STATUS_OK|Http::STATUS_CREATED, array{Content-Type: string, X-NC-IsCustomAvatar: int}>|JSONResponse<Http::STATUS_NOT_FOUND, array<empty>, array{}>|Response<Http::STATUS_INTERNAL_SERVER_ERROR, array{}>
  104. *
  105. * 200: Avatar returned
  106. * 201: Avatar returned
  107. * 404: Avatar not found
  108. */
  109. #[NoCSRFRequired]
  110. #[PublicPage]
  111. #[FrontpageRoute(verb: 'GET', url: '/avatar/{userId}/{size}')]
  112. public function getAvatar(string $userId, int $size, bool $guestFallback = false) {
  113. if ($size <= 64) {
  114. if ($size !== 64) {
  115. $this->logger->debug('Avatar requested in deprecated size ' . $size);
  116. }
  117. $size = 64;
  118. } else {
  119. if ($size !== 512) {
  120. $this->logger->debug('Avatar requested in deprecated size ' . $size);
  121. }
  122. $size = 512;
  123. }
  124. try {
  125. $avatar = $this->avatarManager->getAvatar($userId);
  126. $avatarFile = $avatar->getFile($size);
  127. $response = new FileDisplayResponse(
  128. $avatarFile,
  129. Http::STATUS_OK,
  130. ['Content-Type' => $avatarFile->getMimeType(), 'X-NC-IsCustomAvatar' => (int)$avatar->isCustomAvatar()]
  131. );
  132. } catch (\Exception $e) {
  133. if ($guestFallback) {
  134. return $this->guestAvatarController->getAvatar($userId, (string)$size);
  135. }
  136. return new JSONResponse([], Http::STATUS_NOT_FOUND);
  137. }
  138. // Cache for 1 day
  139. $response->cacheFor(60 * 60 * 24, false, true);
  140. return $response;
  141. }
  142. #[NoAdminRequired]
  143. #[FrontpageRoute(verb: 'POST', url: '/avatar/')]
  144. public function postAvatar(?string $path = null): JSONResponse {
  145. $files = $this->request->getUploadedFile('files');
  146. if (isset($path)) {
  147. $path = stripslashes($path);
  148. $userFolder = $this->rootFolder->getUserFolder($this->userId);
  149. /** @var File $node */
  150. $node = $userFolder->get($path);
  151. if (!($node instanceof File)) {
  152. return new JSONResponse(['data' => ['message' => $this->l10n->t('Please select a file.')]]);
  153. }
  154. if ($node->getSize() > 20 * 1024 * 1024) {
  155. return new JSONResponse(
  156. ['data' => ['message' => $this->l10n->t('File is too big')]],
  157. Http::STATUS_BAD_REQUEST
  158. );
  159. }
  160. if ($node->getMimeType() !== 'image/jpeg' && $node->getMimeType() !== 'image/png') {
  161. return new JSONResponse(
  162. ['data' => ['message' => $this->l10n->t('The selected file is not an image.')]],
  163. Http::STATUS_BAD_REQUEST
  164. );
  165. }
  166. try {
  167. $content = $node->getContent();
  168. } catch (\OCP\Files\NotPermittedException $e) {
  169. return new JSONResponse(
  170. ['data' => ['message' => $this->l10n->t('The selected file cannot be read.')]],
  171. Http::STATUS_BAD_REQUEST
  172. );
  173. }
  174. } elseif (!is_null($files)) {
  175. if (
  176. $files['error'][0] === 0 &&
  177. is_uploaded_file($files['tmp_name'][0])
  178. ) {
  179. if ($files['size'][0] > 20 * 1024 * 1024) {
  180. return new JSONResponse(
  181. ['data' => ['message' => $this->l10n->t('File is too big')]],
  182. Http::STATUS_BAD_REQUEST
  183. );
  184. }
  185. $this->cache->set('avatar_upload', file_get_contents($files['tmp_name'][0]), 7200);
  186. $content = $this->cache->get('avatar_upload');
  187. unlink($files['tmp_name'][0]);
  188. } else {
  189. $phpFileUploadErrors = [
  190. UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'),
  191. UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'),
  192. UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'),
  193. UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'),
  194. UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'),
  195. UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'),
  196. UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'),
  197. UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'),
  198. ];
  199. $message = $phpFileUploadErrors[$files['error'][0]] ?? $this->l10n->t('Invalid file provided');
  200. $this->logger->warning($message, ['app' => 'core']);
  201. return new JSONResponse(
  202. ['data' => ['message' => $message]],
  203. Http::STATUS_BAD_REQUEST
  204. );
  205. }
  206. } else {
  207. //Add imgfile
  208. return new JSONResponse(
  209. ['data' => ['message' => $this->l10n->t('No image or file provided')]],
  210. Http::STATUS_BAD_REQUEST
  211. );
  212. }
  213. try {
  214. $image = new \OCP\Image();
  215. $image->loadFromData($content);
  216. $image->readExif($content);
  217. $image->fixOrientation();
  218. if ($image->valid()) {
  219. $mimeType = $image->mimeType();
  220. if ($mimeType !== 'image/jpeg' && $mimeType !== 'image/png') {
  221. return new JSONResponse(
  222. ['data' => ['message' => $this->l10n->t('Unknown filetype')]],
  223. Http::STATUS_OK
  224. );
  225. }
  226. if ($image->width() === $image->height()) {
  227. try {
  228. $avatar = $this->avatarManager->getAvatar($this->userId);
  229. $avatar->set($image);
  230. // Clean up
  231. $this->cache->remove('tmpAvatar');
  232. return new JSONResponse(['status' => 'success']);
  233. } catch (\Throwable $e) {
  234. $this->logger->error($e->getMessage(), ['exception' => $e, 'app' => 'core']);
  235. return new JSONResponse(['data' => ['message' => $this->l10n->t('An error occurred. Please contact your admin.')]], Http::STATUS_BAD_REQUEST);
  236. }
  237. }
  238. $this->cache->set('tmpAvatar', $image->data(), 7200);
  239. return new JSONResponse(
  240. ['data' => 'notsquare'],
  241. Http::STATUS_OK
  242. );
  243. } else {
  244. return new JSONResponse(
  245. ['data' => ['message' => $this->l10n->t('Invalid image')]],
  246. Http::STATUS_OK
  247. );
  248. }
  249. } catch (\Exception $e) {
  250. $this->logger->error($e->getMessage(), ['exception' => $e, 'app' => 'core']);
  251. return new JSONResponse(['data' => ['message' => $this->l10n->t('An error occurred. Please contact your admin.')]], Http::STATUS_OK);
  252. }
  253. }
  254. #[NoAdminRequired]
  255. #[FrontpageRoute(verb: 'DELETE', url: '/avatar/')]
  256. public function deleteAvatar(): JSONResponse {
  257. try {
  258. $avatar = $this->avatarManager->getAvatar($this->userId);
  259. $avatar->remove();
  260. return new JSONResponse();
  261. } catch (\Exception $e) {
  262. $this->logger->error($e->getMessage(), ['exception' => $e, 'app' => 'core']);
  263. return new JSONResponse(['data' => ['message' => $this->l10n->t('An error occurred. Please contact your admin.')]], Http::STATUS_BAD_REQUEST);
  264. }
  265. }
  266. /**
  267. * @return JSONResponse|DataDisplayResponse
  268. */
  269. #[NoAdminRequired]
  270. #[FrontpageRoute(verb: 'GET', url: '/avatar/tmp')]
  271. public function getTmpAvatar() {
  272. $tmpAvatar = $this->cache->get('tmpAvatar');
  273. if (is_null($tmpAvatar)) {
  274. return new JSONResponse(['data' => [
  275. 'message' => $this->l10n->t("No temporary profile picture available, try again")
  276. ]],
  277. Http::STATUS_NOT_FOUND);
  278. }
  279. $image = new \OCP\Image();
  280. $image->loadFromData($tmpAvatar);
  281. $resp = new DataDisplayResponse(
  282. $image->data() ?? '',
  283. Http::STATUS_OK,
  284. ['Content-Type' => $image->mimeType()]);
  285. $resp->setETag((string)crc32($image->data() ?? ''));
  286. $resp->cacheFor(0);
  287. $resp->setLastModified(new \DateTime('now', new \DateTimeZone('GMT')));
  288. return $resp;
  289. }
  290. #[NoAdminRequired]
  291. #[FrontpageRoute(verb: 'POST', url: '/avatar/cropped')]
  292. public function postCroppedAvatar(?array $crop = null): JSONResponse {
  293. if (is_null($crop)) {
  294. return new JSONResponse(['data' => ['message' => $this->l10n->t("No crop data provided")]],
  295. Http::STATUS_BAD_REQUEST);
  296. }
  297. if (!isset($crop['x'], $crop['y'], $crop['w'], $crop['h'])) {
  298. return new JSONResponse(['data' => ['message' => $this->l10n->t("No valid crop data provided")]],
  299. Http::STATUS_BAD_REQUEST);
  300. }
  301. $tmpAvatar = $this->cache->get('tmpAvatar');
  302. if (is_null($tmpAvatar)) {
  303. return new JSONResponse(['data' => [
  304. 'message' => $this->l10n->t("No temporary profile picture available, try again")
  305. ]],
  306. Http::STATUS_BAD_REQUEST);
  307. }
  308. $image = new \OCP\Image();
  309. $image->loadFromData($tmpAvatar);
  310. $image->crop($crop['x'], $crop['y'], (int)round($crop['w']), (int)round($crop['h']));
  311. try {
  312. $avatar = $this->avatarManager->getAvatar($this->userId);
  313. $avatar->set($image);
  314. // Clean up
  315. $this->cache->remove('tmpAvatar');
  316. return new JSONResponse(['status' => 'success']);
  317. } catch (\OC\NotSquareException $e) {
  318. return new JSONResponse(['data' => ['message' => $this->l10n->t('Crop is not square')]],
  319. Http::STATUS_BAD_REQUEST);
  320. } catch (\Exception $e) {
  321. $this->logger->error($e->getMessage(), ['exception' => $e, 'app' => 'core']);
  322. return new JSONResponse(['data' => ['message' => $this->l10n->t('An error occurred. Please contact your admin.')]], Http::STATUS_BAD_REQUEST);
  323. }
  324. }
  325. }