Skip to content
Open
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
1 change: 1 addition & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,7 @@
'OC\\Avatar\\AvatarManager' => $baseDir . '/lib/private/Avatar/AvatarManager.php',
'OC\\Avatar\\GuestAvatar' => $baseDir . '/lib/private/Avatar/GuestAvatar.php',
'OC\\Avatar\\PlaceholderAvatar' => $baseDir . '/lib/private/Avatar/PlaceholderAvatar.php',
'OC\\Avatar\\RemoteAvatar' => $baseDir . '/lib/private/Avatar/RemoteAvatar.php',
'OC\\Avatar\\UserAvatar' => $baseDir . '/lib/private/Avatar/UserAvatar.php',
'OC\\BackgroundJob\\JobClassesRegistry' => $baseDir . '/lib/private/BackgroundJob/JobClassesRegistry.php',
'OC\\BackgroundJob\\JobList' => $baseDir . '/lib/private/BackgroundJob/JobList.php',
Expand Down
1 change: 1 addition & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -1350,6 +1350,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Avatar\\AvatarManager' => __DIR__ . '/../../..' . '/lib/private/Avatar/AvatarManager.php',
'OC\\Avatar\\GuestAvatar' => __DIR__ . '/../../..' . '/lib/private/Avatar/GuestAvatar.php',
'OC\\Avatar\\PlaceholderAvatar' => __DIR__ . '/../../..' . '/lib/private/Avatar/PlaceholderAvatar.php',
'OC\\Avatar\\RemoteAvatar' => __DIR__ . '/../../..' . '/lib/private/Avatar/RemoteAvatar.php',
'OC\\Avatar\\UserAvatar' => __DIR__ . '/../../..' . '/lib/private/Avatar/UserAvatar.php',
'OC\\BackgroundJob\\JobClassesRegistry' => __DIR__ . '/../../..' . '/lib/private/BackgroundJob/JobClassesRegistry.php',
'OC\\BackgroundJob\\JobList' => __DIR__ . '/../../..' . '/lib/private/BackgroundJob/JobList.php',
Expand Down
27 changes: 27 additions & 0 deletions lib/private/Avatar/AvatarManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use OC\User\Manager;
use OCP\Accounts\IAccountManager;
use OCP\Accounts\PropertyDoesNotExistException;
use OCP\Federation\ICloudIdManager;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
Expand Down Expand Up @@ -53,6 +54,11 @@ public function __construct(
*/
#[\Override]
public function getAvatar(string $userId): IAvatar {
$cloudIdManager = \OCP\Server::get(ICloudIdManager::class);
if ($cloudIdManager->isValidCloudId($userId)) {
return $this->getRemoteAvatar($userId);
}

$user = $this->userManager->get($userId);
if ($user === null) {
throw new \Exception('user does not exist');
Expand Down Expand Up @@ -134,4 +140,25 @@ public function deleteUserAvatar(string $userId): void {
public function getGuestAvatar(string $name): IAvatar {
return new GuestAvatar($name, $this->config, $this->logger);
}

/**
* Returns a RemoteAvatar
*
* @param string $userId The \OCP\Federation\ICloudId of the remote account, e.g. account@example.com
*/
private function getRemoteAvatar(string $userId): IAvatar {
try {
$remoteAvatarFolder = $this->appData->getFolder('__remote');
} catch (NotFoundException $e) {
$remoteAvatarFolder = $this->appData->newFolder('__remote');
}

try {
$folder = $remoteAvatarFolder->getFolder($userId);
} catch (NotFoundException $e) {
$folder = $remoteAvatarFolder->newFolder($userId);
}

return new RemoteAvatar($folder, $userId, $this->config, $this->logger);
}
}
135 changes: 135 additions & 0 deletions lib/private/Avatar/RemoteAvatar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare(strict_types=1);

namespace OC\Avatar;

use OCP\Federation\ICloudId;
use OCP\Federation\ICloudIdManager;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use Psr\Log\LoggerInterface;

class RemoteAvatar extends Avatar {
private const IMAGE_CACHE_AGE = 60 * 60 * 24; // One day

private ICloudId $cloudId;

public function __construct(
protected ISimpleFolder $folder,
protected string $userId,
protected IConfig $config,
protected LoggerInterface $logger,
) {
parent::__construct($config, $logger);

$cloudIdManager = \OCP\Server::get(ICloudIdManager::class);
$this->cloudId = $cloudIdManager->resolveCloudId($userId);
}

#[\Override]
public function exists(): bool {
return true;
}

#[\Override]
public function getDisplayName(): string {
return $this->cloudId->getDisplayId();
}

/**
* Setting avatars isn't implemented for remote accounts
*/
#[\Override]
public function set($data): void {
}

/**
* Removing avatars isn't implemented for remote accounts
*/
#[\Override]
public function remove(bool $silent = false): void {
}

#[\Override]
public function getFile(int $size, bool $darkTheme = false): ISimpleFile {
if ($size === -1) {
$filename = 'avatar' . ($darkTheme ? '-dark' : '');
} else {
$filename = 'avatar' . ($darkTheme ? '-dark' : '') . '.' . $size;
}

$avatar = null;
$files = $this->folder->getDirectoryListing();
foreach ($files as $file) {
if (pathinfo($file->getName(), PATHINFO_FILENAME) === $filename) {
$avatar = $file;
break;
}
}

if ($avatar !== null) {
// check if a remote avatar is at least a day old, in case a new avatar was uploaded
$isAvatarOld = (time() - $avatar->getMTime()) >= self::IMAGE_CACHE_AGE;
if (!$isAvatarOld) {
return $avatar;
}
}

$url = rtrim($this->cloudId->getRemote(), '/') . '/index.php/avatar/' . rawurlencode($this->cloudId->getUser()) . '/' . $size;
if ($darkTheme) {
$url .= '/dark';
}

$clientService = \OCP\Server::get(IClientService::class);
$client = $clientService->newClient();
$response = $client->get($url, [
'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates', false)
]);

$contentType = $response->getHeader('Content-Type');
if (str_starts_with($contentType, 'image/') === false) {
throw new \Exception('Unknown filetype');
}
Comment thread
leftybournes marked this conversation as resolved.

$avatar = $response->getBody();
Comment thread
leftybournes marked this conversation as resolved.
if ($avatar === null) {
throw new \Exception('Failed to fetch remote avatar');
}

if (is_resource($avatar)) {
$avatar = stream_get_contents($avatar);
if ($avatar === false) {
throw new \Exception('Failed to fetch remote avatar');
}
}

$ext = match ($contentType) {
'image/png' => 'png',
'image/jpg', 'image/jpeg' => 'jpg',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => 'png',
};
return $this->folder->newFile($filename . '.' . $ext, $avatar);
}

/**
* Handling user changes isn't implemented for remote accounts
*/
#[\Override]
public function userChanged(string $feature, $oldValue, $newValue): void {
}

#[\Override]
public function isCustomAvatar(): bool {
return true;
}
}
82 changes: 69 additions & 13 deletions tests/lib/Avatar/AvatarManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@

use OC\Avatar\AvatarManager;
use OC\Avatar\PlaceholderAvatar;
use OC\Avatar\RemoteAvatar;
use OC\Avatar\UserAvatar;
use OC\KnownUser\KnownUserService;
use OC\User\Manager;
use OC\User\User;
use OCP\Accounts\IAccount;
use OCP\Accounts\IAccountManager;
use OCP\Accounts\IAccountProperty;
use OCP\Federation\ICloudId;
use OCP\Federation\ICloudIdManager;
use OCP\Files\IAppData;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IConfig;
Expand Down Expand Up @@ -73,19 +76,6 @@ protected function setUp(): void {
);
}

public function testGetAvatarInvalidUser(): void {
$this->expectException(\Exception::class);
$this->expectExceptionMessage('user does not exist');

$this->userManager
->expects($this->once())
->method('get')
->with('invalidUser')
->willReturn(null);

$this->avatarManager->getAvatar('invalidUser');
}

public function testGetAvatarForSelf(): void {
$user = $this->createMock(User::class);
$user
Expand Down Expand Up @@ -276,4 +266,70 @@ public function testGetAvatarScopes($avatarScope, $isPublicCall, $isKnownUser, $
}
$this->assertEquals($expected, $this->avatarManager->getAvatar('valid-user'));
}

public function testGetAvatarInvalidUser(): void {
$this->expectException(\Exception::class);
$this->expectExceptionMessage('user does not exist');

$this->userManager
->expects($this->once())
->method('get')
->with('invalidUser')
->willReturn(null);

$this->avatarManager->getAvatar('invalidUser');
}

public function testGetAvatarForRemoteUser(): void {
$cloudId = 'user@https://remote.example.com';

$this->userManager
->expects($this->never())
->method('get');

$resolvedCloudId = $this->createMock(ICloudId::class);
$resolvedCloudId->method('getUser')->willReturn('user');
$resolvedCloudId->method('getRemote')->willReturn('https://remote.example.com');
$resolvedCloudId->method('getDisplayId')->willReturn('user@remote.example.com');

$cloudIdManager = $this->createMock(ICloudIdManager::class);
$cloudIdManager->expects($this->once())
->method('isValidCloudId')
->with($cloudId)
->willReturn(true);
$cloudIdManager->method('resolveCloudId')
->with($cloudId)
->willReturn($resolvedCloudId);
$this->overwriteService(ICloudIdManager::class, $cloudIdManager);

$this->appData->expects($this->once())->method('getFolder');
$this->accountManager->expects($this->never())->method('getAccount');

$avatar = $this->avatarManager->getAvatar($cloudId);

self::assertInstanceOf(RemoteAvatar::class, $avatar);
self::assertTrue($avatar->exists());
self::assertTrue($avatar->isCustomAvatar());
self::assertSame('user@remote.example.com', $avatar->getDisplayName());
}

public function testGetAvatarThrowsForUnknownUserThatIsNotACloudId(): void {
$this->expectException(\Exception::class);
$this->expectExceptionMessage('user does not exist');

$this->userManager
->expects($this->once())
->method('get')
->with('invalidUser')
->willReturn(null);

$cloudIdManager = $this->createMock(ICloudIdManager::class);
$cloudIdManager->expects($this->once())
->method('isValidCloudId')
->with('invalidUser')
->willReturn(false);
$this->overwriteService(ICloudIdManager::class, $cloudIdManager);

$this->avatarManager->getAvatar('invalidUser');
}
}
Loading
Loading