You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

295 lines
9.2 KiB

<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022, Vitor Mattos <vitor@php.rio>
*
* @author Vitor Mattos <vitor@php.rio>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Talk\Service;
use InvalidArgumentException;
use OC\Files\Filesystem;
use OCA\Talk\Room;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\Files\SimpleFS\InMemoryFile;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IAvatarManager;
use OCP\IEmojiHelper;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\Security\ISecureRandom;
class AvatarService {
public function __construct(
private IAppData $appData,
private IL10N $l,
private IURLGenerator $url,
private ISecureRandom $random,
private RoomService $roomService,
private IAvatarManager $avatarManager,
private IEmojiHelper $emojiHelper,
) {
}
public function setAvatarFromRequest(Room $room, ?array $file): void {
if ($room->getType() === Room::TYPE_ONE_TO_ONE || $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER) {
throw new InvalidArgumentException($this->l->t('One-to-one rooms always need to show the other users avatar'));
}
if (is_null($file) || !is_array($file)) {
throw new InvalidArgumentException($this->l->t('No image file provided'));
}
if (
$file['error'] !== 0 ||
!is_uploaded_file($file['tmp_name']) ||
Filesystem::isFileBlacklisted($file['tmp_name'])
) {
throw new InvalidArgumentException($this->l->t('Invalid file provided'));
}
if ($file['size'] > 20 * 1024 * 1024) {
throw new InvalidArgumentException($this->l->t('File is too big'));
}
$content = file_get_contents($file['tmp_name']);
unlink($file['tmp_name']);
$image = new \OC_Image();
$image->loadFromData($content);
$image->readExif($content);
$this->setAvatar($room, $image);
}
public function setAvatar(Room $room, \OC_Image $image): void {
if ($room->getType() === Room::TYPE_ONE_TO_ONE || $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER) {
throw new InvalidArgumentException($this->l->t('One-to-one rooms always need to show the other users avatar'));
}
$image->fixOrientation();
if (!($image->height() === $image->width())) {
throw new InvalidArgumentException($this->l->t('Avatar image is not square'));
}
if (!$image->valid()) {
throw new InvalidArgumentException($this->l->t('Invalid image'));
}
$mimeType = $image->mimeType();
$allowedMimeTypes = [
'image/jpeg',
'image/png',
];
if (!in_array($mimeType, $allowedMimeTypes)) {
throw new InvalidArgumentException($this->l->t('Unknown filetype'));
}
$token = $room->getToken();
$avatarFolder = $this->getAvatarFolder($token);
// Delete previous avatars
foreach ($avatarFolder->getDirectoryListing() as $file) {
$file->delete();
}
$avatarName = $this->random->generate(16, ISecureRandom::CHAR_HUMAN_READABLE);
if ($mimeType === 'image/jpeg') {
$avatarName .= '.jpg';
} else {
$avatarName .= '.png';
}
$avatarFolder->newFile($avatarName, $image->data());
$this->roomService->setAvatar($room, $avatarName);
}
private function getAvatarFolder(string $token): ISimpleFolder {
try {
$folder = $this->appData->getFolder('room-avatar');
} catch (NotFoundException $e) {
$folder = $this->appData->newFolder('room-avatar');
}
try {
$avatarFolder = $folder->getFolder($token);
} catch (NotFoundException $e) {
$avatarFolder = $folder->newFolder($token);
}
return $avatarFolder;
}
/**
* https://github.com/sebdesign/cap-height -- for 500px height
* Automated check: https://codepen.io/skjnldsv/pen/PydLBK/
* Noto Sans cap-height is 0.715 and we want a 200px caps height size
* (0.4 letter-to-total-height ratio, 500*0.4=200), so: 200/0.715 = 280px.
* Since we start from the baseline (text-anchor) we need to
* shift the y axis by 100px (half the caps height): 500/2+100=350
*
* Copied from @see \OC\Avatar\Avatar::$svgTemplate with some changes:
* - {font} is injected
* - size fixed to 512
* - font-size reduced to 240
* - font-weight and fill color are removed as they are not applicable
*/
private string $svgTemplate = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="512" height="512" version="1.1" viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#{fill}"></rect>
<text x="50%" y="330" style="font-size:240px;font-family:{font};text-anchor:middle;">{letter}</text>
</svg>';
public function getAvatar(Room $room, ?IUser $user, bool $darkTheme = false): ISimpleFile {
$token = $room->getToken();
$avatar = $room->getAvatar();
if ($avatar) {
try {
$folder = $this->appData->getFolder('room-avatar');
if ($folder->fileExists($token)) {
return $folder->getFolder($token)->getFile($avatar);
}
} catch (NotFoundException $e) {
}
}
// Fallback
if ($room->getType() === Room::TYPE_ONE_TO_ONE) {
$users = json_decode($room->getName(), true);
foreach ($users as $participantId) {
if ($user instanceof IUser && $participantId !== $user->getUID()) {
$avatar = $this->avatarManager->getAvatar($participantId);
return $avatar->getFile(512, $darkTheme);
}
}
}
if ($this->emojiHelper->isValidSingleEmoji(mb_substr($room->getName(), 0, 1))) {
return new InMemoryFile($token, $this->getEmojiAvatar($room->getName(), $darkTheme));
}
return new InMemoryFile($token, file_get_contents($this->getAvatarPath($room, $darkTheme)));
}
protected function getEmojiAvatar(string $roomName, bool $darkTheme = false): string {
return str_replace([
'{letter}',
'{fill}',
'{font}',
], [
$this->getFirstCombinedEmoji($roomName),
$darkTheme ? '3B3B3B' : 'DBDBDB',
implode(',', [
"'Segoe UI'",
'Roboto',
'Oxygen-Sans',
'Cantarell',
'Ubuntu',
"'Helvetica Neue'",
'Arial',
'sans-serif',
"'Noto Color Emoji'",
"'Apple Color Emoji'",
"'Segoe UI Emoji'",
"'Segoe UI Symbol'",
"'Noto Sans'",
]),
], $this->svgTemplate);
}
/**
* Get the first combined full emoji (including gender, skin tone, job, …)
*
* @param string $roomName
* @param int $length
* @return string
*/
protected function getFirstCombinedEmoji(string $roomName, int $length = 0): string {
$attempt = mb_substr($roomName, 0, $length + 1);
if ($this->emojiHelper->isValidSingleEmoji($attempt)) {
$longerAttempt = $this->getFirstCombinedEmoji($roomName, $length + 1);
return $longerAttempt ?: $attempt;
}
return '';
}
public function isCustomAvatar(Room $room): bool {
return $room->getAvatar() !== '';
}
private function getAvatarPath(Room $room, bool $darkTheme = false): string {
$colorTone = $darkTheme ? 'dark' : 'bright';
if ($room->getType() === Room::TYPE_CHANGELOG) {
return __DIR__ . '/../../img/changelog.svg';
}
if ($room->getObjectType() === 'file') {
return __DIR__ . '/../../img/icon-conversation-text-' . $colorTone . '.svg';
}
if ($room->getObjectType() === 'share:password') {
return __DIR__ . '/../../img/icon-conversation-password-' . $colorTone . '.svg';
}
if ($room->getObjectType() === 'emails') {
return __DIR__ . '/../../img/icon-conversation-mail-' . $colorTone . '.svg';
}
if ($room->getType() === Room::TYPE_PUBLIC) {
return __DIR__ . '/../../img/icon-conversation-public-' . $colorTone . '.svg';
}
if ($room->getType() === Room::TYPE_ONE_TO_ONE_FORMER
|| $room->getType() === Room::TYPE_ONE_TO_ONE
) {
return __DIR__ . '/../../img/icon-conversation-user-' . $colorTone . '.svg';
}
return __DIR__ . '/../../img/icon-conversation-group-' . $colorTone . '.svg';
}
public function deleteAvatar(Room $room): void {
try {
$folder = $this->appData->getFolder('room-avatar');
$avatarFolder = $folder->getFolder($room->getToken());
$avatarFolder->delete();
$this->roomService->setAvatar($room, '');
} catch (NotFoundException $e) {
}
}
public function getAvatarUrl(Room $room): string {
$arguments = [
'token' => $room->getToken(),
'apiVersion' => 'v1',
];
$avatarVersion = $this->getAvatarVersion($room);
if ($avatarVersion !== '') {
$arguments['v'] = $avatarVersion;
}
return $this->url->linkToOCSRouteAbsolute('spreed.Avatar.getAvatar', $arguments);
}
public function getAvatarVersion(Room $room): string {
$avatarVersion = $room->getAvatar();
if ($avatarVersion) {
[$version] = explode('.', $avatarVersion);
return $version;
}
if ($this->emojiHelper->isValidSingleEmoji(mb_substr($room->getName(), 0, 1))) {
return substr(md5($this->getEmojiAvatar($room->getName())), 0, 8);
}
$avatarPath = $this->getAvatarPath($room);
return substr(md5($avatarPath), 0, 8);
}
}