Skip to content

Commit ed7beb9

Browse files
authored
Merge pull request #6876 from nextcloud/always_img_avatar
Always generate avatar
2 parents 79ed0d1 + 66f523e commit ed7beb9

File tree

4 files changed

+241
-39
lines changed

4 files changed

+241
-39
lines changed

core/Controller/AvatarController.php

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ public function __construct($appName,
111111
$this->timeFactory = $timeFactory;
112112
}
113113

114+
115+
116+
114117
/**
115118
* @NoAdminRequired
116119
* @NoCSRFRequired
@@ -133,19 +136,10 @@ public function getAvatar($userId, $size) {
133136
$resp = new FileDisplayResponse($avatar,
134137
Http::STATUS_OK,
135138
['Content-Type' => $avatar->getMimeType()]);
136-
} catch (NotFoundException $e) {
137-
$user = $this->userManager->get($userId);
138-
$resp = new JSONResponse([
139-
'data' => [
140-
'displayname' => $user->getDisplayName(),
141-
],
142-
]);
143139
} catch (\Exception $e) {
144-
$resp = new JSONResponse([
145-
'data' => [
146-
'displayname' => $userId,
147-
],
148-
]);
140+
$resp = new Http\Response();
141+
$resp->setStatus(Http::STATUS_NOT_FOUND);
142+
return $resp;
149143
}
150144

151145
// Let cache this!

lib/private/Avatar.php

Lines changed: 196 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -124,20 +124,27 @@ public function set ($data) {
124124
$type = 'jpg';
125125
}
126126
if ($type !== 'jpg' && $type !== 'png') {
127-
throw new \Exception($this->l->t("Unknown filetype"));
127+
throw new \Exception($this->l->t('Unknown filetype'));
128128
}
129129

130130
if (!$img->valid()) {
131-
throw new \Exception($this->l->t("Invalid image"));
131+
throw new \Exception($this->l->t('Invalid image'));
132132
}
133133

134134
if (!($img->height() === $img->width())) {
135-
throw new NotSquareException($this->l->t("Avatar image is not square"));
135+
throw new NotSquareException($this->l->t('Avatar image is not square'));
136136
}
137137

138138
$this->remove();
139139
$file = $this->folder->newFile('avatar.'.$type);
140140
$file->putContent($data);
141+
142+
try {
143+
$generated = $this->folder->getFile('generated');
144+
$generated->delete();
145+
} catch (NotFoundException $e) {
146+
//
147+
}
141148
$this->user->triggerChange('avatar', $file);
142149
}
143150

@@ -146,16 +153,13 @@ public function set ($data) {
146153
* @return void
147154
*/
148155
public function remove () {
149-
$regex = '/^avatar\.([0-9]+\.)?(jpg|png)$/';
150156
$avatars = $this->folder->getDirectoryListing();
151157

152158
$this->config->setUserValue($this->user->getUID(), 'avatar', 'version',
153159
(int)$this->config->getUserValue($this->user->getUID(), 'avatar', 'version', 0) + 1);
154160

155161
foreach ($avatars as $avatar) {
156-
if (preg_match($regex, $avatar->getName())) {
157-
$avatar->delete();
158-
}
162+
$avatar->delete();
159163
}
160164
$this->user->triggerChange('avatar', '');
161165
}
@@ -164,7 +168,16 @@ public function remove () {
164168
* @inheritdoc
165169
*/
166170
public function getFile($size) {
167-
$ext = $this->getExtension();
171+
try {
172+
$ext = $this->getExtension();
173+
} catch (NotFoundException $e) {
174+
$data = $this->generateAvatar($this->user->getDisplayName(), 1024);
175+
$avatar = $this->folder->newFile('avatar.png');
176+
$avatar->putContent($data);
177+
$ext = 'png';
178+
179+
$this->folder->newFile('generated');
180+
}
168181

169182
if ($size === -1) {
170183
$path = 'avatar.' . $ext;
@@ -179,19 +192,26 @@ public function getFile($size) {
179192
throw new NotFoundException;
180193
}
181194

182-
$avatar = new OC_Image();
183-
/** @var ISimpleFile $file */
184-
$file = $this->folder->getFile('avatar.' . $ext);
185-
$avatar->loadFromData($file->getContent());
186-
if ($size !== -1) {
195+
if ($this->folder->fileExists('generated')) {
196+
$data = $this->generateAvatar($this->user->getDisplayName(), $size);
197+
198+
} else {
199+
$avatar = new OC_Image();
200+
/** @var ISimpleFile $file */
201+
$file = $this->folder->getFile('avatar.' . $ext);
202+
$avatar->loadFromData($file->getContent());
187203
$avatar->resize($size);
204+
$data = $avatar->data();
188205
}
206+
189207
try {
190208
$file = $this->folder->newFile($path);
191-
$file->putContent($avatar->data());
209+
$file->putContent($data);
192210
} catch (NotPermittedException $e) {
193211
$this->logger->error('Failed to save avatar for ' . $this->user->getUID());
212+
throw new NotFoundException();
194213
}
214+
195215
}
196216

197217
return $file;
@@ -211,4 +231,166 @@ private function getExtension() {
211231
}
212232
throw new NotFoundException;
213233
}
234+
235+
/**
236+
* @param string $userDisplayName
237+
* @param int $size
238+
* @return string
239+
*/
240+
private function generateAvatar($userDisplayName, $size) {
241+
$text = strtoupper(substr($userDisplayName, 0, 1));
242+
$backgroundColor = $this->avatarBackgroundColor($userDisplayName);
243+
244+
$im = imagecreatetruecolor($size, $size);
245+
$background = imagecolorallocate($im, $backgroundColor[0], $backgroundColor[1], $backgroundColor[2]);
246+
$white = imagecolorallocate($im, 255, 255, 255);
247+
imagefilledrectangle($im, 0, 0, $size, $size, $background);
248+
249+
$font = __DIR__ . '/../../core/fonts/OpenSans-Semibold.woff';
250+
251+
$fontSize = $size * 0.4;
252+
$box = imagettfbbox($fontSize, 0, $font, $text);
253+
254+
$x = ($size - ($box[2] - $box[0])) / 2;
255+
$y = ($size - ($box[1] - $box[7])) / 2;
256+
$x += 1;
257+
$y -= $box[7];
258+
imagettftext($im, $fontSize, 0, $x, $y, $white, $font, $text);
259+
260+
ob_start();
261+
imagepng($im);
262+
$data = ob_get_contents();
263+
ob_end_clean();
264+
265+
return $data;
266+
}
267+
268+
/**
269+
* @param int $r
270+
* @param int $g
271+
* @param int $b
272+
* @return double[] Array containing h s l in [0, 1] range
273+
*/
274+
private function rgbToHsl($r, $g, $b) {
275+
$r /= 255.0;
276+
$g /= 255.0;
277+
$b /= 255.0;
278+
279+
$max = max($r, $g, $b);
280+
$min = min($r, $g, $b);
281+
282+
283+
$h = ($max + $min) / 2.0;
284+
$l = ($max + $min) / 2.0;
285+
286+
if($max === $min) {
287+
$h = $s = 0; // Achromatic
288+
} else {
289+
$d = $max - $min;
290+
$s = $l > 0.5 ? $d / (2 - $max - $min) : $d / ($max + $min);
291+
switch($max) {
292+
case $r:
293+
$h = ($g - $b) / $d + ($g < $b ? 6 : 0);
294+
break;
295+
case $g:
296+
$h = ($b - $r) / $d + 2.0;
297+
break;
298+
case $b:
299+
$h = ($r - $g) / $d + 4.0;
300+
break;
301+
}
302+
$h /= 6.0;
303+
}
304+
return [$h, $s, $l];
305+
306+
}
307+
308+
/**
309+
* @param string $text
310+
* @return int[] Array containting r g b in the range [0, 255]
311+
*/
312+
private function avatarBackgroundColor($text) {
313+
$hash = preg_replace('/[^0-9a-f]+/', '', $text);
314+
315+
$hash = md5($hash);
316+
$hashChars = str_split($hash);
317+
318+
319+
// Init vars
320+
$result = ['0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0'];
321+
$rgb = [0, 0, 0];
322+
$sat = 0.70;
323+
$lum = 0.68;
324+
$modulo = 16;
325+
326+
327+
// Splitting evenly the string
328+
foreach($hashChars as $i => $char) {
329+
$result[$i % $modulo] .= intval($char, 16);
330+
}
331+
332+
// Converting our data into a usable rgb format
333+
// Start at 1 because 16%3=1 but 15%3=0 and makes the repartition even
334+
for($count = 1; $count < $modulo; $count++) {
335+
$rgb[$count%3] += (int)$result[$count];
336+
}
337+
338+
// Reduce values bigger than rgb requirements
339+
$rgb[0] %= 255;
340+
$rgb[1] %= 255;
341+
$rgb[2] %= 255;
342+
343+
$hsl = $this->rgbToHsl($rgb[0], $rgb[1], $rgb[2]);
344+
345+
// Classic formula to check the brightness for our eye
346+
// If too bright, lower the sat
347+
$bright = sqrt(0.299 * ($rgb[0] ** 2) + 0.587 * ($rgb[1] ** 2) + 0.114 * ($rgb[2] ** 2));
348+
if ($bright >= 200) {
349+
$sat = 0.60;
350+
}
351+
352+
return $this->hslToRgb($hsl[0], $sat, $lum);
353+
}
354+
355+
/**
356+
* @param double $h Hue in range [0, 1]
357+
* @param double $s Saturation in range [0, 1]
358+
* @param double $l Lightness in range [0, 1]
359+
* @return int[] Array containing r g b in the range [0, 255]
360+
*/
361+
private function hslToRgb($h, $s, $l){
362+
$hue2rgb = function ($p, $q, $t){
363+
if($t < 0) {
364+
$t += 1;
365+
}
366+
if($t > 1) {
367+
$t -= 1;
368+
}
369+
if($t < 1/6) {
370+
return $p + ($q - $p) * 6 * $t;
371+
}
372+
if($t < 1/2) {
373+
return $q;
374+
}
375+
if($t < 2/3) {
376+
return $p + ($q - $p) * (2/3 - $t) * 6;
377+
}
378+
return $p;
379+
};
380+
381+
if($s === 0){
382+
$r = $l;
383+
$g = $l;
384+
$b = $l; // achromatic
385+
}else{
386+
$q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s;
387+
$p = 2 * $l - $q;
388+
$r = $hue2rgb($p, $q, $h + 1/3);
389+
$g = $hue2rgb($p, $q, $h);
390+
$b = $hue2rgb($p, $q, $h - 1/3);
391+
}
392+
393+
return array(round($r * 255), round($g * 255), round($b * 255));
394+
}
395+
214396
}

tests/Core/Controller/AvatarControllerTest.php

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,7 @@ public function testGetAvatarNoAvatar() {
134134
$response = $this->avatarController->getAvatar('userId', 32);
135135

136136
//Comment out until JS is fixed
137-
//$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
138-
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
139-
$this->assertEquals('displayName', $response->getData()['data']['displayname']);
137+
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
140138
}
141139

142140
/**
@@ -167,9 +165,7 @@ public function testGetAvatarNoUser() {
167165
$response = $this->avatarController->getAvatar('userDoesNotExist', 32);
168166

169167
//Comment out until JS is fixed
170-
//$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
171-
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
172-
$this->assertEquals('userDoesNotExist', $response->getData()['data']['displayname']);
168+
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
173169
}
174170

175171
/**

tests/lib/AvatarTest.php

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
use OC\User\User;
1313
use OCP\Files\File;
1414
use OCP\Files\Folder;
15+
use OCP\Files\NotFoundException;
16+
use OCP\Files\SimpleFS\ISimpleFile;
1517
use OCP\IConfig;
1618
use OCP\IL10N;
1719
use OCP\ILogger;
@@ -49,7 +51,35 @@ public function setUp() {
4951
}
5052

5153
public function testGetNoAvatar() {
52-
$this->assertEquals(false, $this->avatar->get());
54+
$file = $this->createMock(ISimpleFile::class);
55+
$this->folder->method('newFile')
56+
->willReturn($file);
57+
58+
$this->folder->method('getFile')
59+
->will($this->returnCallback(function($path) {
60+
if ($path === 'avatar.64.png') {
61+
throw new NotFoundException();
62+
}
63+
}));
64+
$this->folder->method('fileExists')
65+
->will($this->returnCallback(function($path) {
66+
if ($path === 'generated') {
67+
return true;
68+
}
69+
return false;
70+
}));
71+
72+
$data = NULL;
73+
$file->method('putContent')
74+
->with($this->callback(function ($d) use (&$data) {
75+
$data = $d;
76+
return true;
77+
}));
78+
79+
$file->method('getContent')
80+
->willReturn($data);
81+
82+
$this->assertEquals($data, $this->avatar->get()->data());
5383
}
5484

5585
public function testGetAvatarSizeMatch() {
@@ -161,13 +191,13 @@ public function testSetAvatar() {
161191
->willReturn('avatar.32.jpg');
162192
$resizedAvatarFile->expects($this->once())->method('delete');
163193

164-
$nonAvatarFile = $this->createMock(File::class);
165-
$nonAvatarFile->method('getName')
166-
->willReturn('avatarX');
167-
$nonAvatarFile->expects($this->never())->method('delete');
168-
169194
$this->folder->method('getDirectoryListing')
170-
->willReturn([$avatarFileJPG, $avatarFilePNG, $resizedAvatarFile, $nonAvatarFile]);
195+
->willReturn([$avatarFileJPG, $avatarFilePNG, $resizedAvatarFile]);
196+
197+
$generated = $this->createMock(File::class);
198+
$this->folder->method('getFile')
199+
->with('generated')
200+
->willReturn($generated);
171201

172202
$newFile = $this->createMock(File::class);
173203
$this->folder->expects($this->once())

0 commit comments

Comments
 (0)