Completed
Push — master ( a0496b...d82ef7 )
by Morris
61:12 queued 41:11
created

Avatar::generateAvatar()   B

Complexity

Conditions 1
Paths 1

Size

Total Lines 24
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 16
nc 1
nop 2
dl 0
loc 24
rs 8.9713
c 0
b 0
f 0
1
<?php
2
/**
3
 * @copyright Copyright (c) 2016, ownCloud, Inc.
4
 * @copyright 2018 John Molakvoæ <[email protected]>
5
 *
6
 * @author Arthur Schiwon <[email protected]>
7
 * @author Christopher Schäpers <[email protected]>
8
 * @author Lukas Reschke <[email protected]>
9
 * @author Morris Jobke <[email protected]>
10
 * @author Olivier Mehani <[email protected]>
11
 * @author Robin Appelman <[email protected]>
12
 * @author Roeland Jago Douma <[email protected]>
13
 * @author Thomas Müller <[email protected]>
14
 * @author John Molakvoæ <[email protected]>
15
 *
16
 * @license AGPL-3.0
17
 *
18
 * This code is free software: you can redistribute it and/or modify
19
 * it under the terms of the GNU Affero General Public License, version 3,
20
 * as published by the Free Software Foundation.
21
 *
22
 * This program is distributed in the hope that it will be useful,
23
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
24
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
25
 * GNU Affero General Public License for more details.
26
 *
27
 * You should have received a copy of the GNU Affero General Public License, version 3,
28
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
29
 *
30
 */
31
32
namespace OC;
33
34
use OCP\Files\NotFoundException;
35
use OCP\Files\NotPermittedException;
36
use OCP\Files\SimpleFS\ISimpleFile;
37
use OCP\Files\SimpleFS\ISimpleFolder;
38
use OCP\IAvatar;
39
use OCP\IConfig;
40
use OCP\IImage;
41
use OCP\IL10N;
42
use OCP\ILogger;
43
use OC\User\User;
44
use OC_Image;
45
use Imagick;
46
47
/**
48
 * This class gets and sets users avatars.
49
 */
