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
16 changes: 16 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 @@ -55,6 +56,11 @@ public function __construct(
public function getAvatar(string $userId): IAvatar {
$user = $this->userManager->get($userId);
if ($user === null) {
$cloudIdManager = \OCP\Server::get(ICloudIdManager::class);
if ($cloudIdManager->isValidCloudId($userId)) {
return $this->getRemoteAvatar($userId);
}

throw new \Exception('user does not exist');
}

Expand Down Expand Up @@ -134,4 +140,14 @@ 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 {
$folder = $this->appData->newFolder($userId);
return new RemoteAvatar($folder, $userId, $this->config, $this->logger);
}
}
138 changes: 138 additions & 0 deletions lib/private/Avatar/RemoteAvatar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

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

declare(strict_types=1);

namespace OC\Avatar;

use OCP\Federation\ICloudId;
use OCP\Federation\ICloudIdManager;
use OCP\Files\NotFoundException;
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 {
$avatarExists = false;
if ($size === -1) {
$filename = 'avatar' . ($darkTheme ? '-dark' : '');
} else {
$filename = 'avatar' . ($darkTheme ? '-dark' : '') . '.' . $size;
}

// check first if a png avatar exists
$ext = 'png';
try {
$file = $this->folder->getFile($filename . '.' . $ext);
$avatarExists = true;
} catch (NotFoundException $e) {
$this->logger->debug('Unable to find avatar with .png extension. Trying .jpg');
}

// if the png avatar doesn't exist, check if there's a jpg
if (!$avatarExists) {
$ext = 'jpg';
try {
$file = $this->folder->getFile($filename . '.' . $ext);
$avatarExists = true;
} catch (NotFoundException $e) {
$this->logger->debug('No avatar found');
}
}

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

$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 (strpos($contentType, 'image/') === false) {
throw new \Exception('Unknown filetype');
}
Comment on lines +110 to +113

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems a bit strict. If a user uploads their own avatar it is probably stored and served in the original format.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I modified it to also check for jpgs

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a lot more formats that people actually use. Maybe just limit the check to ensure the mimetype starts with image/?

@leftybournes leftybournes Jul 24, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense and it makes the check easier. Just pushed the change.


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

$ext = $contentType === 'image/jpg' || $contentType === 'image/jpeg' ? 'jpg' : '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;
}
}
85 changes: 72 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,73 @@ 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->once())
->method('get')
->with($cloudId)
->willReturn(null);

$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);

// the remote branch must not touch local avatar storage
$this->appData->expects($this->never())->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