Dan Brown

Added custom user avatars

...@@ -33,6 +33,9 @@ GOOGLE_APP_SECRET=false ...@@ -33,6 +33,9 @@ GOOGLE_APP_SECRET=false
33 # URL used for social login redirects, NO TRAILING SLASH 33 # URL used for social login redirects, NO TRAILING SLASH
34 APP_URL=http://bookstack.dev 34 APP_URL=http://bookstack.dev
35 35
36 +# External services
37 +USE_GRAVATAR=true
38 +
36 # Mail settings 39 # Mail settings
37 MAIL_DRIVER=smtp 40 MAIL_DRIVER=smtp
38 MAIL_HOST=localhost 41 MAIL_HOST=localhost
......
...@@ -33,7 +33,7 @@ class ImageController extends Controller ...@@ -33,7 +33,7 @@ class ImageController extends Controller
33 33
34 34
35 /** 35 /**
36 - * Get all gallery images, Paginated 36 + * Get all images for a specific type, Paginated
37 * @param int $page 37 * @param int $page
38 * @return \Illuminate\Http\JsonResponse 38 * @return \Illuminate\Http\JsonResponse
39 */ 39 */
...@@ -43,6 +43,17 @@ class ImageController extends Controller ...@@ -43,6 +43,17 @@ class ImageController extends Controller
43 return response()->json($imgData); 43 return response()->json($imgData);
44 } 44 }
45 45
46 + /**
47 + * Get all images for a user.
48 + * @param int $page
49 + * @return \Illuminate\Http\JsonResponse
50 + */
51 + public function getAllForUserType($page = 0)
52 + {
53 + $imgData = $this->imageRepo->getPaginatedByType('user', $page, 24, $this->currentUser->id);
54 + return response()->json($imgData);
55 + }
56 +
46 57
47 /** 58 /**
48 * Handles image uploads for use on pages. 59 * Handles image uploads for use on pages.
......
...@@ -62,7 +62,7 @@ class UserController extends Controller ...@@ -62,7 +62,7 @@ class UserController extends Controller
62 $this->checkPermission('user-create'); 62 $this->checkPermission('user-create');
63 $this->validate($request, [ 63 $this->validate($request, [
64 'name' => 'required', 64 'name' => 'required',
65 - 'email' => 'required|email', 65 + 'email' => 'required|email|unique:users,email',
66 'password' => 'required|min:5', 66 'password' => 'required|min:5',
67 'password-confirm' => 'required|same:password', 67 'password-confirm' => 'required|same:password',
68 'role' => 'required|exists:roles,id' 68 'role' => 'required|exists:roles,id'
......
...@@ -57,6 +57,9 @@ Route::group(['middleware' => 'auth'], function () { ...@@ -57,6 +57,9 @@ Route::group(['middleware' => 'auth'], function () {
57 57
58 // Image routes 58 // Image routes
59 Route::group(['prefix' => 'images'], function() { 59 Route::group(['prefix' => 'images'], function() {
60 + // Get for user images
61 + Route::get('/user/all', 'ImageController@getAllForUserType');
62 + Route::get('/user/all/{page}', 'ImageController@getAllForUserType');
60 // Standard get, update and deletion for all types 63 // Standard get, update and deletion for all types
61 Route::get('/thumb/{id}/{width}/{height}/{crop}', 'ImageController@getThumbnail'); 64 Route::get('/thumb/{id}/{width}/{height}/{crop}', 'ImageController@getThumbnail');
62 Route::put('/update/{imageId}', 'ImageController@update'); 65 Route::put('/update/{imageId}', 'ImageController@update');
......
...@@ -3,9 +3,10 @@ ...@@ -3,9 +3,10 @@
3 namespace BookStack; 3 namespace BookStack;
4 4
5 5
6 +use Illuminate\Database\Eloquent\Model;
6 use Images; 7 use Images;
7 8
8 -class Image 9 +class Image extends Model
9 { 10 {
10 use Ownable; 11 use Ownable;
11 12
...@@ -16,9 +17,10 @@ class Image ...@@ -16,9 +17,10 @@ class Image
16 * @param int $width 17 * @param int $width
17 * @param int $height 18 * @param int $height
18 * @param bool|false $hardCrop 19 * @param bool|false $hardCrop
20 + * @return string
19 */ 21 */
20 public function getThumb($width, $height, $hardCrop = false) 22 public function getThumb($width, $height, $hardCrop = false)
21 { 23 {
22 - Images::getThumbnail($this, $width, $height, $hardCrop); 24 + return Images::getThumbnail($this, $width, $height, $hardCrop);
23 } 25 }
24 } 26 }
......
...@@ -17,7 +17,7 @@ class ImageRepo ...@@ -17,7 +17,7 @@ class ImageRepo
17 * @param Image $image 17 * @param Image $image
18 * @param ImageService $imageService 18 * @param ImageService $imageService
19 */ 19 */
20 - public function __construct(Image $image,ImageService $imageService) 20 + public function __construct(Image $image, ImageService $imageService)
21 { 21 {
22 $this->image = $image; 22 $this->image = $image;
23 $this->imageService = $imageService; 23 $this->imageService = $imageService;
...@@ -40,12 +40,18 @@ class ImageRepo ...@@ -40,12 +40,18 @@ class ImageRepo
40 * @param string $type 40 * @param string $type
41 * @param int $page 41 * @param int $page
42 * @param int $pageSize 42 * @param int $pageSize
43 + * @param bool|int $userFilter
43 * @return array 44 * @return array
44 */ 45 */
45 - public function getPaginatedByType($type, $page = 0, $pageSize = 24) 46 + public function getPaginatedByType($type, $page = 0, $pageSize = 24, $userFilter = false)
46 { 47 {
47 - $images = $this->image->where('type', '=', strtolower($type)) 48 + $images = $this->image->where('type', '=', strtolower($type));
48 - ->orderBy('created_at', 'desc')->skip($pageSize * $page)->take($pageSize + 1)->get(); 49 +
50 + if ($userFilter !== false) {
51 + $images = $images->where('created_by', '=', $userFilter);
52 + }
53 +
54 + $images = $images->orderBy('created_at', 'desc')->skip($pageSize * $page)->take($pageSize + 1)->get();
49 $hasMore = count($images) > $pageSize; 55 $hasMore = count($images) > $pageSize;
50 56
51 $returnImages = $images->take(24); 57 $returnImages = $images->take(24);
...@@ -67,7 +73,7 @@ class ImageRepo ...@@ -67,7 +73,7 @@ class ImageRepo
67 */ 73 */
68 public function saveNew(UploadedFile $uploadFile, $type) 74 public function saveNew(UploadedFile $uploadFile, $type)
69 { 75 {
70 - $image = $this->imageService->saveNew($this->image, $uploadFile, $type); 76 + $image = $this->imageService->saveNewFromUpload($uploadFile, $type);
71 $this->loadThumbs($image); 77 $this->loadThumbs($image);
72 return $image; 78 return $image;
73 } 79 }
......
1 <?php namespace BookStack\Services; 1 <?php namespace BookStack\Services;
2 2
3 use BookStack\Image; 3 use BookStack\Image;
4 +use BookStack\User;
4 use Intervention\Image\ImageManager; 5 use Intervention\Image\ImageManager;
5 use Illuminate\Contracts\Filesystem\Factory as FileSystem; 6 use Illuminate\Contracts\Filesystem\Factory as FileSystem;
6 use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance; 7 use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
...@@ -34,11 +35,48 @@ class ImageService ...@@ -34,11 +35,48 @@ class ImageService
34 $this->cache = $cache; 35 $this->cache = $cache;
35 } 36 }
36 37
37 - public function saveNew(Image $image, UploadedFile $uploadedFile, $type) 38 + /**
39 + * Saves a new image from an upload.
40 + * @param UploadedFile $uploadedFile
41 + * @param string $type
42 + * @return mixed
43 + */
44 + public function saveNewFromUpload(UploadedFile $uploadedFile, $type)
45 + {
46 + $imageName = $uploadedFile->getClientOriginalName();
47 + $imageData = file_get_contents($uploadedFile->getRealPath());
48 + return $this->saveNew($imageName, $imageData, $type);
49 + }
50 +
51 +
52 + /**
53 + * Gets an image from url and saves it to the database.
54 + * @param $url
55 + * @param string $type
56 + * @param bool|string $imageName
57 + * @return mixed
58 + * @throws \Exception
59 + */
60 + private function saveNewFromUrl($url, $type, $imageName = false)
61 + {
62 + $imageName = $imageName ? $imageName : basename($url);
63 + $imageData = file_get_contents($url);
64 + if($imageData === false) throw new \Exception('Cannot get image from ' . $url);
65 + return $this->saveNew($imageName, $imageData, $type);
66 + }
67 +
68 + /**
69 + * Saves a new image
70 + * @param string $imageName
71 + * @param string $imageData
72 + * @param string $type
73 + * @return Image
74 + */
75 + private function saveNew($imageName, $imageData, $type)
38 { 76 {
39 $storage = $this->getStorage(); 77 $storage = $this->getStorage();
40 $secureUploads = Setting::get('app-secure-images'); 78 $secureUploads = Setting::get('app-secure-images');
41 - $imageName = str_replace(' ', '-', $uploadedFile->getClientOriginalName()); 79 + $imageName = str_replace(' ', '-', $imageName);
42 80
43 if ($secureUploads) $imageName = str_random(16) . '-' . $imageName; 81 if ($secureUploads) $imageName = str_random(16) . '-' . $imageName;
44 82
...@@ -48,10 +86,10 @@ class ImageService ...@@ -48,10 +86,10 @@ class ImageService
48 } 86 }
49 $fullPath = $imagePath . $imageName; 87 $fullPath = $imagePath . $imageName;
50 88
51 - $storage->put($fullPath, file_get_contents($uploadedFile->getRealPath())); 89 + $storage->put($fullPath, $imageData);
52 90
53 $userId = auth()->user()->id; 91 $userId = auth()->user()->id;
54 - $image = $image->forceCreate([ 92 + $image = Image::forceCreate([
55 'name' => $imageName, 93 'name' => $imageName,
56 'path' => $fullPath, 94 'path' => $fullPath,
57 'url' => $this->getPublicUrl($fullPath), 95 'url' => $this->getPublicUrl($fullPath),
...@@ -138,6 +176,26 @@ class ImageService ...@@ -138,6 +176,26 @@ class ImageService
138 } 176 }
139 177
140 /** 178 /**
179 + * Save a gravatar image and set a the profile image for a user.
180 + * @param User $user
181 + * @param int $size
182 + * @return mixed
183 + */
184 + public function saveUserGravatar(User $user, $size = 500)
185 + {
186 + if (!env('USE_GRAVATAR', false)) return false;
187 + $emailHash = md5(strtolower(trim($user->email)));
188 + $url = 'http://www.gravatar.com/avatar/' . $emailHash . '?s=' . $size . '&d=identicon';
189 + $imageName = str_replace(' ', '-', $user->name . '-gravatar.png');
190 + $image = $this->saveNewFromUrl($url, 'user', $imageName);
191 + $image->created_by = $user->id;
192 + $image->save();
193 + $user->avatar()->associate($image);
194 + $user->save();
195 + return $image;
196 + }
197 +
198 + /**
141 * Get the storage that will be used for storing images. 199 * Get the storage that will be used for storing images.
142 * @return FileSystemInstance 200 * @return FileSystemInstance
143 */ 201 */
......
...@@ -24,7 +24,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon ...@@ -24,7 +24,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
24 * 24 *
25 * @var array 25 * @var array
26 */ 26 */
27 - protected $fillable = ['name', 'email', 'password']; 27 + protected $fillable = ['name', 'email', 'password', 'image_id'];
28 28
29 /** 29 /**
30 * The attributes excluded from the model's JSON form. 30 * The attributes excluded from the model's JSON form.
...@@ -145,8 +145,17 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon ...@@ -145,8 +145,17 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
145 */ 145 */
146 public function getAvatar($size = 50) 146 public function getAvatar($size = 50)
147 { 147 {
148 - $emailHash = md5(strtolower(trim($this->email))); 148 + if ($this->image_id === 0 || $this->image_id === null) return '/user_avatar.png';
149 - return '//www.gravatar.com/avatar/' . $emailHash . '?s=' . $size . '&d=identicon'; 149 + return $this->avatar->getThumb($size, $size, true);
150 + }
151 +
152 + /**
153 + * Get the avatar for the user.
154 + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
155 + */
156 + public function avatar()
157 + {
158 + return $this->belongsTo('BookStack\Image', 'image_id');
150 } 159 }
151 160
152 /** 161 /**
......
1 +<?php
2 +
3 +use Illuminate\Database\Schema\Blueprint;
4 +use Illuminate\Database\Migrations\Migration;
5 +
6 +class AddUserAvatars extends Migration
7 +{
8 + /**
9 + * Run the migrations.
10 + *
11 + * @return void
12 + */
13 + public function up()
14 + {
15 + Schema::table('users', function (Blueprint $table) {
16 + $table->integer('image_id')->default(0);
17 + });
18 + }
19 +
20 + /**
21 + * Reverse the migrations.
22 + *
23 + * @return void
24 + */
25 + public function down()
26 + {
27 + Schema::table('users', function (Blueprint $table) {
28 + $table->dropColumn('image_id');
29 + });
30 + }
31 +}
...@@ -80,15 +80,6 @@ ...@@ -80,15 +80,6 @@
80 imageType: { 80 imageType: {
81 type: String, 81 type: String,
82 required: true 82 required: true
83 - },
84 - resizeWidth: {
85 - type: String
86 - },
87 - resizeHeight: {
88 - type: String
89 - },
90 - resizeCrop: {
91 - type: Boolean
92 } 83 }
93 }, 84 },
94 85
...@@ -137,21 +128,7 @@ ...@@ -137,21 +128,7 @@
137 }, 128 },
138 129
139 returnCallback: function (image) { 130 returnCallback: function (image) {
140 - var _this = this; 131 + this.callback(image);
141 - var isResized = _this.resizeWidth && _this.resizeHeight;
142 -
143 - if (!isResized) {
144 - _this.callback(image);
145 - return;
146 - }
147 -
148 - var cropped = _this.resizeCrop ? 'true' : 'false';
149 - var requestString = '/images/thumb/' + image.id + '/' + _this.resizeWidth + '/' + _this.resizeHeight + '/' + cropped;
150 - _this.$http.get(requestString, function(data) {
151 - image.thumbs.custom = data.url;
152 - _this.callback(image);
153 - });
154 -
155 }, 132 },
156 133
157 imageClick: function (image) { 134 imageClick: function (image) {
......
...@@ -7,31 +7,89 @@ ...@@ -7,31 +7,89 @@
7 </div> 7 </div>
8 <button class="button" type="button" @click="showImageManager">Select Image</button> 8 <button class="button" type="button" @click="showImageManager">Select Image</button>
9 <br> 9 <br>
10 - <button class="text-button" @click="reset" type="button">Reset</button> <span class="sep">|</span> <button class="text-button neg" v-on:click="remove" type="button">Remove</button> 10 + <button class="text-button" @click="reset" type="button">Reset</button> <span v-show="showRemove" class="sep">|</span> <button v-show="showRemove" class="text-button neg" @click="remove" type="button">Remove</button>
11 - <input type="hidden" :name="name" :id="name" v-model="image"> 11 + <input type="hidden" :name="name" :id="name" v-model="value">
12 </div> 12 </div>
13 </template> 13 </template>
14 14
15 <script> 15 <script>
16 module.exports = { 16 module.exports = {
17 - props: ['currentImage', 'name', 'imageClass', 'defaultImage'], 17 + props: {
18 + currentImage: {
19 + required: true,
20 + type: String
21 + },
22 + currentId: {
23 + required: false,
24 + default: 'false',
25 + type: String
26 + },
27 + name: {
28 + required: true,
29 + type: String
30 + },
31 + defaultImage: {
32 + required: true,
33 + type: String
34 + },
35 + imageClass: {
36 + required: true,
37 + type: String
38 + },
39 + resizeWidth: {
40 + type: String
41 + },
42 + resizeHeight: {
43 + type: String
44 + },
45 + resizeCrop: {
46 + type: Boolean
47 + },
48 + showRemove: {
49 + type: Boolean,
50 + default: 'true'
51 + }
52 + },
18 data: function() { 53 data: function() {
19 return { 54 return {
20 - image: this.currentImage 55 + image: this.currentImage,
56 + value: false
21 } 57 }
22 }, 58 },
59 + compiled: function() {
60 + this.value = this.currentId === 'false' ? this.currentImage : this.currentId;
61 + },
23 methods: { 62 methods: {
63 + setCurrentValue: function(imageModel, imageUrl) {
64 + this.image = imageUrl;
65 + this.value = this.currentId === 'false' ? imageUrl : imageModel.id;
66 + },
24 showImageManager: function(e) { 67 showImageManager: function(e) {
25 var _this = this; 68 var _this = this;
26 ImageManager.show(function(image) { 69 ImageManager.show(function(image) {
27 - _this.image = image.thumbs.custom || image.url; 70 + _this.updateImageFromModel(image);
28 }); 71 });
29 }, 72 },
30 reset: function() { 73 reset: function() {
31 - this.image = ''; 74 + this.setCurrentValue({id: 0}, this.defaultImage);
32 }, 75 },
33 remove: function() { 76 remove: function() {
34 this.image = 'none'; 77 this.image = 'none';
78 + },
79 + updateImageFromModel: function(model) {
80 + var _this = this;
81 + var isResized = _this.resizeWidth && _this.resizeHeight;
82 +
83 + if (!isResized) {
84 + _this.setCurrentValue(model, model.url);
85 + return;
86 + }
87 +
88 + var cropped = _this.resizeCrop ? 'true' : 'false';
89 + var requestString = '/images/thumb/' + model.id + '/' + _this.resizeWidth + '/' + _this.resizeHeight + '/' + cropped;
90 + _this.$http.get(requestString, function(data) {
91 + _this.setCurrentValue(model, data.url);
92 + });
35 } 93 }
36 } 94 }
37 }; 95 };
......
...@@ -36,6 +36,10 @@ body.dragging, body.dragging * { ...@@ -36,6 +36,10 @@ body.dragging, body.dragging * {
36 width: 40px; 36 width: 40px;
37 height: 40px; 37 height: 40px;
38 } 38 }
39 + &.large {
40 + width: 80px;
41 + height: 80px;
42 + }
39 } 43 }
40 44
41 // System wide notifications 45 // System wide notifications
......
...@@ -33,7 +33,7 @@ ...@@ -33,7 +33,7 @@
33 <div class="form-group" id="logo-control"> 33 <div class="form-group" id="logo-control">
34 <label for="setting-app-logo">Application Logo</label> 34 <label for="setting-app-logo">Application Logo</label>
35 <p class="small">This image should be 43px in height. <br>Large images will be scaled down.</p> 35 <p class="small">This image should be 43px in height. <br>Large images will be scaled down.</p>
36 - <image-picker current-image="{{ Setting::get('app-logo', '') }}" default-image="/logo.png" name="setting-app-logo" image-class="logo-image"></image-picker> 36 + <image-picker resize-height="43" resize-width="200" current-image="{{ Setting::get('app-logo', '') }}" default-image="/logo.png" name="setting-app-logo" image-class="logo-image"></image-picker>
37 </div> 37 </div>
38 </div> 38 </div>
39 </div> 39 </div>
...@@ -86,6 +86,6 @@ ...@@ -86,6 +86,6 @@
86 86
87 </div> 87 </div>
88 88
89 -<image-manager image-type="system" resize-height="43" resize-width="200"></image-manager> 89 +<image-manager image-type="system"></image-manager>
90 90
91 @stop 91 @stop
......
...@@ -19,26 +19,25 @@ ...@@ -19,26 +19,25 @@
19 19
20 20
21 <div class="container small"> 21 <div class="container small">
22 - 22 + <form action="/users/{{$user->id}}" method="post">
23 <div class="row"> 23 <div class="row">
24 <div class="col-md-6"> 24 <div class="col-md-6">
25 <h1>Edit {{ $user->id === $currentUser->id ? 'Profile' : 'User' }}</h1> 25 <h1>Edit {{ $user->id === $currentUser->id ? 'Profile' : 'User' }}</h1>
26 - <form action="/users/{{$user->id}}" method="post">
27 {!! csrf_field() !!} 26 {!! csrf_field() !!}
28 <input type="hidden" name="_method" value="put"> 27 <input type="hidden" name="_method" value="put">
29 @include('users/form', ['model' => $user]) 28 @include('users/form', ['model' => $user])
30 - </form> 29 +
31 </div> 30 </div>
32 <div class="col-md-6"> 31 <div class="col-md-6">
33 <h1>&nbsp;</h1> 32 <h1>&nbsp;</h1>
34 - <div class="shaded padded margin-top"> 33 + <div class="form-group" id="logo-control">
35 - <p> 34 + <label for="user-avatar">User Avatar</label>
36 - <img class="avatar" src="{{ $user->getAvatar(80) }}" alt="{{ $user->name }}"> 35 + <p class="small">This image should be approx 256px square.</p>
37 - </p> 36 + <image-picker resize-height="512" resize-width="512" current-image="{{ $user->getAvatar(80) }}" current-id="{{ $user->image_id }}" default-image="/user_avatar.png" name="image_id" show-remove="false" image-class="avatar large"></image-picker>
38 - <p class="text-muted">You can change your profile picture at <a href="http://en.gravatar.com/">Gravatar</a>.</p>
39 </div> 37 </div>
40 </div> 38 </div>
41 </div> 39 </div>
40 + </form>
42 41
43 <hr class="margin-top large"> 42 <hr class="margin-top large">
44 43
...@@ -80,5 +79,5 @@ ...@@ -80,5 +79,5 @@
80 </div> 79 </div>
81 80
82 <p class="margin-top large"><br></p> 81 <p class="margin-top large"><br></p>
83 - 82 + <image-manager image-type="user"></image-manager>
84 @stop 83 @stop
......
...@@ -37,3 +37,4 @@ ...@@ -37,3 +37,4 @@
37 <a href="/users" class="button muted">Cancel</a> 37 <a href="/users" class="button muted">Cancel</a>
38 <button class="button pos" type="submit">Save</button> 38 <button class="button pos" type="submit">Save</button>
39 </div> 39 </div>
40 +
......