50
51
class Avatar implements IAvatar {
52
	/** @var ISimpleFolder */
53
	private $folder;
54
	/** @var IL10N */
55
	private $l;
56
	/** @var User */
57
	private $user;
58
	/** @var ILogger  */
59
	private $logger;
60
	/** @var IConfig */
61
	private $config;
62
63
	/**
64
	 * https://github.com/sebdesign/cap-height -- for 500px height
65
	 * Open Sans cap-height is 0.72 and we want a 200px caps height size (0.4 letter-to-total-height ratio, 500*0.4=200). 200/0.72 = 278px.
66
	 * 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
67
	 * 
68
	 * @var string 
69
	 */
70
	private $svgTemplate = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>
71
		<svg width="{size}" height="{size}" version="1.1" viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg">
72
			<rect width="100%" height="100%" fill="#{fill}"></rect>
73
			<text x="50%" y="350" style="font-weight:600;font-size:278px;font-family:\'Open Sans\';text-anchor:middle;fill:#fff">{letter}</text>
74
		</svg>';
75
76
	/**
77
	 * constructor
78
	 *
79
	 * @param ISimpleFolder $folder The folder where the avatars are
80
	 * @param IL10N $l
81
	 * @param User $user
82
	 * @param ILogger $logger
83
	 * @param IConfig $config
84
	 */
85 View Code Duplication
	public function __construct(ISimpleFolder $folder,
86
		IL10N $l,
87
		$user,
88
		ILogger $logger,
89
		IConfig $config) {
90
		$this->folder = $folder;
91
		$this->l = $l;
92
		$this->user = $user;
93
		$this->logger = $logger;
94
		$this->config = $config;
95
	}
96
97
	/**
98
	 * @inheritdoc
99
	 */
100
	public function get($size = 64) {
101
		try {
102
			$file = $this->getFile($size);
103
		} catch (NotFoundException $e) {
104
			return false;
105
		}
106
107
		$avatar = new OC_Image();
108
		$avatar->loadFromData($file->getContent());
109
		return $avatar;
110
	}
111
112
	/**
113
	 * Check if an avatar exists for the user
114
	 *
115
	 * @return bool
116
	 */
117
	public function exists() {
118
119
		return $this->folder->fileExists('avatar.jpg') || $this->folder->fileExists('avatar.png');
120
	}
121
122
	/**
123
	 * sets the users avatar
124
	 * @param IImage|resource|string $data An image object, imagedata or path to set a new avatar
125
	 * @throws \Exception if the provided file is not a jpg or png image
126
	 * @throws \Exception if the provided image is not valid
127
	 * @throws NotSquareException if the image is not square
128
	 * @return void
129
	 */
130
	public function set($data) {
131
132
		if ($data instanceof IImage) {
133
			$img = $data;
134
			$data = $img->data();
135
		} else {
136
			$img = new OC_Image();
137
			if (is_resource($data) && get_resource_type($data) === "gd") {
138
				$img->setResource($data);
0 ignored issues
show
Documentation introduced by
$data is of type resource, but the function expects a object<Returns>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
139
			} elseif (is_resource($data)) {
140
				$img->loadFromFileHandle($data);
141
			} else {
142
				try {
143
					// detect if it is a path or maybe the images as string
144
					$result = @realpath($data);
145
					if ($result === false || $result === null) {
146
						$img->loadFromData($data);
0 ignored issues
show
Bug introduced by
It seems like $data defined by parameter $data on line 130 can also be of type resource; however, OC_Image::loadFromData() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
147
					} else {
148
						$img->loadFromFile($data);
0 ignored issues
show
Bug introduced by
It seems like $data defined by parameter $data on line 130 can also be of type resource; however, OC_Image::loadFromFile() does only seem to accept boolean|string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
149
					}
150
				} catch (\Error $e) {
0 ignored issues
show
Bug introduced by
The class Error does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
151
					$img->loadFromData($data);
0 ignored issues
show
Bug introduced by
It seems like $data defined by parameter $data on line 130 can also be of type resource; however, OC_Image::loadFromData() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
152
				}
153
			}
154
		}
155
		$type = substr($img->mimeType(), -3);
156
		if ($type === 'peg') {
157
			$type = 'jpg';
158
		}
159
		if ($type !== 'jpg' && $type !== 'png') {
160
			throw new \Exception($this->l->t('Unknown filetype'));
161
		}
162
163
		if (!$img->valid()) {
164
			throw new \Exception($this->l->t('Invalid image'));
165
		}
166
167
		if (!($img->height() === $img->width())) {
168
			throw new NotSquareException($this->l->t('Avatar image is not square'));
169
		}
170
171
		$this->remove();
172
		$file = $this->folder->newFile('avatar.' . $type);
173
		$file->putContent($data);
0 ignored issues
show
Bug introduced by
It seems like $data defined by parameter $data on line 130 can also be of type resource; however, OCP\Files\SimpleFS\ISimpleFile::putContent() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
174
175
		try {
176
			$generated = $this->folder->getFile('generated');
177
			$this->config->setUserValue($this->user->getUID(), 'avatar', 'generated', 'false');
178
			$generated->delete();
179
		} catch (NotFoundException $e) {
180
			//
181
		}
182
		$this->user->triggerChange('avatar', $file);
183
	}	
184
185
	/**
186
	 * remove the users avatar
187
	 * @return void
188
	 */
189
	public function remove() {
190
		$avatars = $this->folder->getDirectoryListing();
191
192
		$this->config->setUserValue($this->user->getUID(), 'avatar', 'version',
193
			(int) $this->config->getUserValue($this->user->getUID(), 'avatar', 'version', 0) + 1);
194
195
		foreach ($avatars as $avatar) {
196
			$avatar->delete();
197
		}
198
		$this->config->setUserValue($this->user->getUID(), 'avatar', 'generated', 'true');
199
		$this->user->triggerChange('avatar', '');
200
	}
201
202
	/**
203
	 * @inheritdoc
204
	 */
205
	public function getFile($size) {
206
		try {
207
			$ext = $this->getExtension();
208
		} catch (NotFoundException $e) {
209
			if (!$data = $this->generateAvatarFromSvg(1024)) {
210
				$data = $this->generateAvatar($this->user->getDisplayName(), 1024);
211
			}
212
			$avatar = $this->folder->newFile('avatar.png');
213
			$avatar->putContent($data);
214
			$ext = 'png';
215
216
			$this->folder->newFile('generated');
217
			$this->config->setUserValue($this->user->getUID(), 'avatar', 'generated', 'true');
218
		}
219
220
		if ($size === -1) {
221
			$path = 'avatar.' . $ext;
222
		} else {
223
			$path = 'avatar.' . $size . '.' . $ext;
224
		}
225
226
		try {
227
			$file = $this->folder->getFile($path);
228
		} catch (NotFoundException $e) {
229
			if ($size <= 0) {
230
				throw new NotFoundException;
231
			}
232
233
			if ($this->folder->fileExists('generated')) {
234
				if (!$data = $this->generateAvatarFromSvg($size)) {
235
					$data = $this->generateAvatar($this->user->getDisplayName(), $size);
236
				}
237
238
			} else {
239
				$avatar = new OC_Image();
240
				/** @var ISimpleFile $file */
241
				$file = $this->folder->getFile('avatar.' . $ext);
242
				$avatar->loadFromData($file->getContent());
243
				$avatar->resize($size);
244
				$data = $avatar->data();
245
			}
246
247
			try {
248
				$file = $this->folder->newFile($path);
249
				$file->putContent($data);
250
			} catch (NotPermittedException $e) {
251
				$this->logger->error('Failed to save avatar for ' . $this->user->getUID());
252
				throw new NotFoundException();
253
			}
254
255
		}
256
257
		if ($this->config->getUserValue($this->user->getUID(), 'avatar', 'generated', null) === null) {
258
			$generated = $this->folder->fileExists('generated') ? 'true' : 'false';
259
			$this->config->setUserValue($this->user->getUID(), 'avatar', 'generated', $generated);
260
		}
261
262
		return $file;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $file; (OCP\Files\SimpleFS\ISimpleFile) is incompatible with the return type declared by the interface OCP\IAvatar::getFile of type OCP\Files\File.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
263
	}
264
265
	/**
266
	 * Get the extension of the avatar. If there is no avatar throw Exception
267
	 *
268
	 * @return string
269
	 * @throws NotFoundException
270
	 */
271
	private function getExtension() {
272
		if ($this->folder->fileExists('avatar.jpg')) {
273
			return 'jpg';
274
		} elseif ($this->folder->fileExists('avatar.png')) {
275
			return 'png';
276
		}
277
		throw new NotFoundException;
278
	}
279
	
280
	/**
281
	 * {size} = 500
282
	 * {fill} = hex color to fill
283
	 * {letter} = Letter to display
284
	 * 
285
	 * Generate SVG avatar
286
	 * @return string
287
	 * 
288
	 */
289
	private function getAvatarVector(int $size): string {
290
		$userDisplayName = $this->user->getDisplayName();
291
292
		$bgRGB = $this->avatarBackgroundColor($userDisplayName);
293
		$bgHEX = sprintf("%02x%02x%02x", $bgRGB->r, $bgRGB->g, $bgRGB->b);
294
		$letter = mb_strtoupper(mb_substr($userDisplayName, 0, 1), 'UTF-8');
295
		
296
		$toReplace = ['{size}', '{fill}', '{letter}'];
297
		return str_replace($toReplace, [$size, $bgHEX, $letter], $this->svgTemplate);
298
	}
299
300
	/**
301
	 * Generate png avatar from svg with Imagick
302
	 * 
303
	 * @param int $size
304
	 * @return string|boolean
305
	 */
306
	private function generateAvatarFromSvg(int $size) {
307
		if (!extension_loaded('imagick')) {
308
			return false;
309
		}
310
		try {
311
			$font = __DIR__ . '/../../core/fonts/OpenSans-Semibold.ttf';
312
			$svg = $this->getAvatarVector($size);
313
			$avatar = new Imagick();
314
			$avatar->setFont($font);
315
			$avatar->readImageBlob($svg);
316
			$avatar->setImageFormat('png');
317
			$image = new OC_Image();
318
			$image->loadFromData($avatar);
319
			return $image->data();
320
		} catch (\Exception $e) {
321
			return false;
322
		}
323
	}
324
325
	/**
326
	 * Generate png avatar with GD
327
	 * 
328
	 * @param string $userDisplayName
329
	 * @param int $size
330
	 * @return string
331
	 */
332
	private function generateAvatar($userDisplayName, $size) {
333
		$text = mb_strtoupper(mb_substr($userDisplayName, 0, 1), 'UTF-8');
334
		$backgroundColor = $this->avatarBackgroundColor($userDisplayName);
335
336
		$im = imagecreatetruecolor($size, $size);
337
		$background = imagecolorallocate($im, $backgroundColor->r, $backgroundColor->g, $backgroundColor->b);
338
		$white = imagecolorallocate($im, 255, 255, 255);
339
		imagefilledrectangle($im, 0, 0, $size, $size, $background);
340
341
		$font = __DIR__ . '/../../core/fonts/OpenSans-Semibold.ttf';
342
343
		$fontSize = $size * 0.4;
344
345
		list($x, $y) = $this->imageTTFCenter($im, $text, $font, $fontSize);
346
347
		imagettftext($im, $fontSize, 0, $x, $y, $white, $font, $text);
348
349
		ob_start();
350
		imagepng($im);
351
		$data = ob_get_contents();
352
		ob_end_clean();
353
354
		return $data;
355
	}
356
357
	/**
358
	 * Calculate real image ttf center
359
	 *
360
	 * @param resource $image
361
	 * @param string $text text string
362
	 * @param string $font font path
363
	 * @param int $size font size
364
	 * @param int $angle
365
	 * @return Array
366
	 */
367
	protected function imageTTFCenter($image, string $text, string $font, int $size, $angle = 0): array {
368
		// Image width & height
369
		$xi = imagesx($image);
370
		$yi = imagesy($image);
371
372
		// bounding box
373
		$box = imagettfbbox($size, $angle, $font, $text);
374
375
		// imagettfbbox can return negative int
376
		$xr = abs(max($box[2], $box[4]));
377
		$yr = abs(max($box[5], $box[7]));
378
379
		// calculate bottom left placement
380
		$x = intval(($xi - $xr) / 2);
381
		$y = intval(($yi + $yr) / 2);
382
383
		return array($x, $y);
384
	}
385
386
	/**
387
	 * Calculate steps between two Colors
388
	 * @param object Color $steps start color
389
	 * @param object Color $ends end color
390
	 * @return array [r,g,b] steps for each color to go from $steps to $ends
391
	 */
392
	private function stepCalc($steps, $ends) {
393
		$step = array();
394
		$step[0] = ($ends[1]->r - $ends[0]->r) / $steps;
395
		$step[1] = ($ends[1]->g - $ends[0]->g) / $steps;
396
		$step[2] = ($ends[1]->b - $ends[0]->b) / $steps;
397
		return $step;
398
	}
399
400
	/**
401
	 * Convert a string to an integer evenly
402
	 * @param string $hash the text to parse
0 ignored issues
show
Bug introduced by
There is no parameter named $hash. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
403
	 * @param int $maximum the maximum range
0 ignored issues
show
Bug introduced by
There is no parameter named $maximum. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
404
	 * @return int between 0 and $maximum
405
	 */
406
	private function mixPalette($steps, $color1, $color2) {
407
		$count = $steps + 1;
0 ignored issues
show
Unused Code introduced by
$count is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
408
		$palette = array($color1);
409
		$step = $this->stepCalc($steps, [$color1, $color2]);
410
		for ($i = 1; $i < $steps; $i++) {
411
			$r = intval($color1->r + ($step[0] * $i));
412
			$g = intval($color1->g + ($step[1] * $i));
413
			$b = intval($color1->b + ($step[2] * $i));
414
			$palette[] = new Color($r, $g, $b);
415
		}
416
		return $palette;
417
	}
418
419
	/**
420
	 * Convert a string to an integer evenly
421
	 * @param string $hash the text to parse
422
	 * @param int $maximum the maximum range
423
	 * @return int between 0 and $maximum
424
	 */
425
	private function hashToInt($hash, $maximum) {
426
		$final = 0;
427
		$result = array();
428
429
		// Splitting evenly the string
430
		for ($i = 0; $i < strlen($hash); $i++) {
431
			// chars in md5 goes up to f, hex:16
432
			$result[] = intval(substr($hash, $i, 1), 16) % 16;
433
		}
434
		// Adds up all results
435
		foreach ($result as $value) {
436
			$final += $value;
437
		}
438
		// chars in md5 goes up to f, hex:16
439
		return intval($final % $maximum);
440
	}
441
442
	/**
443
	 * @param string $hash
444
	 * @return Color Object containting r g b int in the range [0, 255]
445
	 */
446
	public function avatarBackgroundColor(string $hash) {
447
		// Normalize hash
448
		$hash = strtolower($hash);
449
		
450
		// Already a md5 hash?
451
		if( preg_match('/^([0-9a-f]{4}-?){8}$/', $hash, $matches) !== 1 ) {
452
			$hash = md5($hash);
453
		}
454
455
		// Remove unwanted char
456
		$hash = preg_replace('/[^0-9a-f]+/', '', $hash);
457
458
		$red = new Color(182, 70, 157);
459
		$yellow = new Color(221, 203, 85);
460
		$blue = new Color(0, 130, 201); // Nextcloud blue
461
462
		// Number of steps to go from a color to another
463
		// 3 colors * 6 will result in 18 generated colors
464
		$steps = 6;
465
466
		$palette1 = $this->mixPalette($steps, $red, $yellow);
467
		$palette2 = $this->mixPalette($steps, $yellow, $blue);
468
		$palette3 = $this->mixPalette($steps, $blue, $red);
469
470
		$finalPalette = array_merge($palette1, $palette2, $palette3);
471
472
		return $finalPalette[$this->hashToInt($hash, $steps * 3)];
473
	}
474
475
	public function userChanged($feature, $oldValue, $newValue) {
476
		// We only change the avatar on display name changes
477
		if ($feature !== 'displayName') {
478
			return;
479
		}
480
481
		// If the avatar is not generated (so an uploaded image) we skip this
482
		if (!$this->folder->fileExists('generated')) {
483
			return;
484
		}
485
486
		$this->remove();
487
	}
488
489
}
490