diff options
-rw-r--r-- | appinfo/routes/routesAvatarController.php | 9 | ||||
-rw-r--r-- | docs/avatar.md | 36 | ||||
-rw-r--r-- | lib/Controller/AvatarController.php | 109 | ||||
-rw-r--r-- | lib/Federation/Proxy/TalkV1/Controller/AvatarController.php | 35 | ||||
-rw-r--r-- | lib/Federation/Proxy/TalkV1/ProxyRequest.php | 21 | ||||
-rw-r--r-- | lib/Service/AvatarService.php | 5 | ||||
-rw-r--r-- | openapi-federation.json | 187 | ||||
-rw-r--r-- | openapi-full.json | 1203 | ||||
-rw-r--r-- | src/types/openapi/openapi-federation.ts | 64 | ||||
-rw-r--r-- | src/types/openapi/openapi-full.ts | 472 |
10 files changed, 1420 insertions, 721 deletions
diff --git a/appinfo/routes/routesAvatarController.php b/appinfo/routes/routesAvatarController.php index db4b61a27..688dc1a28 100644 --- a/appinfo/routes/routesAvatarController.php +++ b/appinfo/routes/routesAvatarController.php @@ -27,6 +27,11 @@ $requirements = [ 'apiVersion' => '(v1)', 'token' => '^[a-z0-9]{4,30}$', ]; +$requirementsWithSize = [ + 'apiVersion' => '(v1)', + 'token' => '^[a-z0-9]{4,30}$', + 'size' => '(64|512)', +]; return [ 'ocs' => [ @@ -40,5 +45,9 @@ return [ ['name' => 'Avatar#getAvatarDark', 'url' => '/api/{apiVersion}/room/{token}/avatar/dark', 'verb' => 'GET', 'requirements' => $requirements], /** @see \OCA\Talk\Controller\AvatarController::deleteAvatar() */ ['name' => 'Avatar#deleteAvatar', 'url' => '/api/{apiVersion}/room/{token}/avatar', 'verb' => 'DELETE', 'requirements' => $requirements], + /** @see \OCA\Talk\Controller\AvatarController::getUserProxyAvatar() */ + ['name' => 'Avatar#getUserProxyAvatar', 'url' => '/api/{apiVersion}/proxy/{token}/user-avatar/{size}', 'verb' => 'GET', 'requirements' => $requirementsWithSize], + /** @see \OCA\Talk\Controller\AvatarController::getUserProxyAvatarDark() */ + ['name' => 'Avatar#getUserProxyAvatarDark', 'url' => '/api/{apiVersion}/proxy/{token}/user-avatar/{size}/dark', 'verb' => 'GET', 'requirements' => $requirementsWithSize], ], ]; diff --git a/docs/avatar.md b/docs/avatar.md index 7fc908c44..c15948924 100644 --- a/docs/avatar.md +++ b/docs/avatar.md @@ -92,3 +92,39 @@ + `200 OK` + `404 Not Found` When the conversation could not be found for the participant - Body: the image file + +## Get federated user avatar (binary) + +* Required capability: `federation-v1` +* Method: `GET` +* Endpoint: `/proxy/{token}/user-avatar/{size}` +* Data: + +| field | type | Description | +|-----------|--------|------------------------------------------| +| `size` | int | Only 64 and 512 are supported | +| `cloudId` | string | Federation CloudID to get the avatar for | + +* Response: + - Status code: + + `200 OK` + + `404 Not Found` When the conversation could not be found for the participant + - Body: the image file + +## Get dark mode federated user avatar (binary) + +* Required capability: `federation-v1` +* Method: `GET` +* Endpoint: `/proxy/{token}/user-avatar/{size}/dark` +* Data: + +| field | type | Description | +|-----------|--------|------------------------------------------| +| `size` | int | Only 64 and 512 are supported | +| `cloudId` | string | Federation CloudID to get the avatar for | + +* Response: + - Status code: + + `200 OK` + + `404 Not Found` When the conversation could not be found for the participant + - Body: the image file diff --git a/lib/Controller/AvatarController.php b/lib/Controller/AvatarController.php index ef3116c17..68fc4ca92 100644 --- a/lib/Controller/AvatarController.php +++ b/lib/Controller/AvatarController.php @@ -30,16 +30,21 @@ namespace OCA\Talk\Controller; use InvalidArgumentException; use OCA\Talk\Exceptions\CannotReachRemoteException; use OCA\Talk\Middleware\Attribute\FederationSupported; +use OCA\Talk\Middleware\Attribute\RequireLoggedInParticipant; use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant; use OCA\Talk\Middleware\Attribute\RequireParticipantOrLoggedInAndListedConversation; use OCA\Talk\ResponseDefinitions; use OCA\Talk\Service\AvatarService; use OCA\Talk\Service\RoomFormatter; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\Federation\ICloudIdManager; +use OCP\IAvatarManager; use OCP\IL10N; use OCP\IRequest; use OCP\IUserSession; @@ -57,6 +62,8 @@ class AvatarController extends AEnvironmentAwareController { protected IUserSession $userSession, protected IL10N $l, protected LoggerInterface $logger, + protected ICloudIdManager $cloudIdManager, + protected IAvatarManager $avatarManager, ) { parent::__construct($appName, $request); } @@ -138,7 +145,7 @@ class AvatarController extends AEnvironmentAwareController { #[NoCSRFRequired] #[RequireParticipantOrLoggedInAndListedConversation] public function getAvatar(bool $darkTheme = false): FileDisplayResponse { - if ($this->room->getRemoteServer()) { + if ($this->room->getRemoteServer() !== '') { /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\AvatarController $proxy */ $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\AvatarController::class); try { @@ -171,6 +178,106 @@ class AvatarController extends AEnvironmentAwareController { } /** + * Get the avatar of a cloudId user + * + * @param int $size Avatar size + * @psalm-param 64|512 $size + * @param string $cloudId Federation CloudID to get the avatar for + * @param bool $darkTheme Theme used for background + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}> + * + * 200: User avatar returned + */ + #[FederationSupported] + #[BruteForceProtection(action: 'talkRoomToken')] + #[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)] + #[PublicPage] + #[NoCSRFRequired] + #[RequireLoggedInParticipant] + public function getUserProxyAvatar(int $size, string $cloudId, bool $darkTheme = false): FileDisplayResponse { + try { + $resolvedCloudId = $this->cloudIdManager->resolveCloudId($cloudId); + } catch (\InvalidArgumentException) { + return $this->getPlaceholderResponse($darkTheme); + } + + $ownId = $this->cloudIdManager->getCloudId($this->userSession->getUser()->getCloudId(), null); + + /** + * Reach out to the remote server to get the avatar + */ + if ($ownId->getRemote() !== $resolvedCloudId->getRemote()) { + /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\AvatarController $proxy */ + $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\AvatarController::class); + try { + return $proxy->getUserProxyAvatar($resolvedCloudId->getRemote(), $resolvedCloudId->getUser(), $size, $darkTheme); + } catch (CannotReachRemoteException) { + // Falling back to a local "user" avatar + return $this->getPlaceholderResponse($darkTheme); + } + } + + /** + * We are the server that hosts the user, so getting it from the avatar manager + */ + try { + $avatar = $this->avatarManager->getAvatar($resolvedCloudId->getUser()); + $avatarFile = $avatar->getFile($size, $darkTheme); + } catch (\Exception) { + return $this->getPlaceholderResponse($darkTheme); + } + + $response = new FileDisplayResponse( + $avatarFile, + Http::STATUS_OK, + ['Content-Type' => $avatarFile->getMimeType()], + ); + // Cache for 1 day + $response->cacheFor(60 * 60 * 24, false, true); + return $response; + } + + /** + * Get the dark mode avatar of a cloudId user + * + * @param int $size Avatar size + * @psalm-param 64|512 $size + * @param string $cloudId Federation CloudID to get the avatar for + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}> + * + * 200: User avatar returned + */ + #[FederationSupported] + #[BruteForceProtection(action: 'talkRoomToken')] + #[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)] + #[PublicPage] + #[NoCSRFRequired] + #[RequireLoggedInParticipant] + public function getUserProxyAvatarDark(int $size, string $cloudId): FileDisplayResponse { + return $this->getUserProxyAvatar($size, $cloudId, true); + } + + /** + * Get the placeholder avatar + * + * @param bool $darkTheme Theme used for background + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}> + * + * 200: User avatar returned + */ + protected function getPlaceholderResponse(bool $darkTheme): FileDisplayResponse { + $file = $this->avatarService->getPersonPlaceholder($darkTheme); + $response = new FileDisplayResponse( + $file, + Http::STATUS_OK, + ['Content-Type' => $file->getMimeType()], + ); + $response->cacheFor(60 * 60 * 24, false, true); + return $response; + + } + + /** * Delete the avatar of a room * * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}> diff --git a/lib/Federation/Proxy/TalkV1/Controller/AvatarController.php b/lib/Federation/Proxy/TalkV1/Controller/AvatarController.php index d99964637..43592df6d 100644 --- a/lib/Federation/Proxy/TalkV1/Controller/AvatarController.php +++ b/lib/Federation/Proxy/TalkV1/Controller/AvatarController.php @@ -77,4 +77,39 @@ class AvatarController { $response->cacheFor(60 * 60 * 24, false, true); return $response; } + + /** + * @see \OCA\Talk\Controller\AvatarController::getUserProxyAvatar() + * + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}> + * @throws CannotReachRemoteException + * + * 200: User avatar returned + */ + public function getUserProxyAvatar(string $remoteServer, string $user, int $size, bool $darkTheme): FileDisplayResponse { + $proxy = $this->proxy->get( + null, + null, + $remoteServer . '/index.php/avatar/' . $user . '/' . $size . ($darkTheme ? '/dark' : ''), + ); + + if ($proxy->getStatusCode() !== Http::STATUS_OK) { + if ($proxy->getStatusCode() !== Http::STATUS_NOT_FOUND) { + $this->proxy->logUnexpectedStatusCode(__METHOD__, $proxy->getStatusCode(), (string) $proxy->getBody()); + } + throw new CannotReachRemoteException('Avatar request had unexpected status code'); + } + + $content = $proxy->getBody(); + if ($content === '') { + throw new CannotReachRemoteException('No avatar content received'); + } + + $file = new InMemoryFile($user, $content); + + $response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $file->getMimeType()]); + // Cache for 1 day + $response->cacheFor(60 * 60 * 24, false, true); + return $response; + } } diff --git a/lib/Federation/Proxy/TalkV1/ProxyRequest.php b/lib/Federation/Proxy/TalkV1/ProxyRequest.php index 224a952d8..548553867 100644 --- a/lib/Federation/Proxy/TalkV1/ProxyRequest.php +++ b/lib/Federation/Proxy/TalkV1/ProxyRequest.php @@ -59,11 +59,11 @@ class ProxyRequest { } protected function generateDefaultRequestOptions( - string $cloudId, + ?string $cloudId, #[SensitiveParameter] - string $accessToken, + ?string $accessToken, ): array { - return [ + $options = [ 'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates'), 'nextcloud' => [ 'allow_local_address' => $this->config->getSystemValueBool('allow_local_remote_servers'), @@ -74,8 +74,13 @@ class ProxyRequest { 'OCS-APIRequest' => 'true', ], 'timeout' => 5, - 'auth' => [urlencode($cloudId), $accessToken], ]; + + if ($cloudId !== null && $accessToken !== null) { + $options['auth'] = [urlencode($cloudId), $accessToken]; + } + + return $options; } protected function prependProtocolIfNotAvailable(string $url): string { @@ -91,9 +96,9 @@ class ProxyRequest { */ protected function request( string $verb, - string $cloudId, + ?string $cloudId, #[SensitiveParameter] - string $accessToken, + ?string $accessToken, string $url, array $parameters, ): IResponse { @@ -134,9 +139,9 @@ class ProxyRequest { * @throws CannotReachRemoteException */ public function get( - string $cloudId, + ?string $cloudId, #[SensitiveParameter] - string $accessToken, + ?string $accessToken, string $url, array $parameters = [], ): IResponse { diff --git a/lib/Service/AvatarService.php b/lib/Service/AvatarService.php index 82b701eed..70757074a 100644 --- a/lib/Service/AvatarService.php +++ b/lib/Service/AvatarService.php @@ -237,6 +237,11 @@ class AvatarService { return new InMemoryFile($token, file_get_contents($this->getAvatarPath($room, $darkTheme))); } + public function getPersonPlaceholder(bool $darkTheme = false): ISimpleFile { + $colorTone = $darkTheme ? 'dark' : 'bright'; + return new InMemoryFile('fallback', file_get_contents(__DIR__ . '/../../img/icon-conversation-user-' . $colorTone . '.svg')); + } + protected function getEmojiAvatar(string $emoji, string $fillColor): string { return str_replace([ '{letter}', diff --git a/openapi-federation.json b/openapi-federation.json index 6445bbfeb..10bd1368e 100644 --- a/openapi-federation.json +++ b/openapi-federation.json @@ -731,6 +731,193 @@ } }, "paths": { + "/ocs/v2.php/apps/spreed/api/{apiVersion}/proxy/{token}/user-avatar/{size}": { + "get": { + "operationId": "avatar-get-user-proxy-avatar", + "summary": "Get the avatar of a cloudId user", + "tags": [ + "avatar" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "cloudId", + "in": "query", + "description": "Federation CloudID to get the avatar for", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "darkTheme", + "in": "query", + "description": "Theme used for background", + "schema": { + "type": "integer", + "default": 0, + "enum": [ + 0, + 1 + ] + } + }, + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "size", + "in": "path", + "description": "Avatar size", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "enum": [ + 64, + 512 + ] + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "User avatar returned", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/proxy/{token}/user-avatar/{size}/dark": { + "get": { + "operationId": "avatar-get-user-proxy-avatar-dark", + "summary": "Get the dark mode avatar of a cloudId user", + "tags": [ + "avatar" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "cloudId", + "in": "query", + "description": "Federation CloudID to get the avatar for", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "size", + "in": "path", + "description": "Avatar size", + "required": true, |