Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(federation): Add endpoint to get the proxied avatar of other users #11734

Merged
merged 1 commit into from
Mar 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions appinfo/routes/routesAvatarController.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
'apiVersion' => '(v1)',
'token' => '^[a-z0-9]{4,30}$',
];
$requirementsWithSize = [
'apiVersion' => '(v1)',
'token' => '^[a-z0-9]{4,30}$',
'size' => '(64|512)',
];

return [
'ocs' => [
Expand All @@ -40,5 +45,9 @@
['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],
],
];
36 changes: 36 additions & 0 deletions docs/avatar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
109 changes: 108 additions & 1 deletion lib/Controller/AvatarController.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,21 @@
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;
Expand All @@ -57,6 +62,8 @@ public function __construct(
protected IUserSession $userSession,
protected IL10N $l,
protected LoggerInterface $logger,
protected ICloudIdManager $cloudIdManager,
protected IAvatarManager $avatarManager,
) {
parent::__construct($appName, $request);
}
Expand Down Expand Up @@ -138,7 +145,7 @@ public function emojiAvatar(string $emoji, ?string $color): DataResponse {
#[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 {
Expand Down Expand Up @@ -170,6 +177,106 @@ public function getAvatarDark(): FileDisplayResponse {
return $this->getAvatar(true);
}

/**
* 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
*
Expand Down
35 changes: 35 additions & 0 deletions lib/Federation/Proxy/TalkV1/Controller/AvatarController.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,39 @@ public function getAvatar(Room $room, Participant $participant, bool $darkTheme)
$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;
}
}
21 changes: 13 additions & 8 deletions lib/Federation/Proxy/TalkV1/ProxyRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ public function logUnexpectedStatusCode(string $method, int $statusCode, string
}

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'),
Expand All @@ -74,8 +74,13 @@ protected function generateDefaultRequestOptions(
'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 {
Expand All @@ -91,9 +96,9 @@ protected function prependProtocolIfNotAvailable(string $url): string {
*/
protected function request(
string $verb,
string $cloudId,
?string $cloudId,
#[SensitiveParameter]
string $accessToken,
?string $accessToken,
string $url,
array $parameters,
): IResponse {
Expand Down Expand Up @@ -134,9 +139,9 @@ protected function request(
* @throws CannotReachRemoteException
*/
public function get(
string $cloudId,
?string $cloudId,
#[SensitiveParameter]
string $accessToken,
?string $accessToken,
string $url,
array $parameters = [],
): IResponse {
Expand Down
5 changes: 5 additions & 0 deletions lib/Service/AvatarService.php
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,11 @@ public function getAvatar(Room $room, ?IUser $user, bool $darkTheme = false): IS
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}',
Expand Down
Loading
Loading