diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php index 5b06eb1a22..1b89ee3935 100644 --- a/app/controllers/api/avatars.php +++ b/app/controllers/api/avatars.php @@ -863,6 +863,117 @@ App::get('/v1/avatars/screenshots') } }); +App::get('/v1/avatars/photos') + ->desc('Get user photo from Gravatar') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.read') + ->label('cache', true) + ->label('cache.resourceType', 'avatar/photo') + ->label('cache.resource', 'photo/{request.userId}/{request.width}/{request.height}/{request.quality}/{request.output}') + ->label('sdk', new Method( + namespace: 'avatars', + group: null, + name: 'getPhoto', + description: '/docs/references/avatars/get-photo.md', + auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], + type: MethodType::LOCATION, + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::IMAGE_PNG + )) + ->param('width', 100, new Range(0, 2000), 'Image width. Pass an integer between 0 to 2000. Defaults to 100.', true) + ->param('height', 100, new Range(0, 2000), 'Image height. Pass an integer between 0 to 2000. Defaults to 100.', true) + ->param('quality', -1, new Range(-1, 100), 'Image quality. Pass an integer between 0 to 100. Defaults to keep existing image quality.', true) + ->param('output', '', new WhiteList(\array_keys(Config::getParam('storage-outputs')), true), 'Output format type (jpeg, jpg, png, gif and webp).', true) + ->inject('response') + ->inject('user') + ->action(function (int $width, int $height, int $quality, string $output, Response $response, Document $user) { + + if (!\extension_loaded('imagick')) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); + } + + $email = $user->getAttribute('email', ''); + if (empty($email)) { + throw new Exception(Exception::USER_NOT_FOUND, 'User email not found'); + } + + // Use the larger of width/height for Gravatar size parameter + $gravatarSize = \max($width, $height); + $gravatarSize = \max($gravatarSize, 80); // Minimum size for Gravatar + + // Generate Gravatar URL + $emailHash = \md5(\strtolower(\trim($email))); + $gravatarUrl = 'https://www.gravatar.com/avatar/' . $emailHash . '?s=' . $gravatarSize . '&d=404'; + + $domain = new Domain(\parse_url($gravatarUrl, PHP_URL_HOST)); + + if (!$domain->isKnown()) { + throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); + } + + $client = new Client(); + try { + $res = $client + ->setAllowRedirects(false) + ->fetch($gravatarUrl); + } catch (\Throwable) { + throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); + } + + $imageData = null; + $isGravatarImage = false; + + if ($res->getStatusCode() === 200) { + try { + $imageData = $res->getBody(); + $isGravatarImage = true; + } catch (\Throwable $exception) { + // Fall through to fallback image + } + } + + // If no Gravatar image found, use a fallback image + if (!$isGravatarImage || empty($imageData)) { + $fileLogos = Config::getParam('storage-logos'); + $fallbackPath = $fileLogos['default_image']; + + if (!\is_readable($fallbackPath)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Fallback image not readable'); + } + + $imageData = \file_get_contents($fallbackPath); + } + + try { + $image = new Image($imageData); + } catch (\Throwable $exception) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unable to parse image'); + } + + $image->crop((int) $width, (int) $height); + + // Determine output format - replicate storage preview logic + $outputs = Config::getParam('storage-outputs'); + if (empty($output)) { + $output = 'png'; // Default to PNG + } + + $data = $image->output($output, $quality); + unset($image); + + $contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['png']; + + $response + ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days + ->setContentType($contentType) + ->file($data); + }); + App::get('/v1/cards/cloud') ->desc('Get front Of Cloud Card') ->groups(['api', 'avatars']) diff --git a/docs/references/avatars/get-photo.md b/docs/references/avatars/get-photo.md new file mode 100644 index 0000000000..3f47ffd711 --- /dev/null +++ b/docs/references/avatars/get-photo.md @@ -0,0 +1,5 @@ +Use this endpoint to fetch a user's photo from Gravatar based on their email address. The API automatically generates the Gravatar URL using the user's email hash and provides standard image cropping options. + +When width and height are specified, the image is resized accordingly. If both dimensions are 0, the API provides an image at original size. If dimensions are not specified, the default size is 100x100px. + +This endpoint requires a valid user email address and will return a 404 if no Gravatar image is found for the user. diff --git a/tests/e2e/Services/Avatars/AvatarsBase.php b/tests/e2e/Services/Avatars/AvatarsBase.php index 68bf24250f..d188fd16cc 100644 --- a/tests/e2e/Services/Avatars/AvatarsBase.php +++ b/tests/e2e/Services/Avatars/AvatarsBase.php @@ -1190,4 +1190,87 @@ trait AvatarsBase return []; } + + public function testGetPhoto(): array + { + /** + * Test for SUCCESS - Note: This test may fail if the test user doesn't have a Gravatar image + */ + $response = $this->client->call(Client::METHOD_GET, '/avatars/photos', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'width' => 200, + 'height' => 200, + ]); + + // Gravatar returns 404 if no image is found for the email + if ($response['headers']['status-code'] === 404) { + $this->markTestSkipped('Test user does not have a Gravatar image'); + } + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('image/png', $response['headers']['content-type']); + $this->assertNotEmpty($response['body']); + + // Test with different output format + $response = $this->client->call(Client::METHOD_GET, '/avatars/photos', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'width' => 100, + 'height' => 100, + 'quality' => 80, + 'output' => 'jpeg', + ]); + + // Gravatar returns 404 if no image is found for the email + if ($response['headers']['status-code'] === 404) { + $this->markTestSkipped('Test user does not have a Gravatar image'); + } + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString('image/', $response['headers']['content-type']); + $this->assertNotEmpty($response['body']); + + /** + * Test for FAILURE - Invalid width parameter + */ + $response = $this->client->call(Client::METHOD_GET, '/avatars/photos', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'width' => 3000, // Too high (max 2000) + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + /** + * Test for FAILURE - Invalid height parameter + */ + $response = $this->client->call(Client::METHOD_GET, '/avatars/photos', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'height' => 3000, // Too high (max 2000) + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + /** + * Test for FAILURE - Invalid quality parameter + */ + $response = $this->client->call(Client::METHOD_GET, '/avatars/photos', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'quality' => 150, // Too high (max 100) + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + /** + * Test for FAILURE - Invalid output parameter + */ + $response = $this->client->call(Client::METHOD_GET, '/avatars/photos', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'output' => 'invalid-format', // Invalid format + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + return []; + } } diff --git a/tests/e2e/Services/GraphQL/AvatarsTest.php b/tests/e2e/Services/GraphQL/AvatarsTest.php index 085f0eaecb..5bcabd07e3 100644 --- a/tests/e2e/Services/GraphQL/AvatarsTest.php +++ b/tests/e2e/Services/GraphQL/AvatarsTest.php @@ -353,4 +353,74 @@ class AvatarsTest extends Scope return $screenshot['body']; } + + public function testGetPhoto() + { + $projectId = $this->getProject()['$id']; + + $query = $this->getQuery(self::GET_PHOTO); + $graphQLPayload = [ + 'query' => $query, + 'variables' => [ + 'width' => 200, + 'height' => 200, + ], + ]; + + $photo = $this->client->call(Client::METHOD_POST, '/graphql', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), $graphQLPayload); + + $this->assertEquals(200, $photo['headers']['status-code']); + $this->assertNotEmpty($photo['body']); + + // Check if GraphQL schema includes the photos field + if (!str_contains($photo['headers']['content-type'], 'image/')) { + $this->assertArrayHasKey('errors', $photo['body']); + $this->assertNotEmpty($photo['body']['errors']); + $this->assertStringContainsString('Cannot query field "avatarsGetPhoto"', $photo['body']['errors'][0]['message']); + $this->markTestSkipped('GraphQL schema does not include avatarsGetPhoto field yet'); + } + + $this->assertStringContainsString('image/', $photo['headers']['content-type']); + + return $photo['body']; + } + + public function testGetPhotoWithOutput() + { + $projectId = $this->getProject()['$id']; + + $query = $this->getQuery(self::GET_PHOTO); + $graphQLPayload = [ + 'query' => $query, + 'variables' => [ + 'width' => 100, + 'height' => 100, + 'quality' => 80, + 'output' => 'jpeg', + ], + ]; + + $photo = $this->client->call(Client::METHOD_POST, '/graphql', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), $graphQLPayload); + + $this->assertEquals(200, $photo['headers']['status-code']); + $this->assertNotEmpty($photo['body']); + + // Check if GraphQL schema includes the photos field + if (!str_contains($photo['headers']['content-type'], 'image/')) { + $this->assertArrayHasKey('errors', $photo['body']); + $this->assertNotEmpty($photo['body']['errors']); + $this->assertStringContainsString('Cannot query field "avatarsGetPhoto"', $photo['body']['errors'][0]['message']); + $this->markTestSkipped('GraphQL schema does not include avatarsGetPhoto field yet'); + } + + $this->assertStringContainsString('image/', $photo['headers']['content-type']); + + return $photo['body']; + } } diff --git a/tests/e2e/Services/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php index 4b0d2630e6..e4cd15c466 100644 --- a/tests/e2e/Services/GraphQL/Base.php +++ b/tests/e2e/Services/GraphQL/Base.php @@ -289,6 +289,7 @@ trait Base public const string GET_QRCODE = 'get_qrcode'; public const string GET_USER_INITIALS = 'get_user_initials'; public const string GET_SCREENSHOT = 'get_screenshot'; + public const string GET_PHOTO = 'get_photo'; // Providers public const string CREATE_MAILGUN_PROVIDER = 'create_mailgun_provider'; @@ -1786,6 +1787,12 @@ trait Base status } }'; + case self::GET_PHOTO: + return 'query getPhoto($width: Int, $height: Int, $quality: Int, $output: String) { + avatarsGetPhoto(width: $width, height: $height, quality: $quality, output: $output) { + status + } + }'; case self::GET_ACCOUNT: return 'query getAccount { accountGet {