diff --git a/app/config/services.php b/app/config/services.php index e4bbf9b6f6..531306391e 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -48,7 +48,7 @@ return [ 'name' => 'Avatars', 'subtitle' => 'The Avatars service aims to help you complete everyday tasks related to your app image, icons, and avatars.', 'description' => '/docs/services/avatars.md', - 'controller' => 'api/avatars.php', + 'controller' => '', // Uses modules 'sdk' => true, 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/client/avatars', @@ -146,7 +146,7 @@ return [ 'name' => 'Storage', 'subtitle' => 'The Storage service allows you to manage your project files.', 'description' => '/docs/services/storage.md', - 'controller' => '', + 'controller' => '', // Uses modules 'sdk' => true, 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/client/storage', diff --git a/app/controllers/api/avatars.php b/app/controllers/api/avatars.php deleted file mode 100644 index b4f75a9ee5..0000000000 --- a/app/controllers/api/avatars.php +++ /dev/null @@ -1,1495 +0,0 @@ -crop((int) $width, (int) $height); - $output = (empty($output)) ? $type : $output; - $data = $image->output($output, $quality); - $response - ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days - ->setContentType('image/png') - ->file($data); - unset($image); -}; - -$getUserGitHub = function (string $userId, Document $project, Database $dbForProject, Database $dbForPlatform, ?Logger $logger) { - try { - $user = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $userId)); - - $sessions = $user->getAttribute('sessions', []); - - $gitHubSession = null; - foreach ($sessions as $session) { - if ($session->getAttribute('provider', '') === 'github') { - $gitHubSession = $session; - break; - } - } - - if (empty($gitHubSession)) { - throw new Exception(Exception::USER_SESSION_NOT_FOUND, 'GitHub session not found.'); - } - - $provider = $gitHubSession->getAttribute('provider', ''); - $accessToken = $gitHubSession->getAttribute('providerAccessToken'); - $accessTokenExpiry = $gitHubSession->getAttribute('providerAccessTokenExpiry'); - $refreshToken = $gitHubSession->getAttribute('providerRefreshToken'); - - $appId = $project->getAttribute('oAuthProviders', [])[$provider . 'Appid'] ?? ''; - $appSecret = $project->getAttribute('oAuthProviders', [])[$provider . 'Secret'] ?? '{}'; - - $oAuthProviders = Config::getParam('oAuthProviders'); - $className = $oAuthProviders[$provider]['class']; - if (!\class_exists($className)) { - throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); - } - - $oauth2 = new $className($appId, $appSecret, '', [], []); - - $isExpired = new \DateTime($accessTokenExpiry) < new \DateTime('now'); - if ($isExpired) { - try { - $oauth2->refreshTokens($refreshToken); - - $accessToken = $oauth2->getAccessToken(''); - $refreshToken = $oauth2->getRefreshToken(''); - - $verificationId = $oauth2->getUserID($accessToken); - - if (empty($verificationId)) { - throw new \Exception("Locked tokens."); // Race codition, handeled in catch - } - - $gitHubSession - ->setAttribute('providerAccessToken', $accessToken) - ->setAttribute('providerRefreshToken', $refreshToken) - ->setAttribute('providerAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int)$oauth2->getAccessTokenExpiry(''))); - - Authorization::skip(fn () => $dbForProject->updateDocument('sessions', $gitHubSession->getId(), $gitHubSession)); - - $dbForProject->purgeCachedDocument('users', $user->getId()); - } catch (Throwable $err) { - $index = 0; - do { - $previousAccessToken = $gitHubSession->getAttribute('providerAccessToken'); - - $user = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $userId)); - $sessions = $user->getAttribute('sessions', []); - - $gitHubSession = new Document(); - foreach ($sessions as $session) { - if ($session->getAttribute('provider', '') === 'github') { - $gitHubSession = $session; - break; - } - } - - $accessToken = $gitHubSession->getAttribute('providerAccessToken'); - - if ($accessToken !== $previousAccessToken) { - break; - } - - $index++; - \usleep(500000); - } while ($index < 10); - } - } - - $oauth2 = new $className($appId, $appSecret, '', [], []); - $githubUser = $oauth2->getUserSlug($accessToken); - $githubId = $oauth2->getUserID($accessToken); - - return [ - 'name' => $githubUser, - 'id' => $githubId - ]; - } catch (Exception $error) { - return []; - } -}; - -App::get('/v1/avatars/credit-cards/:code') - ->desc('Get credit card icon') - ->groups(['api', 'avatars']) - ->label('scope', 'avatars.read') - ->label('cache', true) - ->label('cache.resource', 'avatar/credit-card') - ->label('sdk', new Method( - namespace: 'avatars', - group: null, - name: 'getCreditCard', - description: '/docs/references/avatars/get-credit-card.md', - auth: [AuthType::ADMIN, 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('code', '', new WhiteList(\array_keys(Config::getParam('avatar-credit-cards'))), 'Credit Card Code. Possible values: ' . \implode(', ', \array_keys(Config::getParam('avatar-credit-cards'))) . '.') - ->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) - ->inject('response') - ->action(fn (string $code, int $width, int $height, int $quality, Response $response) => $avatarCallback('credit-cards', $code, $width, $height, $quality, $response)); - -App::get('/v1/avatars/browsers/:code') - ->desc('Get browser icon') - ->groups(['api', 'avatars']) - ->label('scope', 'avatars.read') - ->label('cache', true) - ->label('cache.resource', 'avatar/browser') - ->label('sdk', new Method( - namespace: 'avatars', - group: null, - name: 'getBrowser', - description: '/docs/references/avatars/get-browser.md', - auth: [AuthType::ADMIN, 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('code', '', new WhiteList(\array_keys(Config::getParam('avatar-browsers'))), 'Browser Code.') - ->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) - ->inject('response') - ->action(fn (string $code, int $width, int $height, int $quality, Response $response) => $avatarCallback('browsers', $code, $width, $height, $quality, $response)); - -App::get('/v1/avatars/flags/:code') - ->desc('Get country flag') - ->groups(['api', 'avatars']) - ->label('scope', 'avatars.read') - ->label('cache', true) - ->label('cache.resource', 'avatar/flag') - ->label('sdk', new Method( - namespace: 'avatars', - group: null, - name: 'getFlag', - description: '/docs/references/avatars/get-flag.md', - auth: [AuthType::ADMIN, 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('code', '', new WhiteList(\array_keys(Config::getParam('avatar-flags'))), 'Country Code. ISO Alpha-2 country code format.') - ->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) - ->inject('response') - ->action(fn (string $code, int $width, int $height, int $quality, Response $response) => $avatarCallback('flags', $code, $width, $height, $quality, $response)); - -App::get('/v1/avatars/image') - ->desc('Get image from URL') - ->groups(['api', 'avatars']) - ->label('scope', 'avatars.read') - ->label('cache', true) - ->label('cache.resource', 'avatar/image') - ->label('sdk', new Method( - namespace: 'avatars', - group: null, - name: 'getImage', - description: '/docs/references/avatars/get-image.md', - auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], - type: MethodType::LOCATION, - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::IMAGE - )) - ->param('url', '', new URL(['http', 'https']), 'Image URL which you want to crop.') - ->param('width', 400, new Range(0, 2000), 'Resize preview image width, Pass an integer between 0 to 2000. Defaults to 400.', true) - ->param('height', 400, new Range(0, 2000), 'Resize preview image height, Pass an integer between 0 to 2000. Defaults to 400.', true) - ->inject('response') - ->action(function (string $url, int $width, int $height, Response $response) { - - $quality = 80; - $output = 'png'; - $type = 'png'; - - if (!\extension_loaded('imagick')) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); - } - - $domain = new Domain(\parse_url($url, PHP_URL_HOST)); - - if (!$domain->isKnown()) { - throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); - } - - $client = new Client(); - try { - $res = $client - ->setAllowRedirects(false) - ->fetch($url); - } catch (\Throwable) { - throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); - } - - if ($res->getStatusCode() !== 200) { - throw new Exception(Exception::AVATAR_IMAGE_NOT_FOUND); - } - - try { - $image = new Image($res->getBody()); - } catch (\Throwable $exception) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unable to parse image'); - } - - $image->crop((int) $width, (int) $height); - $output = (empty($output)) ? $type : $output; - $data = $image->output($output, $quality); - - $response - ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days - ->setContentType('image/png') - ->file($data); - unset($image); - }); - -App::get('/v1/avatars/favicon') - ->desc('Get favicon') - ->groups(['api', 'avatars']) - ->label('scope', 'avatars.read') - ->label('cache', true) - ->label('cache.resource', 'avatar/favicon') - ->label('sdk', new Method( - namespace: 'avatars', - group: null, - name: 'getFavicon', - description: '/docs/references/avatars/get-favicon.md', - auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], - type: MethodType::LOCATION, - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::IMAGE - )) - ->param('url', '', new URL(['http', 'https']), 'Website URL which you want to fetch the favicon from.') - ->inject('response') - ->action(function (string $url, Response $response) { - - $width = 56; - $height = 56; - $quality = 80; - $output = 'png'; - $type = 'png'; - - if (!\extension_loaded('imagick')) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); - } - - $domain = new Domain(\parse_url($url, PHP_URL_HOST)); - - if (!$domain->isKnown()) { - throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); - } - - $client = new Client(); - try { - $res = $client - ->setAllowRedirects(true) - ->setMaxRedirects(5) - ->setUserAgent(\sprintf( - APP_USERAGENT, - System::getEnv('_APP_VERSION', 'UNKNOWN'), - System::getEnv('_APP_EMAIL_SECURITY', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS', APP_EMAIL_SECURITY)) - )) - ->fetch($url); - } catch (\Throwable) { - throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); - } - - $doc = new DOMDocument(); - $doc->strictErrorChecking = false; - @$doc->loadHTML($res->getBody()); - - $links = $doc->getElementsByTagName('link') ?? []; - $outputHref = ''; - $outputExt = ''; - $space = 0; - - foreach ($links as $link) { /* @var $link DOMElement */ - $href = $link->getAttribute('href'); - $rel = $link->getAttribute('rel'); - $sizes = $link->getAttribute('sizes'); - $absolute = URLParse::unparse(\array_merge(\parse_url($url), \parse_url($href))); - - switch (\strtolower($rel)) { - case 'icon': - case 'shortcut icon': - //case 'apple-touch-icon': - $ext = \pathinfo(\parse_url($absolute, PHP_URL_PATH), PATHINFO_EXTENSION); - - switch ($ext) { - case 'svg': - // SVG icons are prioritized by assigning the maximum possible value. - $space = PHP_INT_MAX; - $outputHref = $absolute; - $outputExt = $ext; - break; - case 'ico': - case 'png': - case 'jpg': - case 'jpeg': - $size = \explode('x', \strtolower($sizes)); - - $sizeWidth = (int) ($size[0] ?? 0); - $sizeHeight = (int) ($size[1] ?? 0); - - if (($sizeWidth * $sizeHeight) >= $space) { - $space = $sizeWidth * $sizeHeight; - $outputHref = $absolute; - $outputExt = $ext; - } - - break; - } - - break; - } - } - - if (empty($outputHref) || empty($outputExt)) { - $default = \parse_url($url); - - $outputHref = $default['scheme'] . '://' . $default['host'] . '/favicon.ico'; - $outputExt = 'ico'; - } - - $domain = new Domain(\parse_url($outputHref, PHP_URL_HOST)); - - if (!$domain->isKnown()) { - throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); - } - - $client = new Client(); - try { - $res = $client - ->setAllowRedirects(true) - ->setMaxRedirects(5) - ->fetch($outputHref); - } catch (\Throwable) { - throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); - } - - if ($res->getStatusCode() !== 200) { - throw new Exception(Exception::AVATAR_ICON_NOT_FOUND); - } - - $data = $res->getBody(); - - if ('ico' === $outputExt) { // Skip crop, Imagick isn\'t supporting icon files - if ( - empty($data) || - stripos($data, 'addHeader('Cache-Control', 'private, max-age=2592000') // 30 days - ->setContentType('image/x-icon') - ->file($data); - return; - } - - if ('svg' === $outputExt) { // Skip crop, Imagick isn\'t supporting svg files - $sanitizer = new SvgSanitizer(); - $sanitizer->minify(true); - $cleanSvg = $sanitizer->sanitize($data); - if ($cleanSvg === false) { - throw new Exception(Exception::AVATAR_SVG_SANITIZATION_FAILED); - } - $response - ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days - ->setContentType('image/svg+xml') - ->file($cleanSvg); - return; - } - - $image = new Image($data); - $image->crop((int) $width, (int) $height); - $output = (empty($output)) ? $type : $output; - $data = $image->output($output, $quality); - - $response - ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days - ->setContentType('image/png') - ->file($data); - unset($image); - }); - -App::get('/v1/avatars/qr') - ->desc('Get QR code') - ->groups(['api', 'avatars']) - ->label('scope', 'avatars.read') - ->label('sdk', new Method( - namespace: 'avatars', - group: null, - name: 'getQR', - description: '/docs/references/avatars/get-qr.md', - auth: [AuthType::ADMIN, 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('text', '', new Text(512), 'Plain text to be converted to QR code image.') - ->param('size', 400, new Range(1, 1000), 'QR code size. Pass an integer between 1 to 1000. Defaults to 400.', true) - ->param('margin', 1, new Range(0, 10), 'Margin from edge. Pass an integer between 0 to 10. Defaults to 1.', true) - ->param('download', false, new Boolean(true), 'Return resulting image with \'Content-Disposition: attachment \' headers for the browser to start downloading it. Pass 0 for no header, or 1 for otherwise. Default value is set to 0.', true) - ->inject('response') - ->action(function (string $text, int $size, int $margin, bool $download, Response $response) { - - $download = ($download === '1' || $download === 'true' || $download === 1 || $download === true); - $options = new QROptions([ - 'addQuietzone' => true, - 'quietzoneSize' => $margin, - 'outputType' => QRCode::OUTPUT_IMAGICK, - 'scale' => 15, - ]); - - $qrcode = new QRCode($options); - - if ($download) { - $response->addHeader('Content-Disposition', 'attachment; filename="qr.png"'); - } - - $image = new Image($qrcode->render($text)); - $image->crop((int) $size, (int) $size); - - $response - ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days - ->setContentType('image/png') - ->send($image->output('png', 90)); - }); - -App::get('/v1/avatars/initials') - ->desc('Get user initials') - ->groups(['api', 'avatars']) - ->label('scope', 'avatars.read') - ->label('cache.resource', 'avatar/initials') - ->label('sdk', new Method( - namespace: 'avatars', - group: null, - name: 'getInitials', - description: '/docs/references/avatars/get-initials.md', - auth: [AuthType::ADMIN, 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('name', '', new Text(128), 'Full Name. When empty, current user name or email will be used. Max length: 128 chars.', true) - ->param('width', 500, new Range(0, 2000), 'Image width. Pass an integer between 0 to 2000. Defaults to 100.', true) - ->param('height', 500, new Range(0, 2000), 'Image height. Pass an integer between 0 to 2000. Defaults to 100.', true) - ->param('background', '', new HexColor(), 'Changes background color. By default a random color will be picked and stay will persistent to the given name.', true) - ->inject('response') - ->inject('user') - ->action(function (string $name, int $width, int $height, string $background, Response $response, Document $user) { - - $themes = [ - ['background' => '#FD366E'], // Default (Pink) - ['background' => '#FE9567'], // Orange - ['background' => '#7C67FE'], // Purple - ['background' => '#68A3FE'], // Blue - ['background' => '#85DBD8'], // Mint - ]; - - $name = (!empty($name)) ? $name : $user->getAttribute('name', $user->getAttribute('email', '')); - $words = \explode(' ', \strtoupper($name)); - // if there is no space, try to split by `_` underscore - $words = (count($words) == 1) ? \explode('_', \strtoupper($name)) : $words; - - $initials = ''; - $code = 0; - - foreach ($words as $key => $w) { - if (ctype_alnum($w[0] ?? '')) { - $initials .= $w[0]; - $code += ord($w[0]); - - if ($key == 1) { - break; - } - } - } - - $rand = \substr($code, -1); - - // Wrap rand value to avoid out of range - $rand = ($rand > \count($themes) - 1) ? $rand % \count($themes) : $rand; - - $background = (!empty($background)) ? '#' . $background : $themes[$rand]['background']; - - $image = new \Imagick(); - $punch = new \Imagick(); - $draw = new \ImagickDraw(); - $fontSize = \min($width, $height) / 2; - - $punch->newImage($width, $height, 'transparent'); - - $draw->setFont(__DIR__ . "/../../assets/fonts/inter-v8-latin-regular.woff2"); - $image->setFont(__DIR__ . "/../../assets/fonts/inter-v8-latin-regular.woff2"); - - $draw->setFillColor(new ImagickPixel('black')); - $draw->setFontSize($fontSize); - - $draw->setTextAlignment(\Imagick::ALIGN_CENTER); - $draw->annotation($width / 1.97, ($height / 2) + ($fontSize / 3), $initials); - - $punch->drawImage($draw); - $punch->negateImage(true, Imagick::CHANNEL_ALPHA); - - $image->newImage($width, $height, $background); - $image->setImageFormat("png"); - $image->compositeImage($punch, Imagick::COMPOSITE_COPYOPACITY, 0, 0); - - $response - ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days - ->setContentType('image/png') - ->file($image->getImageBlob()); - }); - -App::get('/v1/avatars/screenshots') - ->desc('Get webpage screenshot') - ->groups(['api', 'avatars']) - ->label('scope', 'avatars.read') - ->label('usage.metric', METRIC_AVATARS_SCREENSHOTS_GENERATED) - ->label('abuse-limit', 60) - ->label('cache', true) - ->label('cache.resourceType', 'avatar/screenshot') - ->label('cache.resource', 'screenshot/{request.url}/{request.width}/{request.height}/{request.scale}/{request.theme}/{request.userAgent}/{request.fullpage}/{request.locale}/{request.timezone}/{request.latitude}/{request.longitude}/{request.accuracy}/{request.touch}/{request.permissions}/{request.sleep}/{request.quality}/{request.output}') - ->label('sdk', new Method( - namespace: 'avatars', - group: null, - name: 'getScreenshot', - description: '/docs/references/avatars/get-screenshot.md', - auth: [AuthType::ADMIN, 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('url', '', new URL(['http', 'https']), 'Website URL which you want to capture.', example: 'https://example.com') - ->param('headers', [], new Assoc(), 'HTTP headers to send with the browser request. Defaults to empty.', true, example: '{"Authorization":"Bearer token123","X-Custom-Header":"value"}') - ->param('viewportWidth', 1280, new Range(1, 1920), 'Browser viewport width. Pass an integer between 1 to 1920. Defaults to 1280.', true, example: '1920') - ->param('viewportHeight', 720, new Range(1, 1080), 'Browser viewport height. Pass an integer between 1 to 1080. Defaults to 720.', true, example: '1080') - ->param('scale', 1, new Range(0.1, 3, Range::TYPE_FLOAT), 'Browser scale factor. Pass a number between 0.1 to 3. Defaults to 1.', true, example: '2') - ->param('theme', 'light', new WhiteList(['light', 'dark']), 'Browser theme. Pass "light" or "dark". Defaults to "light".', true, example: 'dark') - ->param('userAgent', '', new Text(512), 'Custom user agent string. Defaults to browser default.', true, example: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15') - ->param('fullpage', false, new Boolean(true), 'Capture full page scroll. Pass 0 for viewport only, or 1 for full page. Defaults to 0.', true, example: 'true') - ->param('locale', '', new Text(10), 'Browser locale (e.g., "en-US", "fr-FR"). Defaults to browser default.', true, example: 'en-US') - ->param('timezone', '', new WhiteList(timezone_identifiers_list()), 'IANA timezone identifier (e.g., "America/New_York", "Europe/London"). Defaults to browser default.', true, example: 'america/new_york') - ->param('latitude', 0, new Range(-90, 90, Range::TYPE_FLOAT), 'Geolocation latitude. Pass a number between -90 to 90. Defaults to 0.', true, example: '37.7749') - ->param('longitude', 0, new Range(-180, 180, Range::TYPE_FLOAT), 'Geolocation longitude. Pass a number between -180 to 180. Defaults to 0.', true, example: '-122.4194') - ->param('accuracy', 0, new Range(0, 100000, Range::TYPE_FLOAT), 'Geolocation accuracy in meters. Pass a number between 0 to 100000. Defaults to 0.', true, example: '100') - ->param('touch', false, new Boolean(true), 'Enable touch support. Pass 0 for no touch, or 1 for touch enabled. Defaults to 0.', true, example: 'true') - ->param('permissions', [], new ArrayList(new WhiteList(['geolocation', 'camera', 'microphone', 'notifications', 'midi', 'push', 'clipboard-read', 'clipboard-write', 'payment-handler', 'usb', 'bluetooth', 'accelerometer', 'gyroscope', 'magnetometer', 'ambient-light-sensor', 'background-sync', 'persistent-storage', 'screen-wake-lock', 'web-share', 'xr-spatial-tracking'])), 'Browser permissions to grant. Pass an array of permission names like ["geolocation", "camera", "microphone"]. Defaults to empty.', true, example: '["geolocation","notifications"]') - ->param('sleep', 0, new Range(0, 10), 'Wait time in seconds before taking the screenshot. Pass an integer between 0 to 10. Defaults to 0.', true, example: '3') - ->param('width', 0, new Range(0, 2000), 'Output image width. Pass 0 to use original width, or an integer between 1 to 2000. Defaults to 0 (original width).', true, example: '800') - ->param('height', 0, new Range(0, 2000), 'Output image height. Pass 0 to use original height, or an integer between 1 to 2000. Defaults to 0 (original height).', true, example: '600') - ->param('quality', -1, new Range(-1, 100), 'Screenshot quality. Pass an integer between 0 to 100. Defaults to keep existing image quality.', true, example: '85') - ->param('output', '', new WhiteList(\array_keys(Config::getParam('storage-outputs')), true), 'Output format type (jpeg, jpg, png, gif and webp).', true, example: 'jpeg') - ->inject('response') - ->inject('queueForStatsUsage') - ->action(function (string $url, array $headers, int $viewportWidth, int $viewportHeight, float $scale, string $theme, string $userAgent, bool $fullpage, string $locale, string $timezone, float $latitude, float $longitude, float $accuracy, bool $touch, array $permissions, int $sleep, int $width, int $height, int $quality, string $output, Response $response, StatsUsage $queueForStatsUsage) { - - if (!\extension_loaded('imagick')) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); - } - - $domain = new Domain(\parse_url($url, PHP_URL_HOST)); - - if (!$domain->isKnown()) { - throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); - } - - $client = new Client(); - $client->setTimeout(30 * 1000); // 30 seconds - $client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON); - - // Convert indexed array to empty array (should not happen due to Assoc validator) - if (is_array($headers) && count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) { - $headers = []; - } - - // Create a new object to ensure proper JSON serialization - $headersObject = new \stdClass(); - foreach ($headers as $key => $value) { - $headersObject->$key = $value; - } - - // Create the config with headers as an object - // The custom browser service accepts: url, theme, headers, sleep, viewport, userAgent, fullPage, locale, timezoneId, geolocation, hasTouch, scale - $config = [ - 'url' => $url, - 'theme' => $theme, - 'headers' => $headersObject, - 'sleep' => $sleep * 1000, // Convert seconds to milliseconds - 'waitUntil' => 'load', - 'viewport' => [ - 'width' => $viewportWidth, - 'height' => $viewportHeight - ] - ]; - - // Add scale if not default - if ($scale != 1) { - $config['deviceScaleFactor'] = $scale; - } - - // Add optional parameters that were set, preserving arrays as arrays - if (!empty($userAgent)) { - $config['userAgent'] = $userAgent; - } - - if ($fullpage) { - $config['fullPage'] = true; - } - - if (!empty($locale)) { - $config['locale'] = $locale; - } - - if (!empty($timezone)) { - $config['timezoneId'] = $timezone; - } - - // Add geolocation if any coordinates are provided - if ($latitude != 0 || $longitude != 0) { - $config['geolocation'] = [ - 'latitude' => $latitude, - 'longitude' => $longitude, - 'accuracy' => $accuracy - ]; - } - - if ($touch) { - $config['hasTouch'] = true; - } - - // Add permissions if provided (preserve as array) - if (!empty($permissions)) { - $config['permissions'] = $permissions; // Keep as array - } - - try { - $browserEndpoint = System::getEnv('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1'); - - $fetchResponse = $client->fetch( - url: $browserEndpoint . '/screenshots', - method: 'POST', - body: $config - ); - - if ($fetchResponse->getStatusCode() >= 400) { - throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED, 'Screenshot service failed: ' . $fetchResponse->getBody()); - } - - $screenshot = $fetchResponse->getBody(); - - if (empty($screenshot)) { - throw new Exception(Exception::AVATAR_IMAGE_NOT_FOUND, 'Screenshot not generated'); - } - - // Determine if image processing is needed - $needsProcessing = ($width > 0 || $height > 0) || $quality !== -1 || !empty($output); - - if ($needsProcessing) { - // Process image with cropping, quality adjustment, or format conversion - $image = new Image($screenshot); - - $image->crop($width, $height); - - $output = $output ?: 'png'; // Default to PNG if not specified - $resizedScreenshot = $image->output($output, $quality); - unset($image); - } else { - // Return original screenshot without processing - $resizedScreenshot = $screenshot; - $output = 'png'; // Screenshots are typically PNG by default - } - - // Set content type based on output format - $outputs = Config::getParam('storage-outputs'); - $contentType = $outputs[$output] ?? $outputs['png']; - - $queueForStatsUsage->addMetric(METRIC_AVATARS_SCREENSHOTS_GENERATED, 1); - - $response - ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days - ->setContentType($contentType) - ->file($resizedScreenshot); - - - } catch (\Throwable $th) { - throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED, 'Screenshot generation failed: ' . $th->getMessage()); - } - }); - -App::get('/v1/cards/cloud') - ->desc('Get front Of Cloud Card') - ->groups(['api', 'avatars']) - ->label('scope', 'avatars.read') - ->label('cache', true) - ->label('cache.resourceType', 'cards/cloud') - ->label('cache.resource', 'card/{request.userId}') - ->label('docs', false) - ->label('origin', '*') - ->param('userId', '', new UID(), 'User ID.', true) - ->param('mock', '', new WhiteList(['employee', 'employee-2digit', 'hero', 'contributor', 'normal', 'platinum', 'normal-no-github', 'normal-long']), 'Mocking behaviour.', true) - ->param('width', 0, new Range(0, 512), 'Resize image width, Pass an integer between 0 to 512.', true) - ->param('height', 0, new Range(0, 320), 'Resize image height, Pass an integer between 0 to 320.', true) - ->inject('user') - ->inject('project') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('response') - ->inject('heroes') - ->inject('contributors') - ->inject('employees') - ->inject('logger') - ->action(function (string $userId, string $mock, int $width, int $height, Document $user, Document $project, Database $dbForProject, Database $dbForPlatform, Response $response, array $heroes, array $contributors, array $employees, ?Logger $logger) use ($getUserGitHub) { - $user = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $userId)); - - if ($user->isEmpty() && empty($mock)) { - throw new Exception(Exception::USER_NOT_FOUND); - } - - if (!$mock) { - $name = $user->getAttribute('name', 'Anonymous'); - $email = $user->getAttribute('email', ''); - $createdAt = new \DateTime($user->getCreatedAt()); - - $gitHub = $getUserGitHub($user->getId(), $project, $dbForProject, $dbForPlatform, $logger); - $githubName = $gitHub['name'] ?? ''; - $githubId = $gitHub['id'] ?? ''; - - $isHero = \array_key_exists($email, $heroes); - $isContributor = \in_array($githubId, $contributors); - $isEmployee = \array_key_exists($email, $employees); - $employeeNumber = $isEmployee ? $employees[$email]['spot'] : ''; - - if ($isHero) { - $createdAt = new \DateTime($heroes[$email]['memberSince'] ?? ''); - } elseif ($isEmployee) { - $createdAt = new \DateTime($employees[$email]['memberSince'] ?? ''); - } - - if (!$isEmployee && !empty($githubName)) { - $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees)); - if (!empty($employeeGitHub)) { - $isEmployee = true; - $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : ''; - $createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? ''); - } - } - - $isPlatinum = $user->getSequence() % 100 === 0; - } else { - $name = $mock === 'normal-long' ? 'Sir First Walter O\'Brian Junior' : 'Walter O\'Brian'; - $createdAt = new \DateTime('now'); - $githubName = $mock === 'normal-no-github' ? '' : ($mock === 'normal-long' ? 'sir-first-walterobrian-junior' : 'walterobrian'); - $isHero = $mock === 'hero'; - $isContributor = $mock === 'contributor'; - $isEmployee = \str_starts_with($mock, 'employee'); - $employeeNumber = match ($mock) { - 'employee' => '1', - 'employee-2digit' => '18', - default => '' - }; - - $isPlatinum = $mock === 'platinum'; - } - - if ($isEmployee) { - $isContributor = false; - $isHero = false; - } - - if ($isHero) { - $isContributor = false; - $isEmployee = false; - } - - if ($isContributor) { - $isHero = false; - $isEmployee = false; - } - - $isGolden = $isEmployee || $isHero || $isContributor; - $isPlatinum = $isGolden ? false : $isPlatinum; - $memberSince = \strtoupper('Member since ' . $createdAt->format('M') . ' ' . $createdAt->format('d') . ', ' . $createdAt->format('o')); - - $imagePath = $isGolden ? 'front-golden.png' : ($isPlatinum ? 'front-platinum.png' : 'front.png'); - - $baseImage = new \Imagick(__DIR__ . '/../../../public/images/cards/cloud/' . $imagePath); - - if ($isEmployee) { - $image = new Imagick(__DIR__ . '/../../../public/images/cards/cloud/employee.png'); - $image->setGravity(Imagick::GRAVITY_CENTER); - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 793, 35); - - $text = new \ImagickDraw(); - $text->setTextAlignment(Imagick::ALIGN_CENTER); - $text->setFont(__DIR__ . '/../../../public/fonts/Inter-Bold.ttf'); - $text->setFillColor(new \ImagickPixel('#FFFADF')); - $text->setFontSize(\strlen($employeeNumber) <= 2 ? 54 : 48); - $text->setFontWeight(700); - $metricsText = $baseImage->queryFontMetrics($text, $employeeNumber); - - $hashtag = new \ImagickDraw(); - $hashtag->setTextAlignment(Imagick::ALIGN_CENTER); - $hashtag->setFont(__DIR__ . '/../../../public/fonts/Inter-Bold.ttf'); - $hashtag->setFillColor(new \ImagickPixel('#FFFADF')); - $hashtag->setFontSize(28); - $hashtag->setFontWeight(700); - $metricsHashtag = $baseImage->queryFontMetrics($hashtag, '#'); - - $startX = 898; - $totalWidth = $metricsHashtag['textWidth'] + 12 + $metricsText['textWidth']; - - $hashtagX = ($metricsHashtag['textWidth'] / 2); - $textX = $hashtagX + 12 + ($metricsText['textWidth'] / 2); - - $hashtagX -= $totalWidth / 2; - $textX -= $totalWidth / 2; - - $hashtagX += $startX; - $textX += $startX; - - $baseImage->annotateImage($hashtag, $hashtagX, 150, 0, '#'); - $baseImage->annotateImage($text, $textX, 150, 0, $employeeNumber); - } - - if ($isContributor) { - $image = new Imagick(__DIR__ . '/../../../public/images/cards/cloud/contributor.png'); - $image->setGravity(Imagick::GRAVITY_CENTER); - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 793, 34); - } - - if ($isHero) { - $image = new Imagick(__DIR__ . '/../../../public/images/cards/cloud/hero.png'); - $image->setGravity(Imagick::GRAVITY_CENTER); - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 793, 34); - } - - setlocale(LC_ALL, "en_US.utf8"); - // $name = \iconv("utf-8", "ascii//TRANSLIT", $name); - // $memberSince = \iconv("utf-8", "ascii//TRANSLIT", $memberSince); - // $githubName = \iconv("utf-8", "ascii//TRANSLIT", $githubName); - - $text = new \ImagickDraw(); - $text->setTextAlignment(Imagick::ALIGN_CENTER); - $text->setFont(__DIR__ . '/../../../public/fonts/Inter-Bold.ttf'); - $text->setFillColor(new \ImagickPixel('#FFFFFF')); - - if (\strlen($name) > 32) { - $name = \substr($name, 0, 32); - } - - if (\strlen($name) <= 23) { - $text->setFontSize(80); - $scalingDown = false; - } else { - $text->setFontSize(54); - $scalingDown = true; - } - $text->setFontWeight(700); - $baseImage->annotateImage($text, 512, 477, 0, $name); - - $text = new \ImagickDraw(); - $text->setTextAlignment(Imagick::ALIGN_CENTER); - $text->setFont(__DIR__ . '/../../../public/fonts/Inter-SemiBold.ttf'); - $text->setFillColor(new \ImagickPixel($isGolden || $isPlatinum ? '#FFFFFF' : '#FFB9CC')); - $text->setFontSize(27); - $text->setFontWeight(600); - $text->setTextKerning(1.08); - $baseImage->annotateImage($text, 512, 541, 0, \strtoupper($memberSince)); - - if (!empty($githubName)) { - $text = new \ImagickDraw(); - $text->setTextAlignment(Imagick::ALIGN_CENTER); - $text->setFont(__DIR__ . '/../../../public/fonts/Inter-Regular.ttf'); - $text->setFillColor(new \ImagickPixel('#FFFFFF')); - $text->setFontSize($scalingDown ? 28 : 32); - $text->setFontWeight(400); - $metrics = $baseImage->queryFontMetrics($text, $githubName); - - $baseImage->annotateImage($text, 512 + 20 + 4, 373 + ($scalingDown ? 2 : 0), 0, $githubName); - - $image = new Imagick(__DIR__ . '/../../../public/images/cards/cloud/github.png'); - $image->setGravity(Imagick::GRAVITY_CENTER); - $precisionFix = 5; - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 512 - ($metrics['textWidth'] / 2) - 20 - 4, 373 - ($metrics['textHeight'] - $precisionFix)); - } - - if (!empty($width) || !empty($height)) { - $baseImage->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1); - } - - $response - ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days - ->setContentType('image/png') - ->file($baseImage->getImageBlob()); - }); - -App::get('/v1/cards/cloud-back') - ->desc('Get back Of Cloud Card') - ->groups(['api', 'avatars']) - ->label('scope', 'avatars.read') - ->label('cache', true) - ->label('cache.resourceType', 'cards/cloud-back') - ->label('cache.resource', 'card-back/{request.userId}') - ->label('docs', false) - ->label('origin', '*') - ->param('userId', '', new UID(), 'User ID.', true) - ->param('mock', '', new WhiteList(['golden', 'normal', 'platinum']), 'Mocking behaviour.', true) - ->param('width', 0, new Range(0, 512), 'Resize image width, Pass an integer between 0 to 512.', true) - ->param('height', 0, new Range(0, 320), 'Resize image height, Pass an integer between 0 to 320.', true) - ->inject('user') - ->inject('project') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('response') - ->inject('heroes') - ->inject('contributors') - ->inject('employees') - ->inject('logger') - ->action(function (string $userId, string $mock, int $width, int $height, Document $user, Document $project, Database $dbForProject, Database $dbForPlatform, Response $response, array $heroes, array $contributors, array $employees, ?Logger $logger) use ($getUserGitHub) { - $user = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $userId)); - - if ($user->isEmpty() && empty($mock)) { - throw new Exception(Exception::USER_NOT_FOUND); - } - - if (!$mock) { - $userId = $user->getId(); - $email = $user->getAttribute('email', ''); - - $gitHub = $getUserGitHub($user->getId(), $project, $dbForProject, $dbForPlatform, $logger); - $githubId = $gitHub['id'] ?? ''; - - $isHero = \array_key_exists($email, $heroes); - $isContributor = \in_array($githubId, $contributors); - $isEmployee = \array_key_exists($email, $employees); - - $isGolden = $isEmployee || $isHero || $isContributor; - $isPlatinum = $user->getSequence() % 100 === 0; - } else { - $userId = '63e0bcf3c3eb803ba530'; - - $isGolden = $mock === 'golden'; - $isPlatinum = $mock === 'platinum'; - } - - $userId = 'UID ' . $userId; - - $isPlatinum = $isGolden ? false : $isPlatinum; - - $imagePath = $isGolden ? 'back-golden.png' : ($isPlatinum ? 'back-platinum.png' : 'back.png'); - - $baseImage = new \Imagick(__DIR__ . '/../../../public/images/cards/cloud/' . $imagePath); - - setlocale(LC_ALL, "en_US.utf8"); - // $userId = \iconv("utf-8", "ascii//TRANSLIT", $userId); - - $text = new \ImagickDraw(); - $text->setTextAlignment(Imagick::ALIGN_CENTER); - $text->setFont(__DIR__ . '/../../../public/fonts/SourceCodePro-Regular.ttf'); - $text->setFillColor(new \ImagickPixel($isGolden ? '#664A1E' : ($isPlatinum ? '#555555' : '#E8E9F0'))); - $text->setFontSize(28); - $text->setFontWeight(400); - $baseImage->annotateImage($text, 512, 596, 0, $userId); - - if (!empty($width) || !empty($height)) { - $baseImage->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1); - } - - $response - ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days - ->setContentType('image/png') - ->file($baseImage->getImageBlob()); - }); - -App::get('/v1/cards/cloud-og') - ->desc('Get OG image From Cloud Card') - ->groups(['api', 'avatars']) - ->label('scope', 'avatars.read') - ->label('cache', true) - ->label('cache.resourceType', 'cards/cloud-og') - ->label('cache.resource', 'card-og/{request.userId}') - ->label('docs', false) - ->label('origin', '*') - ->param('userId', '', new UID(), 'User ID.', true) - ->param('mock', '', new WhiteList(['employee', 'employee-2digit', 'hero', 'contributor', 'normal', 'platinum', 'normal-no-github', 'normal-long', 'normal-long-right', 'normal-long-middle', 'normal-bg2', 'normal-bg3', 'normal-right', 'normal-middle', 'platinum-right', 'platinum-middle', 'hero-middle', 'hero-right', 'contributor-right', 'employee-right', 'contributor-middle', 'employee-middle', 'employee-2digit-middle', 'employee-2digit-right']), 'Mocking behaviour.', true) - ->param('width', 0, new Range(0, 1024), 'Resize image card width, Pass an integer between 0 to 1024.', true) - ->param('height', 0, new Range(0, 1024), 'Resize image card height, Pass an integer between 0 to 1024.', true) - ->inject('user') - ->inject('project') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->inject('response') - ->inject('heroes') - ->inject('contributors') - ->inject('employees') - ->inject('logger') - ->action(function (string $userId, string $mock, int $width, int $height, Document $user, Document $project, Database $dbForProject, Database $dbForPlatform, Response $response, array $heroes, array $contributors, array $employees, ?Logger $logger) use ($getUserGitHub) { - $user = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $userId)); - - if ($user->isEmpty() && empty($mock)) { - throw new Exception(Exception::USER_NOT_FOUND); - } - - if (!$mock) { - $sequence = $user->getSequence(); - $bgVariation = $sequence % 3 === 0 ? '1' : ($sequence % 3 === 1 ? '2' : '3'); - $cardVariation = $sequence % 3 === 0 ? '1' : ($sequence % 3 === 1 ? '2' : '3'); - - $name = $user->getAttribute('name', 'Anonymous'); - $email = $user->getAttribute('email', ''); - $createdAt = new \DateTime($user->getCreatedAt()); - - $gitHub = $getUserGitHub($user->getId(), $project, $dbForProject, $dbForPlatform, $logger); - $githubName = $gitHub['name'] ?? ''; - $githubId = $gitHub['id'] ?? ''; - - $isHero = \array_key_exists($email, $heroes); - $isContributor = \in_array($githubId, $contributors); - $isEmployee = \array_key_exists($email, $employees); - $employeeNumber = $isEmployee ? $employees[$email]['spot'] : ''; - - if ($isHero) { - $createdAt = new \DateTime($heroes[$email]['memberSince'] ?? ''); - } elseif ($isEmployee) { - $createdAt = new \DateTime($employees[$email]['memberSince'] ?? ''); - } - - if (!$isEmployee && !empty($githubName)) { - $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees)); - if (!empty($employeeGitHub)) { - $isEmployee = true; - $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : ''; - $createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? ''); - } - } - - $isPlatinum = $user->getSequence() % 100 === 0; - } else { - $bgVariation = \str_ends_with($mock, '-bg2') ? '2' : (\str_ends_with($mock, '-bg3') ? '3' : '1'); - $cardVariation = \str_ends_with($mock, '-right') ? '2' : (\str_ends_with($mock, '-middle') ? '3' : '1'); - $name = \str_starts_with($mock, 'normal-long') ? 'Sir First Walter O\'Brian Junior' : 'Walter O\'Brian'; - $createdAt = new \DateTime('now'); - $githubName = $mock === 'normal-no-github' ? '' : (\str_starts_with($mock, 'normal-long') ? 'sir-first-walterobrian-junior' : 'walterobrian'); - $isHero = \str_starts_with($mock, 'hero'); - $isContributor = \str_starts_with($mock, 'contributor'); - $isEmployee = \str_starts_with($mock, 'employee'); - $employeeNumber = match ($mock) { - 'employee' => '1', - 'employee-right' => '1', - 'employee-middle' => '1', - 'employee-2digit' => '18', - 'employee-2digit-right' => '18', - 'employee-2digit-middle' => '18', - default => '' - }; - - $isPlatinum = \str_starts_with($mock, 'platinum'); - } - - if ($isEmployee) { - $isContributor = false; - $isHero = false; - } - - if ($isHero) { - $isContributor = false; - $isEmployee = false; - } - - if ($isContributor) { - $isHero = false; - $isEmployee = false; - } - - $isGolden = $isEmployee || $isHero || $isContributor; - $isPlatinum = $isGolden ? false : $isPlatinum; - $memberSince = \strtoupper('Member since ' . $createdAt->format('M') . ' ' . $createdAt->format('d') . ', ' . $createdAt->format('o')); - - $baseImage = new \Imagick(__DIR__ . "/../../../public/images/cards/cloud/og-background{$bgVariation}.png"); - - $cardType = $isGolden ? '-golden' : ($isPlatinum ? '-platinum' : ''); - - $image = new Imagick(__DIR__ . "/../../../public/images/cards/cloud/og-card{$cardType}{$cardVariation}.png"); - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 1008 / 2 - $image->getImageWidth() / 2, 1008 / 2 - $image->getImageHeight() / 2); - - $imageLogo = new Imagick(__DIR__ . '/../../../public/images/cards/cloud/og-background-logo.png'); - $imageShadow = new Imagick(__DIR__ . "/../../../public/images/cards/cloud/og-shadow{$cardType}.png"); - if ($cardVariation === '1') { - $baseImage->compositeImage($imageLogo, Imagick::COMPOSITE_OVER, 32, 1008 - $imageLogo->getImageHeight() - 32); - $baseImage->compositeImage($imageShadow, Imagick::COMPOSITE_OVER, -450, 700); - } elseif ($cardVariation === '2') { - $baseImage->compositeImage($imageLogo, Imagick::COMPOSITE_OVER, 1008 - $imageLogo->getImageWidth() - 32, 1008 - $imageLogo->getImageHeight() - 32); - $baseImage->compositeImage($imageShadow, Imagick::COMPOSITE_OVER, -20, 710); - } else { - $baseImage->compositeImage($imageLogo, Imagick::COMPOSITE_OVER, 1008 - $imageLogo->getImageWidth() - 32, 1008 - $imageLogo->getImageHeight() - 32); - $baseImage->compositeImage($imageShadow, Imagick::COMPOSITE_OVER, -135, 710); - } - - if ($isEmployee) { - $file = $cardVariation === '3' ? 'employee-skew.png' : 'employee.png'; - $image = new Imagick(__DIR__ . '/../../../public/images/cards/cloud/' . $file); - $image->setGravity(Imagick::GRAVITY_CENTER); - - $hashtag = new \ImagickDraw(); - $hashtag->setTextAlignment(Imagick::ALIGN_LEFT); - $hashtag->setFont(__DIR__ . '/../../../public/fonts/Inter-Bold.ttf'); - $hashtag->setFillColor(new \ImagickPixel('#FFFADF')); - $hashtag->setFontSize(20); - $hashtag->setFontWeight(700); - - $text = new \ImagickDraw(); - $text->setTextAlignment(Imagick::ALIGN_LEFT); - $text->setFont(__DIR__ . '/../../../public/fonts/Inter-Bold.ttf'); - $text->setFillColor(new \ImagickPixel('#FFFADF')); - $text->setFontSize(\strlen($employeeNumber) <= 1 ? 36 : 28); - $text->setFontWeight(700); - - if ($cardVariation === '3') { - $hashtag->setFontSize(16); - $text->setFontSize(\strlen($employeeNumber) <= 1 ? 30 : 26); - - $hashtag->skewY(20); - $hashtag->skewX(20); - $text->skewY(20); - $text->skewX(20); - } - - $metricsHashtag = $baseImage->queryFontMetrics($hashtag, '#'); - $metricsText = $baseImage->queryFontMetrics($text, $employeeNumber); - - $group = new Imagick(); - $groupWidth = $metricsHashtag['textWidth'] + 6 + $metricsText['textWidth']; - - if ($cardVariation === '1') { - $group->newImage($groupWidth, $metricsText['textHeight'], '#00000000'); - $group->annotateImage($hashtag, 0, $metricsText['textHeight'], 0, '#'); - $group->annotateImage($text, $metricsHashtag['textWidth'] + 6, $metricsText['textHeight'], 0, $employeeNumber); - - $image->resizeImage(120, 120, Imagick::FILTER_LANCZOS, 1); - $image->rotateImage(new ImagickPixel('#00000000'), -20); - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 612, 203); - - $group->rotateImage(new ImagickPixel('#00000000'), -22); - - if (\strlen($employeeNumber) <= 1) { - $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, 660, 245); - } else { - $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, 655, 247); - } - } elseif ($cardVariation === '2') { - $group->newImage($groupWidth, $metricsText['textHeight'], '#00000000'); - $group->annotateImage($hashtag, 0, $metricsText['textHeight'], 0, '#'); - $group->annotateImage($text, $metricsHashtag['textWidth'] + 6, $metricsText['textHeight'], 0, $employeeNumber); - - $image->resizeImage(120, 120, Imagick::FILTER_LANCZOS, 1); - $image->rotateImage(new ImagickPixel('#00000000'), 30); - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 715, 425); - - $group->rotateImage(new ImagickPixel('#00000000'), 32); - - if (\strlen($employeeNumber) <= 1) { - $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, 775, 465); - } else { - $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, 767, 470); - } - } else { - $group->newImage(300, 300, '#00000000'); - - $hashtag->annotation(0, $metricsText['textHeight'], '#'); - $text->annotation($metricsHashtag['textWidth'] + 2, $metricsText['textHeight'], $employeeNumber); - - $group->drawImage($hashtag); - $group->drawImage($text); - - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 640, 293); - - if (\strlen($employeeNumber) <= 1) { - $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, 670, 317); - } else { - $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, 663, 322); - } - } - } - - if ($isContributor) { - $file = $cardVariation === '3' ? 'contributor-skew.png' : 'contributor.png'; - $image = new Imagick(__DIR__ . '/../../../public/images/cards/cloud/' . $file); - $image->setGravity(Imagick::GRAVITY_CENTER); - - if ($cardVariation === '1') { - $image->resizeImage(120, 120, Imagick::FILTER_LANCZOS, 1); - $image->rotateImage(new ImagickPixel('#00000000'), -20); - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 612, 203); - } elseif ($cardVariation === '2') { - $image->resizeImage(120, 120, Imagick::FILTER_LANCZOS, 1); - $image->rotateImage(new ImagickPixel('#00000000'), 30); - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 715, 425); - } else { - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 640, 293); - } - } - - if ($isHero) { - $file = $cardVariation === '3' ? 'hero-skew.png' : 'hero.png'; - $image = new Imagick(__DIR__ . '/../../../public/images/cards/cloud/' . $file); - $image->setGravity(Imagick::GRAVITY_CENTER); - - if ($cardVariation === '1') { - $image->resizeImage(120, 120, Imagick::FILTER_LANCZOS, 1); - $image->rotateImage(new ImagickPixel('#00000000'), -20); - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 612, 203); - } elseif ($cardVariation === '2') { - $image->resizeImage(120, 120, Imagick::FILTER_LANCZOS, 1); - $image->rotateImage(new ImagickPixel('#00000000'), 30); - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 715, 425); - } else { - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 640, 293); - } - } - - setlocale(LC_ALL, "en_US.utf8"); - // $name = \iconv("utf-8", "ascii//TRANSLIT", $name); - // $memberSince = \iconv("utf-8", "ascii//TRANSLIT", $memberSince); - // $githubName = \iconv("utf-8", "ascii//TRANSLIT", $githubName); - - $textName = new \ImagickDraw(); - $textName->setTextAlignment(Imagick::ALIGN_CENTER); - $textName->setFont(__DIR__ . '/../../../public/fonts/Inter-Bold.ttf'); - $textName->setFillColor(new \ImagickPixel('#FFFFFF')); - - if (\strlen($name) > 32) { - $name = \substr($name, 0, 32); - } - - if ($cardVariation === '1') { - if (\strlen($name) <= 23) { - $scalingDown = false; - $textName->setFontSize(54); - } else { - $scalingDown = true; - $textName->setFontSize(36); - } - } elseif ($cardVariation === '2') { - if (\strlen($name) <= 23) { - $scalingDown = false; - $textName->setFontSize(50); - } else { - $scalingDown = true; - $textName->setFontSize(34); - } - } else { - if (\strlen($name) <= 23) { - $scalingDown = false; - $textName->setFontSize(44); - } else { - $scalingDown = true; - $textName->setFontSize(32); - } - } - - $textName->setFontWeight(700); - - $textMember = new \ImagickDraw(); - $textMember->setTextAlignment(Imagick::ALIGN_CENTER); - $textMember->setFont(__DIR__ . '/../../../public/fonts/Inter-Medium.ttf'); - $textMember->setFillColor(new \ImagickPixel($isGolden || $isPlatinum ? '#FFFFFF' : '#FFB9CC')); - $textMember->setFontWeight(500); - $textMember->setTextKerning(1.12); - - if ($cardVariation === '1') { - $textMember->setFontSize(21); - - $baseImage->annotateImage($textName, 550, 600, -22, $name); - $baseImage->annotateImage($textMember, 585, 635, -22, $memberSince); - } elseif ($cardVariation === '2') { - $textMember->setFontSize(20); - - $baseImage->annotateImage($textName, 435, 590, 31.37, $name); - $baseImage->annotateImage($textMember, 412, 628, 31.37, $memberSince); - } else { - $textMember->setFontSize(16); - - $textName->skewY(20); - $textName->skewX(20); - $textName->annotation(320, 700, $name); - - $textMember->skewY(20); - $textMember->skewX(20); - $textMember->annotation(330, 735, $memberSince); - - $baseImage->drawImage($textName); - $baseImage->drawImage($textMember); - } - - if (!empty($githubName)) { - $text = new \ImagickDraw(); - $text->setTextAlignment(Imagick::ALIGN_LEFT); - $text->setFont(__DIR__ . '/../../../public/fonts/Inter-Regular.ttf'); - $text->setFillColor(new \ImagickPixel('#FFFFFF')); - $text->setFontSize($scalingDown ? 16 : 20); - $text->setFontWeight(400); - - if ($cardVariation === '1') { - $metrics = $baseImage->queryFontMetrics($text, $githubName); - - $group = new Imagick(); - $groupWidth = $metrics['textWidth'] + 32 + 4; - $group->newImage($groupWidth, $metrics['textHeight'] + 10, '#00000000'); - $image = new Imagick(__DIR__ . '/../../../public/images/cards/cloud/github.png'); - $image->setGravity(Imagick::GRAVITY_CENTER); - $image->resizeImage(32, 32, Imagick::FILTER_LANCZOS, 1); - $precisionFix = -1; - - $group->compositeImage($image, Imagick::COMPOSITE_OVER, 0, 0); - $group->annotateImage($text, 32 + 4, $metrics['textHeight'] - $precisionFix, 0, $githubName); - - $group->rotateImage(new ImagickPixel('#00000000'), -22); - $x = 510 - $group->getImageWidth() / 2; - $y = 530 - $group->getImageHeight() / 2; - $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, $x, $y); - } elseif ($cardVariation === '2') { - $metrics = $baseImage->queryFontMetrics($text, $githubName); - - $group = new Imagick(); - $groupWidth = $metrics['textWidth'] + 32 + 4; - $group->newImage($groupWidth, $metrics['textHeight'] + 10, '#00000000'); - $image = new Imagick(__DIR__ . '/../../../public/images/cards/cloud/github.png'); - $image->setGravity(Imagick::GRAVITY_CENTER); - $image->resizeImage(32, 32, Imagick::FILTER_LANCZOS, 1); - $precisionFix = -1; - - $group->compositeImage($image, Imagick::COMPOSITE_OVER, 0, 0); - $group->annotateImage($text, 32 + 4, $metrics['textHeight'] - $precisionFix, 0, $githubName); - - $group->rotateImage(new ImagickPixel('#00000000'), 31.11); - $x = 485 - $group->getImageWidth() / 2; - $y = 530 - $group->getImageHeight() / 2; - $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, $x, $y); - } else { - $text->skewY(20); - $text->skewX(20); - $text->setTextAlignment(\Imagick::ALIGN_CENTER); - - $text->annotation(320 + 15 + 2, 640, $githubName); - $metrics = $baseImage->queryFontMetrics($text, $githubName); - - $image = new Imagick(__DIR__ . '/../../../public/images/cards/cloud/github-skew.png'); - $image->setGravity(Imagick::GRAVITY_CENTER); - $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 512 - ($metrics['textWidth'] / 2), 518 + \strlen($githubName) * 1.3); - - $baseImage->drawImage($text); - } - } - - if (!empty($width) || !empty($height)) { - $baseImage->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1); - } - - $response - ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days - ->setContentType('image/png') - ->file($baseImage->getImageBlob()); - }); diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index a34c79308a..2b929765f2 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform; use Appwrite\Platform\Modules\Account; +use Appwrite\Platform\Modules\Avatars; use Appwrite\Platform\Modules\Console; use Appwrite\Platform\Modules\Core; use Appwrite\Platform\Modules\Databases; @@ -20,6 +21,7 @@ class Appwrite extends Platform { parent::__construct(new Core()); $this->addModule(new Account\Module()); + $this->addModule(new Avatars\Module()); $this->addModule(new Databases\Module()); $this->addModule(new Projects\Module()); $this->addModule(new Functions\Module()); diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Action.php b/src/Appwrite/Platform/Modules/Avatars/Http/Action.php new file mode 100644 index 0000000000..1ff2f8f706 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Action.php @@ -0,0 +1,158 @@ +crop((int) $width, (int) $height); + $output = (empty($output)) ? $type : $output; + $data = $image->output($output, $quality); + $response + ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days + ->setContentType('image/png') + ->file($data); + unset($image); + } + + protected function getUserGitHub(string $userId, Document $project, Database $dbForProject, Database $dbForPlatform, ?Logger $logger): array + { + try { + $user = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $userId)); + + $sessions = $user->getAttribute('sessions', []); + + $gitHubSession = null; + foreach ($sessions as $session) { + if ($session->getAttribute('provider', '') === 'github') { + $gitHubSession = $session; + break; + } + } + + if (empty($gitHubSession)) { + throw new Exception(Exception::USER_SESSION_NOT_FOUND, 'GitHub session not found.'); + } + + $provider = $gitHubSession->getAttribute('provider', ''); + $accessToken = $gitHubSession->getAttribute('providerAccessToken'); + $accessTokenExpiry = $gitHubSession->getAttribute('providerAccessTokenExpiry'); + $refreshToken = $gitHubSession->getAttribute('providerRefreshToken'); + + $appId = $project->getAttribute('oAuthProviders', [])[$provider . 'Appid'] ?? ''; + $appSecret = $project->getAttribute('oAuthProviders', [])[$provider . 'Secret'] ?? '{}'; + + $oAuthProviders = Config::getParam('oAuthProviders'); + $className = $oAuthProviders[$provider]['class']; + if (!\class_exists($className)) { + throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); + } + + $oauth2 = new $className($appId, $appSecret, '', [], []); + + $isExpired = new \DateTime($accessTokenExpiry) < new \DateTime('now'); + if ($isExpired) { + try { + $oauth2->refreshTokens($refreshToken); + + $accessToken = $oauth2->getAccessToken(''); + $refreshToken = $oauth2->getRefreshToken(''); + + $verificationId = $oauth2->getUserID($accessToken); + + if (empty($verificationId)) { + throw new \Exception("Locked tokens."); // Race codition, handeled in catch + } + + $gitHubSession + ->setAttribute('providerAccessToken', $accessToken) + ->setAttribute('providerRefreshToken', $refreshToken) + ->setAttribute('providerAccessTokenExpiry', DateTime::addSeconds(new \DateTime(), (int)$oauth2->getAccessTokenExpiry(''))); + + Authorization::skip(fn () => $dbForProject->updateDocument('sessions', $gitHubSession->getId(), $gitHubSession)); + + $dbForProject->purgeCachedDocument('users', $user->getId()); + } catch (Throwable $err) { + $index = 0; + do { + $previousAccessToken = $gitHubSession->getAttribute('providerAccessToken'); + + $user = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $userId)); + $sessions = $user->getAttribute('sessions', []); + + $gitHubSession = new Document(); + foreach ($sessions as $session) { + if ($session->getAttribute('provider', '') === 'github') { + $gitHubSession = $session; + break; + } + } + + $accessToken = $gitHubSession->getAttribute('providerAccessToken'); + + if ($accessToken !== $previousAccessToken) { + break; + } + + $index++; + \usleep(500000); + } while ($index < 10); + } + } + + $oauth2 = new $className($appId, $appSecret, '', [], []); + $githubUser = $oauth2->getUserSlug($accessToken); + $githubId = $oauth2->getUserID($accessToken); + + return [ + 'name' => $githubUser, + 'id' => $githubId + ]; + } catch (Exception $error) { + return []; + } + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Browsers/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Browsers/Get.php new file mode 100644 index 0000000000..04648752b5 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Browsers/Get.php @@ -0,0 +1,64 @@ +setHttpMethod(UtopiaAction::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/avatars/browsers/:code') + ->desc('Get browser icon') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.read') + ->label('cache', true) + ->label('cache.resource', 'avatar/browser') + ->label('sdk', new Method( + namespace: 'avatars', + group: null, + name: 'getBrowser', + description: '/docs/references/avatars/get-browser.md', + auth: [AuthType::ADMIN, 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('code', '', new WhiteList(\array_keys(Config::getParam('avatar-browsers'))), 'Browser Code.') + ->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) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(string $code, int $width, int $height, int $quality, Response $response) + { + $this->avatarCallback('browsers', $code, $width, $height, $quality, $response); + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Back/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Back/Get.php new file mode 100644 index 0000000000..1c0de4001e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Back/Get.php @@ -0,0 +1,115 @@ +setHttpMethod(UtopiaAction::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/cards/cloud-back') + ->desc('Get back Of Cloud Card') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.read') + ->label('cache', true) + ->label('cache.resourceType', 'cards/cloud-back') + ->label('cache.resource', 'card-back/{request.userId}') + ->label('docs', false) + ->label('origin', '*') + ->param('userId', '', new UID(), 'User ID.', true) + ->param('mock', '', new WhiteList(['golden', 'normal', 'platinum']), 'Mocking behaviour.', true) + ->param('width', 0, new Range(0, 512), 'Resize image width, Pass an integer between 0 to 512.', true) + ->param('height', 0, new Range(0, 320), 'Resize image height, Pass an integer between 0 to 320.', true) + ->inject('user') + ->inject('project') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('response') + ->inject('heroes') + ->inject('contributors') + ->inject('employees') + ->inject('logger') + ->callback($this->action(...)); + } + + public function action(string $userId, string $mock, int $width, int $height, Document $user, Document $project, Database $dbForProject, Database $dbForPlatform, Response $response, array $heroes, array $contributors, array $employees, ?Logger $logger) + { + $user = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $userId)); + + if ($user->isEmpty() && empty($mock)) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + if (!$mock) { + $userId = $user->getId(); + $email = $user->getAttribute('email', ''); + + $gitHub = $this->getUserGitHub($user->getId(), $project, $dbForProject, $dbForPlatform, $logger); + $githubId = $gitHub['id'] ?? ''; + + $isHero = \array_key_exists($email, $heroes); + $isContributor = \in_array($githubId, $contributors); + $isEmployee = \array_key_exists($email, $employees); + + $isGolden = $isEmployee || $isHero || $isContributor; + $isPlatinum = $user->getSequence() % 100 === 0; + } else { + $userId = '63e0bcf3c3eb803ba530'; + + $isGolden = $mock === 'golden'; + $isPlatinum = $mock === 'platinum'; + } + + $userId = 'UID ' . $userId; + + $isPlatinum = $isGolden ? false : $isPlatinum; + + $imagePath = $isGolden ? 'back-golden.png' : ($isPlatinum ? 'back-platinum.png' : 'back.png'); + + $baseImage = new Imagick($this->getAppRoot() . '/public/images/cards/cloud/' . $imagePath); + + setlocale(LC_ALL, "en_US.utf8"); + // $userId = \iconv("utf-8", "ascii//TRANSLIT", $userId); + + $text = new ImagickDraw(); + $text->setTextAlignment(Imagick::ALIGN_CENTER); + $text->setFont($this->getAppRoot() . '/public/fonts/SourceCodePro-Regular.ttf'); + $text->setFillColor(new ImagickPixel($isGolden ? '#664A1E' : ($isPlatinum ? '#555555' : '#E8E9F0'))); + $text->setFontSize(28); + $text->setFontWeight(400); + $baseImage->annotateImage($text, 512, 596, 0, $userId); + + if (!empty($width) || !empty($height)) { + $baseImage->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1); + } + + $response + ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days + ->setContentType('image/png') + ->file($baseImage->getImageBlob()); + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php new file mode 100644 index 0000000000..9d53991dd6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/Front/Get.php @@ -0,0 +1,244 @@ +setHttpMethod(UtopiaAction::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/cards/cloud') + ->desc('Get front Of Cloud Card') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.read') + ->label('cache', true) + ->label('cache.resourceType', 'cards/cloud') + ->label('cache.resource', 'card/{request.userId}') + ->label('docs', false) + ->label('origin', '*') + ->param('userId', '', new UID(), 'User ID.', true) + ->param('mock', '', new WhiteList(['employee', 'employee-2digit', 'hero', 'contributor', 'normal', 'platinum', 'normal-no-github', 'normal-long']), 'Mocking behaviour.', true) + ->param('width', 0, new Range(0, 512), 'Resize image width, Pass an integer between 0 to 512.', true) + ->param('height', 0, new Range(0, 320), 'Resize image height, Pass an integer between 0 to 320.', true) + ->inject('user') + ->inject('project') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('response') + ->inject('heroes') + ->inject('contributors') + ->inject('employees') + ->inject('logger') + ->callback($this->action(...)); + } + + public function action(string $userId, string $mock, int $width, int $height, Document $user, Document $project, Database $dbForProject, Database $dbForPlatform, Response $response, array $heroes, array $contributors, array $employees, ?Logger $logger) + { + $user = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $userId)); + + if ($user->isEmpty() && empty($mock)) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + if (!$mock) { + $name = $user->getAttribute('name', 'Anonymous'); + $email = $user->getAttribute('email', ''); + $createdAt = new \DateTime($user->getCreatedAt()); + + $gitHub = $this->getUserGitHub($user->getId(), $project, $dbForProject, $dbForPlatform, $logger); + $githubName = $gitHub['name'] ?? ''; + $githubId = $gitHub['id'] ?? ''; + + $isHero = \array_key_exists($email, $heroes); + $isContributor = \in_array($githubId, $contributors); + $isEmployee = \array_key_exists($email, $employees); + $employeeNumber = $isEmployee ? $employees[$email]['spot'] : ''; + + if ($isHero) { + $createdAt = new \DateTime($heroes[$email]['memberSince'] ?? ''); + } elseif ($isEmployee) { + $createdAt = new \DateTime($employees[$email]['memberSince'] ?? ''); + } + + if (!$isEmployee && !empty($githubName)) { + $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees)); + if (!empty($employeeGitHub)) { + $isEmployee = true; + $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : ''; + $createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? ''); + } + } + + $isPlatinum = $user->getSequence() % 100 === 0; + } else { + $name = $mock === 'normal-long' ? 'Sir First Walter O\'Brian Junior' : 'Walter O\'Brian'; + $createdAt = new \DateTime('now'); + $githubName = $mock === 'normal-no-github' ? '' : ($mock === 'normal-long' ? 'sir-first-walterobrian-junior' : 'walterobrian'); + $isHero = $mock === 'hero'; + $isContributor = $mock === 'contributor'; + $isEmployee = \str_starts_with($mock, 'employee'); + $employeeNumber = match ($mock) { + 'employee' => '1', + 'employee-2digit' => '18', + default => '' + }; + + $isPlatinum = $mock === 'platinum'; + } + + if ($isEmployee) { + $isContributor = false; + $isHero = false; + } + + if ($isHero) { + $isContributor = false; + $isEmployee = false; + } + + if ($isContributor) { + $isHero = false; + $isEmployee = false; + } + + $isGolden = $isEmployee || $isHero || $isContributor; + $isPlatinum = $isGolden ? false : $isPlatinum; + $memberSince = \strtoupper('Member since ' . $createdAt->format('M') . ' ' . $createdAt->format('d') . ', ' . $createdAt->format('o')); + + $imagePath = $isGolden ? 'front-golden.png' : ($isPlatinum ? 'front-platinum.png' : 'front.png'); + + $baseImage = new Imagick($this->getAppRoot() . '/public/images/cards/cloud/' . $imagePath); + + if ($isEmployee) { + $image = new Imagick($this->getAppRoot() . '/public/images/cards/cloud/employee.png'); + $image->setGravity(Imagick::GRAVITY_CENTER); + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 793, 35); + + $text = new ImagickDraw(); + $text->setTextAlignment(Imagick::ALIGN_CENTER); + $text->setFont($this->getAppRoot() . '/public/fonts/Inter-Bold.ttf'); + $text->setFillColor(new ImagickPixel('#FFFADF')); + $text->setFontSize(\strlen($employeeNumber) <= 2 ? 54 : 48); + $text->setFontWeight(700); + $metricsText = $baseImage->queryFontMetrics($text, $employeeNumber); + + $hashtag = new ImagickDraw(); + $hashtag->setTextAlignment(Imagick::ALIGN_CENTER); + $hashtag->setFont($this->getAppRoot() . '/public/fonts/Inter-Bold.ttf'); + $hashtag->setFillColor(new ImagickPixel('#FFFADF')); + $hashtag->setFontSize(28); + $hashtag->setFontWeight(700); + $metricsHashtag = $baseImage->queryFontMetrics($hashtag, '#'); + + $startX = 898; + $totalWidth = $metricsHashtag['textWidth'] + 12 + $metricsText['textWidth']; + + $hashtagX = ($metricsHashtag['textWidth'] / 2); + $textX = $hashtagX + 12 + ($metricsText['textWidth'] / 2); + + $hashtagX -= $totalWidth / 2; + $textX -= $totalWidth / 2; + + $hashtagX += $startX; + $textX += $startX; + + $baseImage->annotateImage($hashtag, $hashtagX, 150, 0, '#'); + $baseImage->annotateImage($text, $textX, 150, 0, $employeeNumber); + } + + if ($isContributor) { + $image = new Imagick($this->getAppRoot() . '/public/images/cards/cloud/contributor.png'); + $image->setGravity(Imagick::GRAVITY_CENTER); + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 793, 34); + } + + if ($isHero) { + $image = new Imagick($this->getAppRoot() . '/public/images/cards/cloud/hero.png'); + $image->setGravity(Imagick::GRAVITY_CENTER); + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 793, 34); + } + + setlocale(LC_ALL, "en_US.utf8"); + // $name = \iconv("utf-8", "ascii//TRANSLIT", $name); + // $memberSince = \iconv("utf-8", "ascii//TRANSLIT", $memberSince); + // $githubName = \iconv("utf-8", "ascii//TRANSLIT", $githubName); + + $text = new ImagickDraw(); + $text->setTextAlignment(Imagick::ALIGN_CENTER); + $text->setFont($this->getAppRoot() . '/public/fonts/Inter-Bold.ttf'); + $text->setFillColor(new ImagickPixel('#FFFFFF')); + + if (\strlen($name) > 32) { + $name = \substr($name, 0, 32); + } + + if (\strlen($name) <= 23) { + $text->setFontSize(80); + $scalingDown = false; + } else { + $text->setFontSize(54); + $scalingDown = true; + } + $text->setFontWeight(700); + $baseImage->annotateImage($text, 512, 477, 0, $name); + + $text = new ImagickDraw(); + $text->setTextAlignment(Imagick::ALIGN_CENTER); + $text->setFont($this->getAppRoot() . '/public/fonts/Inter-SemiBold.ttf'); + $text->setFillColor(new ImagickPixel($isGolden || $isPlatinum ? '#FFFFFF' : '#FFB9CC')); + $text->setFontSize(27); + $text->setFontWeight(600); + $text->setTextKerning(1.08); + $baseImage->annotateImage($text, 512, 541, 0, \strtoupper($memberSince)); + + if (!empty($githubName)) { + $text = new ImagickDraw(); + $text->setTextAlignment(Imagick::ALIGN_CENTER); + $text->setFont($this->getAppRoot() . '/public/fonts/Inter-Regular.ttf'); + $text->setFillColor(new ImagickPixel('#FFFFFF')); + $text->setFontSize($scalingDown ? 28 : 32); + $text->setFontWeight(400); + $metrics = $baseImage->queryFontMetrics($text, $githubName); + + $baseImage->annotateImage($text, 512 + 20 + 4, 373 + ($scalingDown ? 2 : 0), 0, $githubName); + + $image = new Imagick($this->getAppRoot() . '/public/images/cards/cloud/github.png'); + $image->setGravity(Imagick::GRAVITY_CENTER); + $precisionFix = 5; + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 512 - ($metrics['textWidth'] / 2) - 20 - 4, 373 - ($metrics['textHeight'] - $precisionFix)); + } + + if (!empty($width) || !empty($height)) { + $baseImage->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1); + } + + $response + ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days + ->setContentType('image/png') + ->file($baseImage->getImageBlob()); + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php new file mode 100644 index 0000000000..f7c983db78 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Cards/Cloud/OG/Get.php @@ -0,0 +1,427 @@ +setHttpMethod(UtopiaAction::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/cards/cloud-og') + ->desc('Get OG image From Cloud Card') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.read') + ->label('cache', true) + ->label('cache.resourceType', 'cards/cloud-og') + ->label('cache.resource', 'card-og/{request.userId}') + ->label('docs', false) + ->label('origin', '*') + ->param('userId', '', new UID(), 'User ID.', true) + ->param('mock', '', new WhiteList(['employee', 'employee-2digit', 'hero', 'contributor', 'normal', 'platinum', 'normal-no-github', 'normal-long', 'normal-long-right', 'normal-long-middle', 'normal-bg2', 'normal-bg3', 'normal-right', 'normal-middle', 'platinum-right', 'platinum-middle', 'hero-middle', 'hero-right', 'contributor-right', 'employee-right', 'contributor-middle', 'employee-middle', 'employee-2digit-middle', 'employee-2digit-right']), 'Mocking behaviour.', true) + ->param('width', 0, new Range(0, 1024), 'Resize image card width, Pass an integer between 0 to 1024.', true) + ->param('height', 0, new Range(0, 1024), 'Resize image card height, Pass an integer between 0 to 1024.', true) + ->inject('user') + ->inject('project') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('response') + ->inject('heroes') + ->inject('contributors') + ->inject('employees') + ->inject('logger') + ->callback($this->action(...)); + } + + public function action(string $userId, string $mock, int $width, int $height, Document $user, Document $project, Database $dbForProject, Database $dbForPlatform, Response $response, array $heroes, array $contributors, array $employees, ?Logger $logger) + { + $user = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $userId)); + + if ($user->isEmpty() && empty($mock)) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + if (!$mock) { + $sequence = $user->getSequence(); + $bgVariation = $sequence % 3 === 0 ? '1' : ($sequence % 3 === 1 ? '2' : '3'); + $cardVariation = $sequence % 3 === 0 ? '1' : ($sequence % 3 === 1 ? '2' : '3'); + + $name = $user->getAttribute('name', 'Anonymous'); + $email = $user->getAttribute('email', ''); + $createdAt = new \DateTime($user->getCreatedAt()); + + $gitHub = $this->getUserGitHub($user->getId(), $project, $dbForProject, $dbForPlatform, $logger); + $githubName = $gitHub['name'] ?? ''; + $githubId = $gitHub['id'] ?? ''; + + $isHero = \array_key_exists($email, $heroes); + $isContributor = \in_array($githubId, $contributors); + $isEmployee = \array_key_exists($email, $employees); + $employeeNumber = $isEmployee ? $employees[$email]['spot'] : ''; + + if ($isHero) { + $createdAt = new \DateTime($heroes[$email]['memberSince'] ?? ''); + } elseif ($isEmployee) { + $createdAt = new \DateTime($employees[$email]['memberSince'] ?? ''); + } + + if (!$isEmployee && !empty($githubName)) { + $employeeGitHub = \array_search(\strtolower($githubName), \array_map(fn ($employee) => \strtolower($employee['gitHub']) ?? '', $employees)); + if (!empty($employeeGitHub)) { + $isEmployee = true; + $employeeNumber = $isEmployee ? $employees[$employeeGitHub]['spot'] : ''; + $createdAt = new \DateTime($employees[$employeeGitHub]['memberSince'] ?? ''); + } + } + + $isPlatinum = $user->getSequence() % 100 === 0; + } else { + $bgVariation = \str_ends_with($mock, '-bg2') ? '2' : (\str_ends_with($mock, '-bg3') ? '3' : '1'); + $cardVariation = \str_ends_with($mock, '-right') ? '2' : (\str_ends_with($mock, '-middle') ? '3' : '1'); + $name = \str_starts_with($mock, 'normal-long') ? 'Sir First Walter O\'Brian Junior' : 'Walter O\'Brian'; + $createdAt = new \DateTime('now'); + $githubName = $mock === 'normal-no-github' ? '' : (\str_starts_with($mock, 'normal-long') ? 'sir-first-walterobrian-junior' : 'walterobrian'); + $isHero = \str_starts_with($mock, 'hero'); + $isContributor = \str_starts_with($mock, 'contributor'); + $isEmployee = \str_starts_with($mock, 'employee'); + $employeeNumber = match ($mock) { + 'employee' => '1', + 'employee-right' => '1', + 'employee-middle' => '1', + 'employee-2digit' => '18', + 'employee-2digit-right' => '18', + 'employee-2digit-middle' => '18', + default => '' + }; + + $isPlatinum = \str_starts_with($mock, 'platinum'); + } + + if ($isEmployee) { + $isContributor = false; + $isHero = false; + } + + if ($isHero) { + $isContributor = false; + $isEmployee = false; + } + + if ($isContributor) { + $isHero = false; + $isEmployee = false; + } + + $isGolden = $isEmployee || $isHero || $isContributor; + $isPlatinum = $isGolden ? false : $isPlatinum; + $memberSince = \strtoupper('Member since ' . $createdAt->format('M') . ' ' . $createdAt->format('d') . ', ' . $createdAt->format('o')); + + $baseImage = new Imagick($this->getAppRoot() . "/public/images/cards/cloud/og-background{$bgVariation}.png"); + + $cardType = $isGolden ? '-golden' : ($isPlatinum ? '-platinum' : ''); + + $image = new Imagick($this->getAppRoot() . "/public/images/cards/cloud/og-card{$cardType}{$cardVariation}.png"); + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 1008 / 2 - $image->getImageWidth() / 2, 1008 / 2 - $image->getImageHeight() / 2); + + $imageLogo = new Imagick($this->getAppRoot() . '/public/images/cards/cloud/og-background-logo.png'); + $imageShadow = new Imagick($this->getAppRoot() . "/public/images/cards/cloud/og-shadow{$cardType}.png"); + if ($cardVariation === '1') { + $baseImage->compositeImage($imageLogo, Imagick::COMPOSITE_OVER, 32, 1008 - $imageLogo->getImageHeight() - 32); + $baseImage->compositeImage($imageShadow, Imagick::COMPOSITE_OVER, -450, 700); + } elseif ($cardVariation === '2') { + $baseImage->compositeImage($imageLogo, Imagick::COMPOSITE_OVER, 1008 - $imageLogo->getImageWidth() - 32, 1008 - $imageLogo->getImageHeight() - 32); + $baseImage->compositeImage($imageShadow, Imagick::COMPOSITE_OVER, -20, 710); + } else { + $baseImage->compositeImage($imageLogo, Imagick::COMPOSITE_OVER, 1008 - $imageLogo->getImageWidth() - 32, 1008 - $imageLogo->getImageHeight() - 32); + $baseImage->compositeImage($imageShadow, Imagick::COMPOSITE_OVER, -135, 710); + } + + if ($isEmployee) { + $file = $cardVariation === '3' ? 'employee-skew.png' : 'employee.png'; + $image = new Imagick($this->getAppRoot() . '/public/images/cards/cloud/' . $file); + $image->setGravity(Imagick::GRAVITY_CENTER); + + $hashtag = new ImagickDraw(); + $hashtag->setTextAlignment(Imagick::ALIGN_LEFT); + $hashtag->setFont($this->getAppRoot() . '/public/fonts/Inter-Bold.ttf'); + $hashtag->setFillColor(new ImagickPixel('#FFFADF')); + $hashtag->setFontSize(20); + $hashtag->setFontWeight(700); + + $text = new ImagickDraw(); + $text->setTextAlignment(Imagick::ALIGN_LEFT); + $text->setFont($this->getAppRoot() . '/public/fonts/Inter-Bold.ttf'); + $text->setFillColor(new ImagickPixel('#FFFADF')); + $text->setFontSize(\strlen($employeeNumber) <= 1 ? 36 : 28); + $text->setFontWeight(700); + + if ($cardVariation === '3') { + $hashtag->setFontSize(16); + $text->setFontSize(\strlen($employeeNumber) <= 1 ? 30 : 26); + + $hashtag->skewY(20); + $hashtag->skewX(20); + $text->skewY(20); + $text->skewX(20); + } + + $metricsHashtag = $baseImage->queryFontMetrics($hashtag, '#'); + $metricsText = $baseImage->queryFontMetrics($text, $employeeNumber); + + $group = new Imagick(); + $groupWidth = $metricsHashtag['textWidth'] + 6 + $metricsText['textWidth']; + + if ($cardVariation === '1') { + $group->newImage($groupWidth, $metricsText['textHeight'], '#00000000'); + $group->annotateImage($hashtag, 0, $metricsText['textHeight'], 0, '#'); + $group->annotateImage($text, $metricsHashtag['textWidth'] + 6, $metricsText['textHeight'], 0, $employeeNumber); + + $image->resizeImage(120, 120, Imagick::FILTER_LANCZOS, 1); + $image->rotateImage(new ImagickPixel('#00000000'), -20); + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 612, 203); + + $group->rotateImage(new ImagickPixel('#00000000'), -22); + + if (\strlen($employeeNumber) <= 1) { + $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, 660, 245); + } else { + $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, 655, 247); + } + } elseif ($cardVariation === '2') { + $group->newImage($groupWidth, $metricsText['textHeight'], '#00000000'); + $group->annotateImage($hashtag, 0, $metricsText['textHeight'], 0, '#'); + $group->annotateImage($text, $metricsHashtag['textWidth'] + 6, $metricsText['textHeight'], 0, $employeeNumber); + + $image->resizeImage(120, 120, Imagick::FILTER_LANCZOS, 1); + $image->rotateImage(new ImagickPixel('#00000000'), 30); + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 715, 425); + + $group->rotateImage(new ImagickPixel('#00000000'), 32); + + if (\strlen($employeeNumber) <= 1) { + $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, 775, 465); + } else { + $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, 767, 470); + } + } else { + $group->newImage(300, 300, '#00000000'); + + $hashtag->annotation(0, $metricsText['textHeight'], '#'); + $text->annotation($metricsHashtag['textWidth'] + 2, $metricsText['textHeight'], $employeeNumber); + + $group->drawImage($hashtag); + $group->drawImage($text); + + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 640, 293); + + if (\strlen($employeeNumber) <= 1) { + $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, 670, 317); + } else { + $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, 663, 322); + } + } + } + + if ($isContributor) { + $file = $cardVariation === '3' ? 'contributor-skew.png' : 'contributor.png'; + $image = new Imagick($this->getAppRoot() . '/public/images/cards/cloud/' . $file); + $image->setGravity(Imagick::GRAVITY_CENTER); + + if ($cardVariation === '1') { + $image->resizeImage(120, 120, Imagick::FILTER_LANCZOS, 1); + $image->rotateImage(new ImagickPixel('#00000000'), -20); + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 612, 203); + } elseif ($cardVariation === '2') { + $image->resizeImage(120, 120, Imagick::FILTER_LANCZOS, 1); + $image->rotateImage(new ImagickPixel('#00000000'), 30); + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 715, 425); + } else { + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 640, 293); + } + } + + if ($isHero) { + $file = $cardVariation === '3' ? 'hero-skew.png' : 'hero.png'; + $image = new Imagick($this->getAppRoot() . '/public/images/cards/cloud/' . $file); + $image->setGravity(Imagick::GRAVITY_CENTER); + + if ($cardVariation === '1') { + $image->resizeImage(120, 120, Imagick::FILTER_LANCZOS, 1); + $image->rotateImage(new ImagickPixel('#00000000'), -20); + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 612, 203); + } elseif ($cardVariation === '2') { + $image->resizeImage(120, 120, Imagick::FILTER_LANCZOS, 1); + $image->rotateImage(new ImagickPixel('#00000000'), 30); + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 715, 425); + } else { + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 640, 293); + } + } + + setlocale(LC_ALL, "en_US.utf8"); + // $name = \iconv("utf-8", "ascii//TRANSLIT", $name); + // $memberSince = \iconv("utf-8", "ascii//TRANSLIT", $memberSince); + // $githubName = \iconv("utf-8", "ascii//TRANSLIT", $githubName); + + $textName = new ImagickDraw(); + $textName->setTextAlignment(Imagick::ALIGN_CENTER); + $textName->setFont($this->getAppRoot() . '/public/fonts/Inter-Bold.ttf'); + $textName->setFillColor(new ImagickPixel('#FFFFFF')); + + if (\strlen($name) > 32) { + $name = \substr($name, 0, 32); + } + + if ($cardVariation === '1') { + if (\strlen($name) <= 23) { + $scalingDown = false; + $textName->setFontSize(54); + } else { + $scalingDown = true; + $textName->setFontSize(36); + } + } elseif ($cardVariation === '2') { + if (\strlen($name) <= 23) { + $scalingDown = false; + $textName->setFontSize(50); + } else { + $scalingDown = true; + $textName->setFontSize(34); + } + } else { + if (\strlen($name) <= 23) { + $scalingDown = false; + $textName->setFontSize(44); + } else { + $scalingDown = true; + $textName->setFontSize(32); + } + } + + $textName->setFontWeight(700); + + $textMember = new ImagickDraw(); + $textMember->setTextAlignment(Imagick::ALIGN_CENTER); + $textMember->setFont($this->getAppRoot() . '/public/fonts/Inter-Medium.ttf'); + $textMember->setFillColor(new ImagickPixel($isGolden || $isPlatinum ? '#FFFFFF' : '#FFB9CC')); + $textMember->setFontWeight(500); + $textMember->setTextKerning(1.12); + + if ($cardVariation === '1') { + $textMember->setFontSize(21); + + $baseImage->annotateImage($textName, 550, 600, -22, $name); + $baseImage->annotateImage($textMember, 585, 635, -22, $memberSince); + } elseif ($cardVariation === '2') { + $textMember->setFontSize(20); + + $baseImage->annotateImage($textName, 435, 590, 31.37, $name); + $baseImage->annotateImage($textMember, 412, 628, 31.37, $memberSince); + } else { + $textMember->setFontSize(16); + + $textName->skewY(20); + $textName->skewX(20); + $textName->annotation(320, 700, $name); + + $textMember->skewY(20); + $textMember->skewX(20); + $textMember->annotation(330, 735, $memberSince); + + $baseImage->drawImage($textName); + $baseImage->drawImage($textMember); + } + + if (!empty($githubName)) { + $text = new ImagickDraw(); + $text->setTextAlignment(Imagick::ALIGN_LEFT); + $text->setFont($this->getAppRoot() . '/public/fonts/Inter-Regular.ttf'); + $text->setFillColor(new ImagickPixel('#FFFFFF')); + $text->setFontSize($scalingDown ? 16 : 20); + $text->setFontWeight(400); + + if ($cardVariation === '1') { + $metrics = $baseImage->queryFontMetrics($text, $githubName); + + $group = new Imagick(); + $groupWidth = $metrics['textWidth'] + 32 + 4; + $group->newImage($groupWidth, $metrics['textHeight'] + 10, '#00000000'); + $image = new Imagick($this->getAppRoot() . '/public/images/cards/cloud/github.png'); + $image->setGravity(Imagick::GRAVITY_CENTER); + $image->resizeImage(32, 32, Imagick::FILTER_LANCZOS, 1); + $precisionFix = -1; + + $group->compositeImage($image, Imagick::COMPOSITE_OVER, 0, 0); + $group->annotateImage($text, 32 + 4, $metrics['textHeight'] - $precisionFix, 0, $githubName); + + $group->rotateImage(new ImagickPixel('#00000000'), -22); + $x = 510 - $group->getImageWidth() / 2; + $y = 530 - $group->getImageHeight() / 2; + $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, $x, $y); + } elseif ($cardVariation === '2') { + $metrics = $baseImage->queryFontMetrics($text, $githubName); + + $group = new Imagick(); + $groupWidth = $metrics['textWidth'] + 32 + 4; + $group->newImage($groupWidth, $metrics['textHeight'] + 10, '#00000000'); + $image = new Imagick($this->getAppRoot() . '/public/images/cards/cloud/github.png'); + $image->setGravity(Imagick::GRAVITY_CENTER); + $image->resizeImage(32, 32, Imagick::FILTER_LANCZOS, 1); + $precisionFix = -1; + + $group->compositeImage($image, Imagick::COMPOSITE_OVER, 0, 0); + $group->annotateImage($text, 32 + 4, $metrics['textHeight'] - $precisionFix, 0, $githubName); + + $group->rotateImage(new ImagickPixel('#00000000'), 31.11); + $x = 485 - $group->getImageWidth() / 2; + $y = 530 - $group->getImageHeight() / 2; + $baseImage->compositeImage($group, Imagick::COMPOSITE_OVER, $x, $y); + } else { + $text->skewY(20); + $text->skewX(20); + $text->setTextAlignment(Imagick::ALIGN_CENTER); + + $text->annotation(320 + 15 + 2, 640, $githubName); + $metrics = $baseImage->queryFontMetrics($text, $githubName); + + $image = new Imagick($this->getAppRoot() . '/public/images/cards/cloud/github-skew.png'); + $image->setGravity(Imagick::GRAVITY_CENTER); + $baseImage->compositeImage($image, Imagick::COMPOSITE_OVER, 512 - ($metrics['textWidth'] / 2), 518 + \strlen($githubName) * 1.3); + + $baseImage->drawImage($text); + } + } + + if (!empty($width) || !empty($height)) { + $baseImage->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1); + } + + $response + ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days + ->setContentType('image/png') + ->file($baseImage->getImageBlob()); + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/CreditCards/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/CreditCards/Get.php new file mode 100644 index 0000000000..5d3429b377 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Http/CreditCards/Get.php @@ -0,0 +1,64 @@ +setHttpMethod(UtopiaAction::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/avatars/credit-cards/:code') + ->desc('Get credit card icon') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.read') + ->label('cache', true) + ->label('cache.resource', 'avatar/credit-card') + ->label('sdk', new Method( + namespace: 'avatars', + group: null, + name: 'getCreditCard', + description: '/docs/references/avatars/get-credit-card.md', + auth: [AuthType::ADMIN, 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('code', '', new WhiteList(\array_keys(Config::getParam('avatar-credit-cards'))), 'Credit Card Code. Possible values: ' . \implode(', ', \array_keys(Config::getParam('avatar-credit-cards'))) . '.') + ->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) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(string $code, int $width, int $height, int $quality, Response $response) + { + $this->avatarCallback('credit-cards', $code, $width, $height, $quality, $response); + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php new file mode 100644 index 0000000000..0a4d652d0e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php @@ -0,0 +1,216 @@ +setHttpMethod(UtopiaAction::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/avatars/favicon') + ->desc('Get favicon') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.read') + ->label('cache', true) + ->label('cache.resource', 'avatar/favicon') + ->label('sdk', new Method( + namespace: 'avatars', + group: null, + name: 'getFavicon', + description: '/docs/references/avatars/get-favicon.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + type: MethodType::LOCATION, + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::IMAGE + )) + ->param('url', '', new URL(['http', 'https']), 'Website URL which you want to fetch the favicon from.') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(string $url, Response $response) + { + $width = 56; + $height = 56; + $quality = 80; + $output = 'png'; + $type = 'png'; + + if (!\extension_loaded('imagick')) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); + } + + $domain = new Domain(\parse_url($url, PHP_URL_HOST)); + + if (!$domain->isKnown()) { + throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); + } + + $client = new Client(); + try { + $res = $client + ->setAllowRedirects(true) + ->setMaxRedirects(5) + ->setUserAgent(\sprintf( + APP_USERAGENT, + System::getEnv('_APP_VERSION', 'UNKNOWN'), + System::getEnv('_APP_EMAIL_SECURITY', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS', APP_EMAIL_SECURITY)) + )) + ->fetch($url); + } catch (\Throwable) { + throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); + } + + $doc = new DOMDocument(); + $doc->strictErrorChecking = false; + @$doc->loadHTML($res->getBody()); + + $links = $doc->getElementsByTagName('link') ?? []; + $outputHref = ''; + $outputExt = ''; + $space = 0; + + foreach ($links as $link) { /* @var $link DOMElement */ + $href = $link->getAttribute('href'); + $rel = $link->getAttribute('rel'); + $sizes = $link->getAttribute('sizes'); + $absolute = URLParse::unparse(\array_merge(\parse_url($url), \parse_url($href))); + + switch (\strtolower($rel)) { + case 'icon': + case 'shortcut icon': + //case 'apple-touch-icon': + $ext = \pathinfo(\parse_url($absolute, PHP_URL_PATH), PATHINFO_EXTENSION); + + switch ($ext) { + case 'svg': + // SVG icons are prioritized by assigning the maximum possible value. + $space = PHP_INT_MAX; + $outputHref = $absolute; + $outputExt = $ext; + break; + case 'ico': + case 'png': + case 'jpg': + case 'jpeg': + $size = \explode('x', \strtolower($sizes)); + + $sizeWidth = (int) ($size[0] ?? 0); + $sizeHeight = (int) ($size[1] ?? 0); + + if (($sizeWidth * $sizeHeight) >= $space) { + $space = $sizeWidth * $sizeHeight; + $outputHref = $absolute; + $outputExt = $ext; + } + + break; + } + + break; + } + } + + if (empty($outputHref) || empty($outputExt)) { + $default = \parse_url($url); + + $outputHref = $default['scheme'] . '://' . $default['host'] . '/favicon.ico'; + $outputExt = 'ico'; + } + + $domain = new Domain(\parse_url($outputHref, PHP_URL_HOST)); + + if (!$domain->isKnown()) { + throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); + } + + $client = new Client(); + try { + $res = $client + ->setAllowRedirects(true) + ->setMaxRedirects(5) + ->fetch($outputHref); + } catch (\Throwable) { + throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); + } + + if ($res->getStatusCode() !== 200) { + throw new Exception(Exception::AVATAR_ICON_NOT_FOUND); + } + + $data = $res->getBody(); + + if ('ico' === $outputExt) { // Skip crop, Imagick isn\'t supporting icon files + if ( + empty($data) || + stripos($data, 'addHeader('Cache-Control', 'private, max-age=2592000') // 30 days + ->setContentType('image/x-icon') + ->file($data); + return; + } + + if ('svg' === $outputExt) { // Skip crop, Imagick isn\'t supporting svg files + $sanitizer = new SvgSanitizer(); + $sanitizer->minify(true); + $cleanSvg = $sanitizer->sanitize($data); + if ($cleanSvg === false) { + throw new Exception(Exception::AVATAR_SVG_SANITIZATION_FAILED); + } + $response + ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days + ->setContentType('image/svg+xml') + ->file($cleanSvg); + return; + } + + $image = new Image($data); + $image->crop((int) $width, (int) $height); + $output = (empty($output)) ? $type : $output; + $data = $image->output($output, $quality); + + $response + ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days + ->setContentType('image/png') + ->file($data); + unset($image); + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Flags/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Flags/Get.php new file mode 100644 index 0000000000..c3960c134e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Flags/Get.php @@ -0,0 +1,64 @@ +setHttpMethod(UtopiaAction::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/avatars/flags/:code') + ->desc('Get country flag') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.read') + ->label('cache', true) + ->label('cache.resource', 'avatar/flag') + ->label('sdk', new Method( + namespace: 'avatars', + group: null, + name: 'getFlag', + description: '/docs/references/avatars/get-flag.md', + auth: [AuthType::ADMIN, 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('code', '', new WhiteList(\array_keys(Config::getParam('avatar-flags'))), 'Country Code. ISO Alpha-2 country code format.') + ->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) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(string $code, int $width, int $height, int $quality, Response $response) + { + $this->avatarCallback('flags', $code, $width, $height, $quality, $response); + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php new file mode 100644 index 0000000000..eb56ddf0b2 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php @@ -0,0 +1,107 @@ +setHttpMethod(UtopiaAction::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/avatars/image') + ->desc('Get image from URL') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.read') + ->label('cache', true) + ->label('cache.resource', 'avatar/image') + ->label('sdk', new Method( + namespace: 'avatars', + group: null, + name: 'getImage', + description: '/docs/references/avatars/get-image.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + type: MethodType::LOCATION, + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_NONE, + ) + ], + contentType: ContentType::IMAGE + )) + ->param('url', '', new URL(['http', 'https']), 'Image URL which you want to crop.') + ->param('width', 400, new Range(0, 2000), 'Resize preview image width, Pass an integer between 0 to 2000. Defaults to 400.', true) + ->param('height', 400, new Range(0, 2000), 'Resize preview image height, Pass an integer between 0 to 2000. Defaults to 400.', true) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(string $url, int $width, int $height, Response $response) + { + $quality = 80; + $output = 'png'; + $type = 'png'; + + if (!\extension_loaded('imagick')) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); + } + + $domain = new Domain(\parse_url($url, PHP_URL_HOST)); + + if (!$domain->isKnown()) { + throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); + } + + $client = new Client(); + try { + $res = $client + ->setAllowRedirects(false) + ->fetch($url); + } catch (\Throwable) { + throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); + } + + if ($res->getStatusCode() !== 200) { + throw new Exception(Exception::AVATAR_IMAGE_NOT_FOUND); + } + + try { + $image = new Image($res->getBody()); + } catch (\Throwable $exception) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unable to parse image'); + } + + $image->crop((int) $width, (int) $height); + $output = (empty($output)) ? $type : $output; + $data = $image->output($output, $quality); + + $response + ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days + ->setContentType('image/png') + ->file($data); + unset($image); + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Initials/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Initials/Get.php new file mode 100644 index 0000000000..8278a43ea3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Initials/Get.php @@ -0,0 +1,127 @@ +setHttpMethod(UtopiaAction::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/avatars/initials') + ->desc('Get user initials') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.read') + ->label('cache.resource', 'avatar/initials') + ->label('sdk', new Method( + namespace: 'avatars', + group: null, + name: 'getInitials', + description: '/docs/references/avatars/get-initials.md', + auth: [AuthType::ADMIN, 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('name', '', new Text(128), 'Full Name. When empty, current user name or email will be used. Max length: 128 chars.', true) + ->param('width', 500, new Range(0, 2000), 'Image width. Pass an integer between 0 to 2000. Defaults to 100.', true) + ->param('height', 500, new Range(0, 2000), 'Image height. Pass an integer between 0 to 2000. Defaults to 100.', true) + ->param('background', '', new HexColor(), 'Changes background color. By default a random color will be picked and stay will persistent to the given name.', true) + ->inject('response') + ->inject('user') + ->callback($this->action(...)); + } + + public function action(string $name, int $width, int $height, string $background, Response $response, Document $user) + { + $themes = [ + ['background' => '#FD366E'], // Default (Pink) + ['background' => '#FE9567'], // Orange + ['background' => '#7C67FE'], // Purple + ['background' => '#68A3FE'], // Blue + ['background' => '#85DBD8'], // Mint + ]; + + $name = (!empty($name)) ? $name : $user->getAttribute('name', $user->getAttribute('email', '')); + $words = \explode(' ', \strtoupper($name)); + // if there is no space, try to split by `_` underscore + $words = (count($words) == 1) ? \explode('_', \strtoupper($name)) : $words; + + $initials = ''; + $code = 0; + + foreach ($words as $key => $w) { + if (ctype_alnum($w[0] ?? '')) { + $initials .= $w[0]; + $code += ord($w[0]); + + if ($key == 1) { + break; + } + } + } + + $rand = \substr($code, -1); + + $rand = ($rand > \count($themes) - 1) ? $rand % \count($themes) : $rand; + + $background = (!empty($background)) ? '#' . $background : $themes[$rand]['background']; + + $image = new Imagick(); + $punch = new Imagick(); + $draw = new ImagickDraw(); + $fontSize = \min($width, $height) / 2; + + $punch->newImage($width, $height, 'transparent'); + + $draw->setFont($this->getAppRoot() . '/app/assets/fonts/inter-v8-latin-regular.woff2'); + $image->setFont($this->getAppRoot() . '/app/assets/fonts/inter-v8-latin-regular.woff2'); + + $draw->setFillColor(new ImagickPixel('black')); + $draw->setFontSize($fontSize); + + $draw->setTextAlignment(Imagick::ALIGN_CENTER); + $draw->annotation($width / 1.97, ($height / 2) + ($fontSize / 3), $initials); + + $punch->drawImage($draw); + $punch->negateImage(true, Imagick::CHANNEL_ALPHA); + + $image->newImage($width, $height, $background); + $image->setImageFormat("png"); + $image->compositeImage($punch, Imagick::COMPOSITE_COPYOPACITY, 0, 0); + + $response + ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days + ->setContentType('image/png') + ->file($image->getImageBlob()); + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php new file mode 100644 index 0000000000..27fd8708d9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Http/QR/Get.php @@ -0,0 +1,85 @@ +setHttpMethod(UtopiaAction::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/avatars/qr') + ->desc('Get QR code') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.read') + ->label('sdk', new Method( + namespace: 'avatars', + group: null, + name: 'getQR', + description: '/docs/references/avatars/get-qr.md', + auth: [AuthType::ADMIN, 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('text', '', new Text(512), 'Plain text to be converted to QR code image.') + ->param('size', 400, new Range(1, 1000), 'QR code size. Pass an integer between 1 to 1000. Defaults to 400.', true) + ->param('margin', 1, new Range(0, 10), 'Margin from edge. Pass an integer between 0 to 10. Defaults to 1.', true) + ->param('download', false, new Boolean(true), 'Return resulting image with \'Content-Disposition: attachment \' headers for the browser to start downloading it. Pass 0 for no header, or 1 for otherwise. Default value is set to 0.', true) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(string $text, int $size, int $margin, bool $download, Response $response) + { + $download = ($download === '1' || $download === 'true' || $download === 1 || $download === true); + $options = new QROptions([ + 'addQuietzone' => true, + 'quietzoneSize' => $margin, + 'outputType' => QRCode::OUTPUT_IMAGICK, + 'scale' => 15, + ]); + + $qrcode = new QRCode($options); + + if ($download) { + $response->addHeader('Content-Disposition', 'attachment; filename="qr.png"'); + } + + $image = new Image($qrcode->render($text)); + $image->crop((int) $size, (int) $size); + + $response + ->addHeader('Cache-Control', 'private, max-age=3888000') // 45 days + ->setContentType('image/png') + ->send($image->output('png', 90)); + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php new file mode 100644 index 0000000000..b6fd354ee3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php @@ -0,0 +1,225 @@ +setHttpMethod(UtopiaAction::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/avatars/screenshots') + ->desc('Get webpage screenshot') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.read') + ->label('usage.metric', METRIC_AVATARS_SCREENSHOTS_GENERATED) + ->label('abuse-limit', 60) + ->label('cache', true) + ->label('cache.resourceType', 'avatar/screenshot') + ->label('cache.resource', 'screenshot/{request.url}/{request.width}/{request.height}/{request.scale}/{request.theme}/{request.userAgent}/{request.fullpage}/{request.locale}/{request.timezone}/{request.latitude}/{request.longitude}/{request.accuracy}/{request.touch}/{request.permissions}/{request.sleep}/{request.quality}/{request.output}') + ->label('sdk', new Method( + namespace: 'avatars', + group: null, + name: 'getScreenshot', + description: '/docs/references/avatars/get-screenshot.md', + auth: [AuthType::ADMIN, 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('url', '', new URL(['http', 'https']), 'Website URL which you want to capture.', example: 'https://example.com') + ->param('headers', [], new Assoc(), 'HTTP headers to send with the browser request. Defaults to empty.', true, example: '{"Authorization":"Bearer token123","X-Custom-Header":"value"}') + ->param('viewportWidth', 1280, new Range(1, 1920), 'Browser viewport width. Pass an integer between 1 to 1920. Defaults to 1280.', true, example: '1920') + ->param('viewportHeight', 720, new Range(1, 1080), 'Browser viewport height. Pass an integer between 1 to 1080. Defaults to 720.', true, example: '1080') + ->param('scale', 1, new Range(0.1, 3, Range::TYPE_FLOAT), 'Browser scale factor. Pass a number between 0.1 to 3. Defaults to 1.', true, example: '2') + ->param('theme', 'light', new WhiteList(['light', 'dark']), 'Browser theme. Pass "light" or "dark". Defaults to "light".', true, example: 'dark') + ->param('userAgent', '', new Text(512), 'Custom user agent string. Defaults to browser default.', true, example: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15') + ->param('fullpage', false, new Boolean(true), 'Capture full page scroll. Pass 0 for viewport only, or 1 for full page. Defaults to 0.', true, example: 'true') + ->param('locale', '', new Text(10), 'Browser locale (e.g., "en-US", "fr-FR"). Defaults to browser default.', true, example: 'en-US') + ->param('timezone', '', new WhiteList(timezone_identifiers_list()), 'IANA timezone identifier (e.g., "America/New_York", "Europe/London"). Defaults to browser default.', true, example: 'america/new_york') + ->param('latitude', 0, new Range(-90, 90, Range::TYPE_FLOAT), 'Geolocation latitude. Pass a number between -90 to 90. Defaults to 0.', true, example: '37.7749') + ->param('longitude', 0, new Range(-180, 180, Range::TYPE_FLOAT), 'Geolocation longitude. Pass a number between -180 to 180. Defaults to 0.', true, example: '-122.4194') + ->param('accuracy', 0, new Range(0, 100000, Range::TYPE_FLOAT), 'Geolocation accuracy in meters. Pass a number between 0 to 100000. Defaults to 0.', true, example: '100') + ->param('touch', false, new Boolean(true), 'Enable touch support. Pass 0 for no touch, or 1 for touch enabled. Defaults to 0.', true, example: 'true') + ->param('permissions', [], new ArrayList(new WhiteList(['geolocation', 'camera', 'microphone', 'notifications', 'midi', 'push', 'clipboard-read', 'clipboard-write', 'payment-handler', 'usb', 'bluetooth', 'accelerometer', 'gyroscope', 'magnetometer', 'ambient-light-sensor', 'background-sync', 'persistent-storage', 'screen-wake-lock', 'web-share', 'xr-spatial-tracking'])), 'Browser permissions to grant. Pass an array of permission names like ["geolocation", "camera", "microphone"]. Defaults to empty.', true, example: '["geolocation","notifications"]') + ->param('sleep', 0, new Range(0, 10), 'Wait time in seconds before taking the screenshot. Pass an integer between 0 to 10. Defaults to 0.', true, example: '3') + ->param('width', 0, new Range(0, 2000), 'Output image width. Pass 0 to use original width, or an integer between 1 to 2000. Defaults to 0 (original width).', true, example: '800') + ->param('height', 0, new Range(0, 2000), 'Output image height. Pass 0 to use original height, or an integer between 1 to 2000. Defaults to 0 (original height).', true, example: '600') + ->param('quality', -1, new Range(-1, 100), 'Screenshot quality. Pass an integer between 0 to 100. Defaults to keep existing image quality.', true, example: '85') + ->param('output', '', new WhiteList(\array_keys(Config::getParam('storage-outputs')), true), 'Output format type (jpeg, jpg, png, gif and webp).', true, example: 'jpeg') + ->inject('response') + ->inject('queueForStatsUsage') + ->callback($this->action(...)); + } + + public function action(string $url, array $headers, int $viewportWidth, int $viewportHeight, float $scale, string $theme, string $userAgent, bool $fullpage, string $locale, string $timezone, float $latitude, float $longitude, float $accuracy, bool $touch, array $permissions, int $sleep, int $width, int $height, int $quality, string $output, Response $response, StatsUsage $queueForStatsUsage) + { + if (!\extension_loaded('imagick')) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); + } + + $domain = new Domain(\parse_url($url, PHP_URL_HOST)); + + if (!$domain->isKnown()) { + throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); + } + + $client = new Client(); + $client->setTimeout(30 * 1000); // 30 seconds + $client->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON); + + // Convert indexed array to empty array (should not happen due to Assoc validator) + if (is_array($headers) && count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) { + $headers = []; + } + + // Create a new object to ensure proper JSON serialization + $headersObject = new \stdClass(); + foreach ($headers as $key => $value) { + $headersObject->$key = $value; + } + + // Create the config with headers as an object + // The custom browser service accepts: url, theme, headers, sleep, viewport, userAgent, fullPage, locale, timezoneId, geolocation, hasTouch, scale + $config = [ + 'url' => $url, + 'theme' => $theme, + 'headers' => $headersObject, + 'sleep' => $sleep * 1000, // Convert seconds to milliseconds + 'waitUntil' => 'load', + 'viewport' => [ + 'width' => $viewportWidth, + 'height' => $viewportHeight + ] + ]; + + // Add scale if not default + if ($scale != 1) { + $config['deviceScaleFactor'] = $scale; + } + + // Add optional parameters that were set, preserving arrays as arrays + if (!empty($userAgent)) { + $config['userAgent'] = $userAgent; + } + + if ($fullpage) { + $config['fullPage'] = true; + } + + if (!empty($locale)) { + $config['locale'] = $locale; + } + + if (!empty($timezone)) { + $config['timezoneId'] = $timezone; + } + + // Add geolocation if any coordinates are provided + if ($latitude != 0 || $longitude != 0) { + $config['geolocation'] = [ + 'latitude' => $latitude, + 'longitude' => $longitude, + 'accuracy' => $accuracy + ]; + } + + if ($touch) { + $config['hasTouch'] = true; + } + + // Add permissions if provided (preserve as array) + if (!empty($permissions)) { + $config['permissions'] = $permissions; // Keep as array + } + + try { + $browserEndpoint = System::getEnv('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1'); + + $fetchResponse = $client->fetch( + url: $browserEndpoint . '/screenshots', + method: 'POST', + body: $config + ); + + if ($fetchResponse->getStatusCode() >= 400) { + throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED, 'Screenshot service failed: ' . $fetchResponse->getBody()); + } + + $screenshot = $fetchResponse->getBody(); + + if (empty($screenshot)) { + throw new Exception(Exception::AVATAR_IMAGE_NOT_FOUND, 'Screenshot not generated'); + } + + // Determine if image processing is needed + $needsProcessing = ($width > 0 || $height > 0) || $quality !== -1 || !empty($output); + + if ($needsProcessing) { + // Process image with cropping, quality adjustment, or format conversion + $image = new Image($screenshot); + + $image->crop($width, $height); + + $output = $output ?: 'png'; // Default to PNG if not specified + $resizedScreenshot = $image->output($output, $quality); + unset($image); + } else { + // Return original screenshot without processing + $resizedScreenshot = $screenshot; + $output = 'png'; // Screenshots are typically PNG by default + } + + // Set content type based on output format + $outputs = Config::getParam('storage-outputs'); + $contentType = $outputs[$output] ?? $outputs['png']; + + $queueForStatsUsage->addMetric(METRIC_AVATARS_SCREENSHOTS_GENERATED, 1); + + $response + ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days + ->setContentType($contentType) + ->file($resizedScreenshot); + + + } catch (\Throwable $th) { + throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED, 'Screenshot generation failed: ' . $th->getMessage()); + } + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Module.php b/src/Appwrite/Platform/Modules/Avatars/Module.php new file mode 100644 index 0000000000..187bd96905 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Services/Http.php b/src/Appwrite/Platform/Modules/Avatars/Services/Http.php new file mode 100644 index 0000000000..c52edb6a05 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Services/Http.php @@ -0,0 +1,36 @@ +type = Service::TYPE_HTTP; + + $this->addAction(GetCreditCard::getName(), new GetCreditCard()); + $this->addAction(GetBrowser::getName(), new GetBrowser()); + $this->addAction(GetFlag::getName(), new GetFlag()); + $this->addAction(GetImage::getName(), new GetImage()); + $this->addAction(GetFavicon::getName(), new GetFavicon()); + $this->addAction(GetQR::getName(), new GetQR()); + $this->addAction(GetInitials::getName(), new GetInitials()); + $this->addAction(GetScreenshot::getName(), new GetScreenshot()); + $this->addAction(GetCloudCard::getName(), new GetCloudCard()); + $this->addAction(GetCloudCardBack::getName(), new GetCloudCardBack()); + $this->addAction(GetCloudCardOG::getName(), new GetCloudCardOG()); + } +}