Dan Brown

Merge branch 'master' into release

Showing 60 changed files with 1137 additions and 247 deletions
...@@ -14,14 +14,28 @@ CACHE_DRIVER=file ...@@ -14,14 +14,28 @@ CACHE_DRIVER=file
14 SESSION_DRIVER=file 14 SESSION_DRIVER=file
15 QUEUE_DRIVER=sync 15 QUEUE_DRIVER=sync
16 16
17 +# Storage
18 +STORAGE_TYPE=local
19 +# Amazon S3 Config
20 +STORAGE_S3_KEY=false
21 +STORAGE_S3_SECRET=false
22 +STORAGE_S3_REGION=false
23 +STORAGE_S3_BUCKET=false
24 +# Storage URL
25 +# Used to prefix image urls for when using custom domains/cdns
26 +STORAGE_URL=false
27 +
17 # Social Authentication information. Defaults as off. 28 # Social Authentication information. Defaults as off.
18 GITHUB_APP_ID=false 29 GITHUB_APP_ID=false
19 GITHUB_APP_SECRET=false 30 GITHUB_APP_SECRET=false
20 GOOGLE_APP_ID=false 31 GOOGLE_APP_ID=false
21 GOOGLE_APP_SECRET=false 32 GOOGLE_APP_SECRET=false
22 -# URL for social login redirects, NO TRAILING SLASH 33 +# URL used for social login redirects, NO TRAILING SLASH
23 APP_URL=http://bookstack.dev 34 APP_URL=http://bookstack.dev
24 35
36 +# External services
37 +USE_GRAVATAR=true
38 +
25 # Mail settings 39 # Mail settings
26 MAIL_DRIVER=smtp 40 MAIL_DRIVER=smtp
27 MAIL_HOST=localhost 41 MAIL_HOST=localhost
......
...@@ -7,23 +7,7 @@ use Illuminate\Database\Eloquent\Model; ...@@ -7,23 +7,7 @@ use Illuminate\Database\Eloquent\Model;
7 abstract class Entity extends Model 7 abstract class Entity extends Model
8 { 8 {
9 9
10 - /** 10 + use Ownable;
11 - * Relation for the user that created this entity.
12 - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
13 - */
14 - public function createdBy()
15 - {
16 - return $this->belongsTo('BookStack\User', 'created_by');
17 - }
18 -
19 - /**
20 - * Relation for the user that updated this entity.
21 - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
22 - */
23 - public function updatedBy()
24 - {
25 - return $this->belongsTo('BookStack\User', 'updated_by');
26 - }
27 11
28 /** 12 /**
29 * Compares this entity to another given entity. 13 * Compares this entity to another given entity.
...@@ -97,19 +81,30 @@ abstract class Entity extends Model ...@@ -97,19 +81,30 @@ abstract class Entity extends Model
97 */ 81 */
98 public static function isA($type) 82 public static function isA($type)
99 { 83 {
100 - return static::getName() === strtolower($type); 84 + return static::getClassName() === strtolower($type);
101 } 85 }
102 86
103 /** 87 /**
104 * Gets the class name. 88 * Gets the class name.
105 * @return string 89 * @return string
106 */ 90 */
107 - public static function getName() 91 + public static function getClassName()
108 { 92 {
109 return strtolower(array_slice(explode('\\', static::class), -1, 1)[0]); 93 return strtolower(array_slice(explode('\\', static::class), -1, 1)[0]);
110 } 94 }
111 95
112 /** 96 /**
97 + *Gets a limited-length version of the entities name.
98 + * @param int $length
99 + * @return string
100 + */
101 + public function getShortName($length = 25)
102 + {
103 + if(strlen($this->name) <= $length) return $this->name;
104 + return substr($this->name, 0, $length-3) . '...';
105 + }
106 +
107 + /**
113 * Perform a full-text search on this entity. 108 * Perform a full-text search on this entity.
114 * @param string[] $fieldsToSearch 109 * @param string[] $fieldsToSearch
115 * @param string[] $terms 110 * @param string[] $terms
...@@ -123,20 +118,20 @@ abstract class Entity extends Model ...@@ -123,20 +118,20 @@ abstract class Entity extends Model
123 $termString .= $term . '* '; 118 $termString .= $term . '* ';
124 } 119 }
125 $fields = implode(',', $fieldsToSearch); 120 $fields = implode(',', $fieldsToSearch);
126 - $search = static::whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]); 121 + $termStringEscaped = \DB::connection()->getPdo()->quote($termString);
122 + $search = static::addSelect(\DB::raw('*, MATCH(name) AGAINST('.$termStringEscaped.' IN BOOLEAN MODE) AS title_relevance'));
123 + $search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]);
124 +
125 + // Add additional where terms
127 foreach ($wheres as $whereTerm) { 126 foreach ($wheres as $whereTerm) {
128 $search->where($whereTerm[0], $whereTerm[1], $whereTerm[2]); 127 $search->where($whereTerm[0], $whereTerm[1], $whereTerm[2]);
129 } 128 }
130 129
131 - if (!static::isA('book')) { 130 + // Load in relations
132 - $search = $search->with('book'); 131 + if (!static::isA('book')) $search = $search->with('book');
133 - } 132 + if (static::isA('page')) $search = $search->with('chapter');
134 -
135 - if (static::isA('page')) {
136 - $search = $search->with('chapter');
137 - }
138 133
139 - return $search->get(); 134 + return $search->orderBy('title_relevance', 'desc')->get();
140 } 135 }
141 136
142 /** 137 /**
......
...@@ -42,8 +42,10 @@ class BookController extends Controller ...@@ -42,8 +42,10 @@ class BookController extends Controller
42 public function index() 42 public function index()
43 { 43 {
44 $books = $this->bookRepo->getAllPaginated(10); 44 $books = $this->bookRepo->getAllPaginated(10);
45 - $recents = $this->signedIn ? $this->bookRepo->getRecentlyViewed(10, 0) : false; 45 + $recents = $this->signedIn ? $this->bookRepo->getRecentlyViewed(4, 0) : false;
46 - return view('books/index', ['books' => $books, 'recents' => $recents]); 46 + $popular = $this->bookRepo->getPopular(4, 0);
47 + $this->setPageTitle('Books');
48 + return view('books/index', ['books' => $books, 'recents' => $recents, 'popular' => $popular]);
47 } 49 }
48 50
49 /** 51 /**
...@@ -54,6 +56,7 @@ class BookController extends Controller ...@@ -54,6 +56,7 @@ class BookController extends Controller
54 public function create() 56 public function create()
55 { 57 {
56 $this->checkPermission('book-create'); 58 $this->checkPermission('book-create');
59 + $this->setPageTitle('Create New Book');
57 return view('books/create'); 60 return view('books/create');
58 } 61 }
59 62
...@@ -88,8 +91,9 @@ class BookController extends Controller ...@@ -88,8 +91,9 @@ class BookController extends Controller
88 public function show($slug) 91 public function show($slug)
89 { 92 {
90 $book = $this->bookRepo->getBySlug($slug); 93 $book = $this->bookRepo->getBySlug($slug);
91 - Views::add($book);
92 $bookChildren = $this->bookRepo->getChildren($book); 94 $bookChildren = $this->bookRepo->getChildren($book);
95 + Views::add($book);
96 + $this->setPageTitle($book->getShortName());
93 return view('books/show', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]); 97 return view('books/show', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]);
94 } 98 }
95 99
...@@ -103,6 +107,7 @@ class BookController extends Controller ...@@ -103,6 +107,7 @@ class BookController extends Controller
103 { 107 {
104 $this->checkPermission('book-update'); 108 $this->checkPermission('book-update');
105 $book = $this->bookRepo->getBySlug($slug); 109 $book = $this->bookRepo->getBySlug($slug);
110 + $this->setPageTitle('Edit Book ' . $book->getShortName());
106 return view('books/edit', ['book' => $book, 'current' => $book]); 111 return view('books/edit', ['book' => $book, 'current' => $book]);
107 } 112 }
108 113
...@@ -138,6 +143,7 @@ class BookController extends Controller ...@@ -138,6 +143,7 @@ class BookController extends Controller
138 { 143 {
139 $this->checkPermission('book-delete'); 144 $this->checkPermission('book-delete');
140 $book = $this->bookRepo->getBySlug($bookSlug); 145 $book = $this->bookRepo->getBySlug($bookSlug);
146 + $this->setPageTitle('Delete Book ' . $book->getShortName());
141 return view('books/delete', ['book' => $book, 'current' => $book]); 147 return view('books/delete', ['book' => $book, 'current' => $book]);
142 } 148 }
143 149
...@@ -152,9 +158,16 @@ class BookController extends Controller ...@@ -152,9 +158,16 @@ class BookController extends Controller
152 $book = $this->bookRepo->getBySlug($bookSlug); 158 $book = $this->bookRepo->getBySlug($bookSlug);
153 $bookChildren = $this->bookRepo->getChildren($book); 159 $bookChildren = $this->bookRepo->getChildren($book);
154 $books = $this->bookRepo->getAll(); 160 $books = $this->bookRepo->getAll();
161 + $this->setPageTitle('Sort Book ' . $book->getShortName());
155 return view('books/sort', ['book' => $book, 'current' => $book, 'books' => $books, 'bookChildren' => $bookChildren]); 162 return view('books/sort', ['book' => $book, 'current' => $book, 'books' => $books, 'bookChildren' => $bookChildren]);
156 } 163 }
157 164
165 + /**
166 + * Shows the sort box for a single book.
167 + * Used via AJAX when loading in extra books to a sort.
168 + * @param $bookSlug
169 + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
170 + */
158 public function getSortItem($bookSlug) 171 public function getSortItem($bookSlug)
159 { 172 {
160 $book = $this->bookRepo->getBySlug($bookSlug); 173 $book = $this->bookRepo->getBySlug($bookSlug);
......
...@@ -40,6 +40,7 @@ class ChapterController extends Controller ...@@ -40,6 +40,7 @@ class ChapterController extends Controller
40 { 40 {
41 $this->checkPermission('chapter-create'); 41 $this->checkPermission('chapter-create');
42 $book = $this->bookRepo->getBySlug($bookSlug); 42 $book = $this->bookRepo->getBySlug($bookSlug);
43 + $this->setPageTitle('Create New Chapter');
43 return view('chapters/create', ['book' => $book, 'current' => $book]); 44 return view('chapters/create', ['book' => $book, 'current' => $book]);
44 } 45 }
45 46
...@@ -79,6 +80,7 @@ class ChapterController extends Controller ...@@ -79,6 +80,7 @@ class ChapterController extends Controller
79 $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id); 80 $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
80 $sidebarTree = $this->bookRepo->getChildren($book); 81 $sidebarTree = $this->bookRepo->getChildren($book);
81 Views::add($chapter); 82 Views::add($chapter);
83 + $this->setPageTitle($chapter->getShortName());
82 return view('chapters/show', ['book' => $book, 'chapter' => $chapter, 'current' => $chapter, 'sidebarTree' => $sidebarTree]); 84 return view('chapters/show', ['book' => $book, 'chapter' => $chapter, 'current' => $chapter, 'sidebarTree' => $sidebarTree]);
83 } 85 }
84 86
...@@ -93,6 +95,7 @@ class ChapterController extends Controller ...@@ -93,6 +95,7 @@ class ChapterController extends Controller
93 $this->checkPermission('chapter-update'); 95 $this->checkPermission('chapter-update');
94 $book = $this->bookRepo->getBySlug($bookSlug); 96 $book = $this->bookRepo->getBySlug($bookSlug);
95 $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id); 97 $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
98 + $this->setPageTitle('Edit Chapter' . $chapter->getShortName());
96 return view('chapters/edit', ['book' => $book, 'chapter' => $chapter, 'current' => $chapter]); 99 return view('chapters/edit', ['book' => $book, 'chapter' => $chapter, 'current' => $chapter]);
97 } 100 }
98 101
...@@ -127,6 +130,7 @@ class ChapterController extends Controller ...@@ -127,6 +130,7 @@ class ChapterController extends Controller
127 $this->checkPermission('chapter-delete'); 130 $this->checkPermission('chapter-delete');
128 $book = $this->bookRepo->getBySlug($bookSlug); 131 $book = $this->bookRepo->getBySlug($bookSlug);
129 $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id); 132 $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
133 + $this->setPageTitle('Delete Chapter' . $chapter->getShortName());
130 return view('chapters/delete', ['book' => $book, 'chapter' => $chapter, 'current' => $chapter]); 134 return view('chapters/delete', ['book' => $book, 'chapter' => $chapter, 'current' => $chapter]);
131 } 135 }
132 136
......
...@@ -43,6 +43,15 @@ abstract class Controller extends BaseController ...@@ -43,6 +43,15 @@ abstract class Controller extends BaseController
43 } 43 }
44 44
45 /** 45 /**
46 + * Adds the page title into the view.
47 + * @param $title
48 + */
49 + public function setPageTitle($title)
50 + {
51 + view()->share('pageTitle', $title);
52 + }
53 +
54 + /**
46 * Checks for a permission. 55 * Checks for a permission.
47 * 56 *
48 * @param $permissionName 57 * @param $permissionName
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
2 2
3 namespace BookStack\Http\Controllers; 3 namespace BookStack\Http\Controllers;
4 4
5 +use BookStack\Repos\ImageRepo;
5 use Illuminate\Filesystem\Filesystem as File; 6 use Illuminate\Filesystem\Filesystem as File;
6 use Illuminate\Http\Request; 7 use Illuminate\Http\Request;
7 use Illuminate\Support\Facades\Auth; 8 use Illuminate\Support\Facades\Auth;
...@@ -14,125 +15,78 @@ class ImageController extends Controller ...@@ -14,125 +15,78 @@ class ImageController extends Controller
14 { 15 {
15 protected $image; 16 protected $image;
16 protected $file; 17 protected $file;
18 + protected $imageRepo;
17 19
18 /** 20 /**
19 * ImageController constructor. 21 * ImageController constructor.
20 * @param Image $image 22 * @param Image $image
21 * @param File $file 23 * @param File $file
24 + * @param ImageRepo $imageRepo
22 */ 25 */
23 - public function __construct(Image $image, File $file) 26 + public function __construct(Image $image, File $file, ImageRepo $imageRepo)
24 { 27 {
25 $this->image = $image; 28 $this->image = $image;
26 $this->file = $file; 29 $this->file = $file;
30 + $this->imageRepo = $imageRepo;
27 parent::__construct(); 31 parent::__construct();
28 } 32 }
29 33
30 34
31 /** 35 /**
32 - * Get all images, Paginated 36 + * Get all images for a specific type, Paginated
33 * @param int $page 37 * @param int $page
34 * @return \Illuminate\Http\JsonResponse 38 * @return \Illuminate\Http\JsonResponse
35 */ 39 */
36 - public function getAll($page = 0) 40 + public function getAllByType($type, $page = 0)
37 { 41 {
38 - $pageSize = 30; 42 + $imgData = $this->imageRepo->getPaginatedByType($type, $page);
39 - $images = $this->image->orderBy('created_at', 'desc') 43 + return response()->json($imgData);
40 - ->skip($page * $pageSize)->take($pageSize)->get();
41 - foreach ($images as $image) {
42 - $this->loadSizes($image);
43 - }
44 - $hasMore = $this->image->orderBy('created_at', 'desc')
45 - ->skip(($page + 1) * $pageSize)->take($pageSize)->count() > 0;
46 - return response()->json([
47 - 'images' => $images,
48 - 'hasMore' => $hasMore
49 - ]);
50 } 44 }
51 45
52 /** 46 /**
53 - * Loads the standard thumbnail sizes for an image. 47 + * Get all images for a user.
54 - * @param Image $image 48 + * @param int $page
55 - */ 49 + * @return \Illuminate\Http\JsonResponse
56 - private function loadSizes(Image $image)
57 - {
58 - $image->thumbnail = $this->getThumbnail($image, 150, 150);
59 - $image->display = $this->getThumbnail($image, 840, 0, true);
60 - }
61 -
62 - /**
63 - * Get the thumbnail for an image.
64 - * If $keepRatio is true only the width will be used.
65 - * @param $image
66 - * @param int $width
67 - * @param int $height
68 - * @param bool $keepRatio
69 - * @return string
70 */ 50 */
71 - public function getThumbnail($image, $width = 220, $height = 220, $keepRatio = false) 51 + public function getAllForUserType($page = 0)
72 { 52 {
73 - $explodedPath = explode('/', $image->url); 53 + $imgData = $this->imageRepo->getPaginatedByType('user', $page, 24, $this->currentUser->id);
74 - $dirPrefix = $keepRatio ? 'scaled-' : 'thumbs-'; 54 + return response()->json($imgData);
75 - array_splice($explodedPath, 4, 0, [$dirPrefix . $width . '-' . $height]);
76 - $thumbPath = implode('/', $explodedPath);
77 - $thumbFilePath = public_path() . $thumbPath;
78 -
79 - // Return the thumbnail url path if already exists
80 - if (file_exists($thumbFilePath)) {
81 - return $thumbPath;
82 - }
83 -
84 - // Otherwise create the thumbnail
85 - $thumb = ImageTool::make(public_path() . $image->url);
86 - if($keepRatio) {
87 - $thumb->resize($width, null, function ($constraint) {
88 - $constraint->aspectRatio();
89 - $constraint->upsize();
90 - });
91 - } else {
92 - $thumb->fit($width, $height);
93 - }
94 -
95 - // Create thumbnail folder if it does not exist
96 - if (!file_exists(dirname($thumbFilePath))) {
97 - mkdir(dirname($thumbFilePath), 0775, true);
98 } 55 }
99 56
100 - //Save Thumbnail
101 - $thumb->save($thumbFilePath);
102 - return $thumbPath;
103 - }
104 57
105 /** 58 /**
106 * Handles image uploads for use on pages. 59 * Handles image uploads for use on pages.
60 + * @param string $type
107 * @param Request $request 61 * @param Request $request
108 * @return \Illuminate\Http\JsonResponse 62 * @return \Illuminate\Http\JsonResponse
109 */ 63 */
110 - public function upload(Request $request) 64 + public function uploadByType($type, Request $request)
111 { 65 {
112 $this->checkPermission('image-create'); 66 $this->checkPermission('image-create');
113 $this->validate($request, [ 67 $this->validate($request, [
114 'file' => 'image|mimes:jpeg,gif,png' 68 'file' => 'image|mimes:jpeg,gif,png'
115 ]); 69 ]);
116 - $imageUpload = $request->file('file');
117 70
118 - $name = str_replace(' ', '-', $imageUpload->getClientOriginalName()); 71 + $imageUpload = $request->file('file');
119 - $storageName = substr(sha1(time()), 0, 10) . '-' . $name; 72 + $image = $this->imageRepo->saveNew($imageUpload, $type);
120 - $imagePath = '/uploads/images/' . Date('Y-m-M') . '/'; 73 + return response()->json($image);
121 - $storagePath = public_path() . $imagePath;
122 - $fullPath = $storagePath . $storageName;
123 - while (file_exists($fullPath)) {
124 - $storageName = substr(sha1(rand()), 0, 3) . $storageName;
125 - $fullPath = $storagePath . $storageName;
126 } 74 }
127 - $imageUpload->move($storagePath, $storageName); 75 +
128 - // Create and save image object 76 + /**
129 - $this->image->name = $name; 77 + * Generate a sized thumbnail for an image.
130 - $this->image->url = $imagePath . $storageName; 78 + * @param $id
131 - $this->image->created_by = auth()->user()->id; 79 + * @param $width
132 - $this->image->updated_by = auth()->user()->id; 80 + * @param $height
133 - $this->image->save(); 81 + * @param $crop
134 - $this->loadSizes($this->image); 82 + * @return \Illuminate\Http\JsonResponse
135 - return response()->json($this->image); 83 + */
84 + public function getThumbnail($id, $width, $height, $crop)
85 + {
86 + $this->checkPermission('image-create');
87 + $image = $this->imageRepo->getById($id);
88 + $thumbnailUrl = $this->imageRepo->getThumbnail($image, $width, $height, $crop == 'false');
89 + return response()->json(['url' => $thumbnailUrl]);
136 } 90 }
137 91
138 /** 92 /**
...@@ -147,13 +101,12 @@ class ImageController extends Controller ...@@ -147,13 +101,12 @@ class ImageController extends Controller
147 $this->validate($request, [ 101 $this->validate($request, [
148 'name' => 'required|min:2|string' 102 'name' => 'required|min:2|string'
149 ]); 103 ]);
150 - $image = $this->image->findOrFail($imageId); 104 + $image = $this->imageRepo->getById($imageId);
151 - $image->fill($request->all()); 105 + $image = $this->imageRepo->updateImageDetails($image, $request->all());
152 - $image->save(); 106 + return response()->json($image);
153 - $this->loadSizes($image);
154 - return response()->json($this->image);
155 } 107 }
156 108
109 +
157 /** 110 /**
158 * Deletes an image and all thumbnail/image files 111 * Deletes an image and all thumbnail/image files
159 * @param PageRepo $pageRepo 112 * @param PageRepo $pageRepo
...@@ -164,41 +117,18 @@ class ImageController extends Controller ...@@ -164,41 +117,18 @@ class ImageController extends Controller
164 public function destroy(PageRepo $pageRepo, Request $request, $id) 117 public function destroy(PageRepo $pageRepo, Request $request, $id)
165 { 118 {
166 $this->checkPermission('image-delete'); 119 $this->checkPermission('image-delete');
167 - $image = $this->image->findOrFail($id); 120 + $image = $this->imageRepo->getById($id);
168 121
169 // Check if this image is used on any pages 122 // Check if this image is used on any pages
170 - $pageSearch = $pageRepo->searchForImage($image->url);
171 $isForced = ($request->has('force') && ($request->get('force') === 'true') || $request->get('force') === true); 123 $isForced = ($request->has('force') && ($request->get('force') === 'true') || $request->get('force') === true);
172 - if ($pageSearch !== false && !$isForced) { 124 + if (!$isForced) {
125 + $pageSearch = $pageRepo->searchForImage($image->url);
126 + if ($pageSearch !== false) {
173 return response()->json($pageSearch, 400); 127 return response()->json($pageSearch, 400);
174 } 128 }
175 -
176 - // Delete files
177 - $folder = public_path() . dirname($image->url);
178 - $fileName = basename($image->url);
179 -
180 - // Delete thumbnails
181 - foreach (glob($folder . '/*') as $file) {
182 - if (is_dir($file)) {
183 - $thumbName = $file . '/' . $fileName;
184 - if (file_exists($file)) {
185 - unlink($thumbName);
186 - }
187 - // Remove thumb folder if empty
188 - if (count(glob($file . '/*')) === 0) {
189 - rmdir($file);
190 - }
191 - }
192 } 129 }
193 130
194 - // Delete file and database entry 131 + $this->imageRepo->destroyImage($image);
195 - unlink($folder . '/' . $fileName);
196 - $image->delete();
197 -
198 - // Delete parent folder if empty
199 - if (count(glob($folder . '/*')) === 0) {
200 - rmdir($folder);
201 - }
202 return response()->json('Image Deleted'); 132 return response()->json('Image Deleted');
203 } 133 }
204 134
......
...@@ -46,6 +46,7 @@ class PageController extends Controller ...@@ -46,6 +46,7 @@ class PageController extends Controller
46 $this->checkPermission('page-create'); 46 $this->checkPermission('page-create');
47 $book = $this->bookRepo->getBySlug($bookSlug); 47 $book = $this->bookRepo->getBySlug($bookSlug);
48 $chapter = $chapterSlug ? $this->chapterRepo->getBySlug($chapterSlug, $book->id) : false; 48 $chapter = $chapterSlug ? $this->chapterRepo->getBySlug($chapterSlug, $book->id) : false;
49 + $this->setPageTitle('Create New Page');
49 return view('pages/create', ['book' => $book, 'chapter' => $chapter]); 50 return view('pages/create', ['book' => $book, 'chapter' => $chapter]);
50 } 51 }
51 52
...@@ -89,6 +90,7 @@ class PageController extends Controller ...@@ -89,6 +90,7 @@ class PageController extends Controller
89 $page = $this->pageRepo->getBySlug($pageSlug, $book->id); 90 $page = $this->pageRepo->getBySlug($pageSlug, $book->id);
90 $sidebarTree = $this->bookRepo->getChildren($book); 91 $sidebarTree = $this->bookRepo->getChildren($book);
91 Views::add($page); 92 Views::add($page);
93 + $this->setPageTitle($page->getShortName());
92 return view('pages/show', ['page' => $page, 'book' => $book, 'current' => $page, 'sidebarTree' => $sidebarTree]); 94 return view('pages/show', ['page' => $page, 'book' => $book, 'current' => $page, 'sidebarTree' => $sidebarTree]);
93 } 95 }
94 96
...@@ -104,6 +106,7 @@ class PageController extends Controller ...@@ -104,6 +106,7 @@ class PageController extends Controller
104 $this->checkPermission('page-update'); 106 $this->checkPermission('page-update');
105 $book = $this->bookRepo->getBySlug($bookSlug); 107 $book = $this->bookRepo->getBySlug($bookSlug);
106 $page = $this->pageRepo->getBySlug($pageSlug, $book->id); 108 $page = $this->pageRepo->getBySlug($pageSlug, $book->id);
109 + $this->setPageTitle('Editing Page ' . $page->getShortName());
107 return view('pages/edit', ['page' => $page, 'book' => $book, 'current' => $page]); 110 return view('pages/edit', ['page' => $page, 'book' => $book, 'current' => $page]);
108 } 111 }
109 112
...@@ -148,6 +151,7 @@ class PageController extends Controller ...@@ -148,6 +151,7 @@ class PageController extends Controller
148 $this->checkPermission('page-delete'); 151 $this->checkPermission('page-delete');
149 $book = $this->bookRepo->getBySlug($bookSlug); 152 $book = $this->bookRepo->getBySlug($bookSlug);
150 $page = $this->pageRepo->getBySlug($pageSlug, $book->id); 153 $page = $this->pageRepo->getBySlug($pageSlug, $book->id);
154 + $this->setPageTitle('Delete Page ' . $page->getShortName());
151 return view('pages/delete', ['book' => $book, 'page' => $page, 'current' => $page]); 155 return view('pages/delete', ['book' => $book, 'page' => $page, 'current' => $page]);
152 } 156 }
153 157
...@@ -179,6 +183,7 @@ class PageController extends Controller ...@@ -179,6 +183,7 @@ class PageController extends Controller
179 { 183 {
180 $book = $this->bookRepo->getBySlug($bookSlug); 184 $book = $this->bookRepo->getBySlug($bookSlug);
181 $page = $this->pageRepo->getBySlug($pageSlug, $book->id); 185 $page = $this->pageRepo->getBySlug($pageSlug, $book->id);
186 + $this->setPageTitle('Revisions For ' . $page->getShortName());
182 return view('pages/revisions', ['page' => $page, 'book' => $book, 'current' => $page]); 187 return view('pages/revisions', ['page' => $page, 'book' => $book, 'current' => $page]);
183 } 188 }
184 189
...@@ -195,6 +200,7 @@ class PageController extends Controller ...@@ -195,6 +200,7 @@ class PageController extends Controller
195 $page = $this->pageRepo->getBySlug($pageSlug, $book->id); 200 $page = $this->pageRepo->getBySlug($pageSlug, $book->id);
196 $revision = $this->pageRepo->getRevisionById($revisionId); 201 $revision = $this->pageRepo->getRevisionById($revisionId);
197 $page->fill($revision->toArray()); 202 $page->fill($revision->toArray());
203 + $this->setPageTitle('Page Revision For ' . $page->getShortName());
198 return view('pages/revision', ['page' => $page, 'book' => $book]); 204 return view('pages/revision', ['page' => $page, 'book' => $book]);
199 } 205 }
200 206
......
...@@ -45,6 +45,7 @@ class SearchController extends Controller ...@@ -45,6 +45,7 @@ class SearchController extends Controller
45 $pages = $this->pageRepo->getBySearch($searchTerm); 45 $pages = $this->pageRepo->getBySearch($searchTerm);
46 $books = $this->bookRepo->getBySearch($searchTerm); 46 $books = $this->bookRepo->getBySearch($searchTerm);
47 $chapters = $this->chapterRepo->getBySearch($searchTerm); 47 $chapters = $this->chapterRepo->getBySearch($searchTerm);
48 + $this->setPageTitle('Search For ' . $searchTerm);
48 return view('search/all', ['pages' => $pages, 'books' => $books, 'chapters' => $chapters, 'searchTerm' => $searchTerm]); 49 return view('search/all', ['pages' => $pages, 'books' => $books, 'chapters' => $chapters, 'searchTerm' => $searchTerm]);
49 } 50 }
50 51
......
...@@ -18,6 +18,7 @@ class SettingController extends Controller ...@@ -18,6 +18,7 @@ class SettingController extends Controller
18 public function index() 18 public function index()
19 { 19 {
20 $this->checkPermission('settings-update'); 20 $this->checkPermission('settings-update');
21 + $this->setPageTitle('Settings');
21 return view('settings/index'); 22 return view('settings/index');
22 } 23 }
23 24
......
...@@ -4,7 +4,7 @@ namespace BookStack\Http\Controllers; ...@@ -4,7 +4,7 @@ namespace BookStack\Http\Controllers;
4 4
5 use Illuminate\Http\Request; 5 use Illuminate\Http\Request;
6 6
7 -use Illuminate\Support\Facades\Hash; 7 +use Illuminate\Http\Response;
8 use BookStack\Http\Requests; 8 use BookStack\Http\Requests;
9 use BookStack\Repos\UserRepo; 9 use BookStack\Repos\UserRepo;
10 use BookStack\Services\SocialAuthService; 10 use BookStack\Services\SocialAuthService;
...@@ -18,7 +18,8 @@ class UserController extends Controller ...@@ -18,7 +18,8 @@ class UserController extends Controller
18 18
19 /** 19 /**
20 * UserController constructor. 20 * UserController constructor.
21 - * @param $user 21 + * @param User $user
22 + * @param UserRepo $userRepo
22 */ 23 */
23 public function __construct(User $user, UserRepo $userRepo) 24 public function __construct(User $user, UserRepo $userRepo)
24 { 25 {
...@@ -29,18 +30,17 @@ class UserController extends Controller ...@@ -29,18 +30,17 @@ class UserController extends Controller
29 30
30 /** 31 /**
31 * Display a listing of the users. 32 * Display a listing of the users.
32 - *
33 * @return Response 33 * @return Response
34 */ 34 */
35 public function index() 35 public function index()
36 { 36 {
37 $users = $this->user->all(); 37 $users = $this->user->all();
38 + $this->setPageTitle('Users');
38 return view('users/index', ['users' => $users]); 39 return view('users/index', ['users' => $users]);
39 } 40 }
40 41
41 /** 42 /**
42 * Show the form for creating a new user. 43 * Show the form for creating a new user.
43 - *
44 * @return Response 44 * @return Response
45 */ 45 */
46 public function create() 46 public function create()
...@@ -51,7 +51,6 @@ class UserController extends Controller ...@@ -51,7 +51,6 @@ class UserController extends Controller
51 51
52 /** 52 /**
53 * Store a newly created user in storage. 53 * Store a newly created user in storage.
54 - *
55 * @param Request $request 54 * @param Request $request
56 * @return Response 55 * @return Response
57 */ 56 */
...@@ -60,7 +59,7 @@ class UserController extends Controller ...@@ -60,7 +59,7 @@ class UserController extends Controller
60 $this->checkPermission('user-create'); 59 $this->checkPermission('user-create');
61 $this->validate($request, [ 60 $this->validate($request, [
62 'name' => 'required', 61 'name' => 'required',
63 - 'email' => 'required|email', 62 + 'email' => 'required|email|unique:users,email',
64 'password' => 'required|min:5', 63 'password' => 'required|min:5',
65 'password-confirm' => 'required|same:password', 64 'password-confirm' => 'required|same:password',
66 'role' => 'required|exists:roles,id' 65 'role' => 'required|exists:roles,id'
...@@ -71,13 +70,20 @@ class UserController extends Controller ...@@ -71,13 +70,20 @@ class UserController extends Controller
71 $user->save(); 70 $user->save();
72 71
73 $user->attachRoleId($request->get('role')); 72 $user->attachRoleId($request->get('role'));
73 +
74 + // Get avatar from gravatar and save
75 + if (!env('DISABLE_EXTERNAL_SERVICES', false)) {
76 + $avatar = \Images::saveUserGravatar($user);
77 + $user->avatar()->associate($avatar);
78 + $user->save();
79 + }
80 +
74 return redirect('/users'); 81 return redirect('/users');
75 } 82 }
76 83
77 84
78 /** 85 /**
79 * Show the form for editing the specified user. 86 * Show the form for editing the specified user.
80 - *
81 * @param int $id 87 * @param int $id
82 * @param SocialAuthService $socialAuthService 88 * @param SocialAuthService $socialAuthService
83 * @return Response 89 * @return Response
...@@ -90,12 +96,12 @@ class UserController extends Controller ...@@ -90,12 +96,12 @@ class UserController extends Controller
90 96
91 $user = $this->user->findOrFail($id); 97 $user = $this->user->findOrFail($id);
92 $activeSocialDrivers = $socialAuthService->getActiveDrivers(); 98 $activeSocialDrivers = $socialAuthService->getActiveDrivers();
99 + $this->setPageTitle('User Profile');
93 return view('users/edit', ['user' => $user, 'activeSocialDrivers' => $activeSocialDrivers]); 100 return view('users/edit', ['user' => $user, 'activeSocialDrivers' => $activeSocialDrivers]);
94 } 101 }
95 102
96 /** 103 /**
97 * Update the specified user in storage. 104 * Update the specified user in storage.
98 - *
99 * @param Request $request 105 * @param Request $request
100 * @param int $id 106 * @param int $id
101 * @return Response 107 * @return Response
...@@ -139,12 +145,12 @@ class UserController extends Controller ...@@ -139,12 +145,12 @@ class UserController extends Controller
139 return $this->currentUser->id == $id; 145 return $this->currentUser->id == $id;
140 }); 146 });
141 $user = $this->user->findOrFail($id); 147 $user = $this->user->findOrFail($id);
148 + $this->setPageTitle('Delete User ' . $user->name);
142 return view('users/delete', ['user' => $user]); 149 return view('users/delete', ['user' => $user]);
143 } 150 }
144 151
145 /** 152 /**
146 * Remove the specified user from storage. 153 * Remove the specified user from storage.
147 - *
148 * @param int $id 154 * @param int $id
149 * @return Response 155 * @return Response
150 */ 156 */
...@@ -153,14 +159,14 @@ class UserController extends Controller ...@@ -153,14 +159,14 @@ class UserController extends Controller
153 $this->checkPermissionOr('user-delete', function () use ($id) { 159 $this->checkPermissionOr('user-delete', function () use ($id) {
154 return $this->currentUser->id == $id; 160 return $this->currentUser->id == $id;
155 }); 161 });
162 +
156 $user = $this->userRepo->getById($id); 163 $user = $this->userRepo->getById($id);
157 - // Delete social accounts 164 + if ($this->userRepo->isOnlyAdmin($user)) {
158 - if($this->userRepo->isOnlyAdmin($user)) {
159 session()->flash('error', 'You cannot delete the only admin'); 165 session()->flash('error', 'You cannot delete the only admin');
160 return redirect($user->getEditUrl()); 166 return redirect($user->getEditUrl());
161 } 167 }
162 - $user->socialAccounts()->delete(); 168 + $this->userRepo->destroy($user);
163 - $user->delete(); 169 +
164 return redirect('/users'); 170 return redirect('/users');
165 } 171 }
166 } 172 }
......
...@@ -45,8 +45,6 @@ Route::group(['middleware' => 'auth'], function () { ...@@ -45,8 +45,6 @@ Route::group(['middleware' => 'auth'], function () {
45 45
46 }); 46 });
47 47
48 - // Uploads
49 - Route::post('/upload/image', 'ImageController@upload');
50 48
51 // Users 49 // Users
52 Route::get('/users', 'UserController@index'); 50 Route::get('/users', 'UserController@index');
...@@ -58,10 +56,18 @@ Route::group(['middleware' => 'auth'], function () { ...@@ -58,10 +56,18 @@ Route::group(['middleware' => 'auth'], function () {
58 Route::delete('/users/{id}', 'UserController@destroy'); 56 Route::delete('/users/{id}', 'UserController@destroy');
59 57
60 // Image routes 58 // Image routes
61 - Route::get('/images/all', 'ImageController@getAll'); 59 + Route::group(['prefix' => 'images'], function() {
62 - Route::put('/images/update/{imageId}', 'ImageController@update'); 60 + // Get for user images
63 - Route::delete('/images/{imageId}', 'ImageController@destroy'); 61 + Route::get('/user/all', 'ImageController@getAllForUserType');
64 - Route::get('/images/all/{page}', 'ImageController@getAll'); 62 + Route::get('/user/all/{page}', 'ImageController@getAllForUserType');
63 + // Standard get, update and deletion for all types
64 + Route::get('/thumb/{id}/{width}/{height}/{crop}', 'ImageController@getThumbnail');
65 + Route::put('/update/{imageId}', 'ImageController@update');
66 + Route::post('/{type}/upload', 'ImageController@uploadByType');
67 + Route::get('/{type}/all', 'ImageController@getAllByType');
68 + Route::get('/{type}/all/{page}', 'ImageController@getAllByType');
69 + Route::delete('/{imageId}', 'ImageController@destroy');
70 + });
65 71
66 // Links 72 // Links
67 Route::get('/link/{id}', 'PageController@redirectFromLink'); 73 Route::get('/link/{id}', 'PageController@redirectFromLink');
......
...@@ -3,22 +3,24 @@ ...@@ -3,22 +3,24 @@
3 namespace BookStack; 3 namespace BookStack;
4 4
5 5
6 -class Image extends Entity 6 +use Illuminate\Database\Eloquent\Model;
7 +use Images;
8 +
9 +class Image extends Model
7 { 10 {
11 + use Ownable;
8 12
9 protected $fillable = ['name']; 13 protected $fillable = ['name'];
10 14
11 - public function getFilePath()
12 - {
13 - return storage_path() . $this->url;
14 - }
15 -
16 /** 15 /**
17 - * Get the url for this item. 16 + * Get a thumbnail for this image.
17 + * @param int $width
18 + * @param int $height
19 + * @param bool|false $keepRatio
18 * @return string 20 * @return string
19 */ 21 */
20 - public function getUrl() 22 + public function getThumb($width, $height, $keepRatio = false)
21 { 23 {
22 - return public_path() . $this->url; 24 + return Images::getThumbnail($this, $width, $height, $keepRatio);
23 } 25 }
24 } 26 }
......
1 +<?php namespace BookStack;
2 +
3 +
4 +trait Ownable
5 +{
6 + /**
7 + * Relation for the user that created this entity.
8 + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
9 + */
10 + public function createdBy()
11 + {
12 + return $this->belongsTo('BookStack\User', 'created_by');
13 + }
14 +
15 + /**
16 + * Relation for the user that updated this entity.
17 + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
18 + */
19 + public function updatedBy()
20 + {
21 + return $this->belongsTo('BookStack\User', 'updated_by');
22 + }
23 +}
...\ No newline at end of file ...\ No newline at end of file
...@@ -32,7 +32,6 @@ class Page extends Entity ...@@ -32,7 +32,6 @@ class Page extends Entity
32 return $this->chapter()->count() > 0; 32 return $this->chapter()->count() > 0;
33 } 33 }
34 34
35 -
36 public function revisions() 35 public function revisions()
37 { 36 {
38 return $this->hasMany('BookStack\PageRevision')->orderBy('created_at', 'desc'); 37 return $this->hasMany('BookStack\PageRevision')->orderBy('created_at', 'desc');
...@@ -40,7 +39,6 @@ class Page extends Entity ...@@ -40,7 +39,6 @@ class Page extends Entity
40 39
41 public function getUrl() 40 public function getUrl()
42 { 41 {
43 - // TODO - Extract this and share with chapters
44 $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug; 42 $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
45 return '/books/' . $bookSlug . '/page/' . $this->slug; 43 return '/books/' . $bookSlug . '/page/' . $this->slug;
46 } 44 }
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
2 2
3 namespace BookStack\Providers; 3 namespace BookStack\Providers;
4 4
5 +use BookStack\Services\ImageService;
5 use BookStack\Services\ViewService; 6 use BookStack\Services\ViewService;
6 use Illuminate\Support\ServiceProvider; 7 use Illuminate\Support\ServiceProvider;
7 use BookStack\Services\ActivityService; 8 use BookStack\Services\ActivityService;
...@@ -40,5 +41,12 @@ class CustomFacadeProvider extends ServiceProvider ...@@ -40,5 +41,12 @@ class CustomFacadeProvider extends ServiceProvider
40 $this->app->make('Illuminate\Contracts\Cache\Repository') 41 $this->app->make('Illuminate\Contracts\Cache\Repository')
41 ); 42 );
42 }); 43 });
44 + $this->app->bind('images', function() {
45 + return new ImageService(
46 + $this->app->make('Intervention\Image\ImageManager'),
47 + $this->app->make('Illuminate\Contracts\Filesystem\Factory'),
48 + $this->app->make('Illuminate\Contracts\Cache\Repository')
49 + );
50 + });
43 } 51 }
44 } 52 }
......
...@@ -78,6 +78,17 @@ class BookRepo ...@@ -78,6 +78,17 @@ class BookRepo
78 } 78 }
79 79
80 /** 80 /**
81 + * Gets the most viewed books.
82 + * @param int $count
83 + * @param int $page
84 + * @return mixed
85 + */
86 + public function getPopular($count = 10, $page = 0)
87 + {
88 + return Views::getPopular($count, $page, $this->book);
89 + }
90 +
91 + /**
81 * Get a book by slug 92 * Get a book by slug
82 * @param $slug 93 * @param $slug
83 * @return mixed 94 * @return mixed
......
1 +<?php namespace BookStack\Repos;
2 +
3 +
4 +use BookStack\Image;
5 +use BookStack\Services\ImageService;
6 +use Setting;
7 +use Symfony\Component\HttpFoundation\File\UploadedFile;
8 +
9 +class ImageRepo
10 +{
11 +
12 + protected $image;
13 + protected $imageService;
14 +
15 + /**
16 + * ImageRepo constructor.
17 + * @param Image $image
18 + * @param ImageService $imageService
19 + */
20 + public function __construct(Image $image, ImageService $imageService)
21 + {
22 + $this->image = $image;
23 + $this->imageService = $imageService;
24 + }
25 +
26 +
27 + /**
28 + * Get an image with the given id.
29 + * @param $id
30 + * @return mixed
31 + */
32 + public function getById($id)
33 + {
34 + return $this->image->findOrFail($id);
35 + }
36 +
37 +
38 + /**
39 + * Gets a load images paginated, filtered by image type.
40 + * @param string $type
41 + * @param int $page
42 + * @param int $pageSize
43 + * @param bool|int $userFilter
44 + * @return array
45 + */
46 + public function getPaginatedByType($type, $page = 0, $pageSize = 24, $userFilter = false)
47 + {
48 + $images = $this->image->where('type', '=', strtolower($type));
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();
55 + $hasMore = count($images) > $pageSize;
56 +
57 + $returnImages = $images->take(24);
58 + $returnImages->each(function ($image) {
59 + $this->loadThumbs($image);
60 + });
61 +
62 + return [
63 + 'images' => $returnImages,
64 + 'hasMore' => $hasMore
65 + ];
66 + }
67 +
68 + /**
69 + * Save a new image into storage and return the new image.
70 + * @param UploadedFile $uploadFile
71 + * @param string $type
72 + * @return Image
73 + */
74 + public function saveNew(UploadedFile $uploadFile, $type)
75 + {
76 + $image = $this->imageService->saveNewFromUpload($uploadFile, $type);
77 + $this->loadThumbs($image);
78 + return $image;
79 + }
80 +
81 + /**
82 + * Update the details of an image via an array of properties.
83 + * @param Image $image
84 + * @param array $updateDetails
85 + * @return Image
86 + */
87 + public function updateImageDetails(Image $image, $updateDetails)
88 + {
89 + $image->fill($updateDetails);
90 + $image->save();
91 + $this->loadThumbs($image);
92 + return $image;
93 + }
94 +
95 +
96 + /**
97 + * Destroys an Image object along with its files and thumbnails.
98 + * @param Image $image
99 + * @return bool
100 + */
101 + public function destroyImage(Image $image)
102 + {
103 + $this->imageService->destroyImage($image);
104 + return true;
105 + }
106 +
107 +
108 + /**
109 + * Load thumbnails onto an image object.
110 + * @param Image $image
111 + */
112 + private function loadThumbs(Image $image)
113 + {
114 + $image->thumbs = [
115 + 'gallery' => $this->getThumbnail($image, 150, 150),
116 + 'display' => $this->getThumbnail($image, 840, 0, true)
117 + ];
118 + }
119 +
120 + /**
121 + * Get the thumbnail for an image.
122 + * If $keepRatio is true only the width will be used.
123 + * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
124 + *
125 + * @param Image $image
126 + * @param int $width
127 + * @param int $height
128 + * @param bool $keepRatio
129 + * @return string
130 + */
131 + public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
132 + {
133 + return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
134 + }
135 +
136 +
137 +}
...\ No newline at end of file ...\ No newline at end of file
...@@ -269,7 +269,7 @@ class PageRepo ...@@ -269,7 +269,7 @@ class PageRepo
269 * @param Page $page 269 * @param Page $page
270 * @return $this 270 * @return $this
271 */ 271 */
272 - private function saveRevision(Page $page) 272 + public function saveRevision(Page $page)
273 { 273 {
274 $revision = $this->pageRevision->fill($page->toArray()); 274 $revision = $this->pageRevision->fill($page->toArray());
275 $revision->page_id = $page->id; 275 $revision->page_id = $page->id;
......
...@@ -46,14 +46,19 @@ class UserRepo ...@@ -46,14 +46,19 @@ class UserRepo
46 public function registerNew(array $data) 46 public function registerNew(array $data)
47 { 47 {
48 $user = $this->create($data); 48 $user = $this->create($data);
49 - $roleId = \Setting::get('registration-role'); 49 + $this->attachDefaultRole($user);
50 - 50 + return $user;
51 - if ($roleId === false) {
52 - $roleId = $this->role->getDefault()->id;
53 } 51 }
54 52
53 + /**
54 + * Give a user the default role. Used when creating a new user.
55 + * @param $user
56 + */
57 + public function attachDefaultRole($user)
58 + {
59 + $roleId = \Setting::get('registration-role');
60 + if ($roleId === false) $roleId = $this->role->getDefault()->id;
55 $user->attachRoleId($roleId); 61 $user->attachRoleId($roleId);
56 - return $user;
57 } 62 }
58 63
59 /** 64 /**
...@@ -88,4 +93,14 @@ class UserRepo ...@@ -88,4 +93,14 @@ class UserRepo
88 'password' => bcrypt($data['password']) 93 'password' => bcrypt($data['password'])
89 ]); 94 ]);
90 } 95 }
96 +
97 + /**
98 + * Remove the given user from storage, Delete all related content.
99 + * @param User $user
100 + */
101 + public function destroy(User $user)
102 + {
103 + $user->socialAccounts()->delete();
104 + $user->delete();
105 + }
91 } 106 }
...\ No newline at end of file ...\ No newline at end of file
......
1 +<?php namespace BookStack\Services\Facades;
2 +
3 +
4 +use Illuminate\Support\Facades\Facade;
5 +
6 +class Images extends Facade
7 +{
8 + /**
9 + * Get the registered name of the component.
10 + *
11 + * @return string
12 + */
13 + protected static function getFacadeAccessor() { return 'images'; }
14 +}
...\ No newline at end of file ...\ No newline at end of file
1 +<?php namespace BookStack\Services;
2 +
3 +use BookStack\Image;
4 +use BookStack\User;
5 +use Intervention\Image\ImageManager;
6 +use Illuminate\Contracts\Filesystem\Factory as FileSystem;
7 +use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
8 +use Illuminate\Contracts\Cache\Repository as Cache;
9 +use Setting;
10 +use Symfony\Component\HttpFoundation\File\UploadedFile;
11 +
12 +class ImageService
13 +{
14 +
15 + protected $imageTool;
16 + protected $fileSystem;
17 + protected $cache;
18 +
19 + /**
20 + * @var FileSystemInstance
21 + */
22 + protected $storageInstance;
23 + protected $storageUrl;
24 +
25 + /**
26 + * ImageService constructor.
27 + * @param $imageTool
28 + * @param $fileSystem
29 + * @param $cache
30 + */
31 + public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
32 + {
33 + $this->imageTool = $imageTool;
34 + $this->fileSystem = $fileSystem;
35 + $this->cache = $cache;
36 + }
37 +
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)
76 + {
77 + $storage = $this->getStorage();
78 + $secureUploads = Setting::get('app-secure-images');
79 + $imageName = str_replace(' ', '-', $imageName);
80 +
81 + if ($secureUploads) $imageName = str_random(16) . '-' . $imageName;
82 +
83 + $imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/';
84 + while ($storage->exists($imagePath . $imageName)) {
85 + $imageName = str_random(3) . $imageName;
86 + }
87 + $fullPath = $imagePath . $imageName;
88 +
89 + $storage->put($fullPath, $imageData);
90 +
91 + $userId = auth()->user()->id;
92 + $image = Image::forceCreate([
93 + 'name' => $imageName,
94 + 'path' => $fullPath,
95 + 'url' => $this->getPublicUrl($fullPath),
96 + 'type' => $type,
97 + 'created_by' => $userId,
98 + 'updated_by' => $userId
99 + ]);
100 +
101 + return $image;
102 + }
103 +
104 + /**
105 + * Get the thumbnail for an image.
106 + * If $keepRatio is true only the width will be used.
107 + * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
108 + *
109 + * @param Image $image
110 + * @param int $width
111 + * @param int $height
112 + * @param bool $keepRatio
113 + * @return string
114 + */
115 + public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
116 + {
117 + $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
118 + $thumbFilePath = dirname($image->path) . $thumbDirName . basename($image->path);
119 +
120 + if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) {
121 + return $this->getPublicUrl($thumbFilePath);
122 + }
123 +
124 + $storage = $this->getStorage();
125 +
126 + if ($storage->exists($thumbFilePath)) {
127 + return $this->getPublicUrl($thumbFilePath);
128 + }
129 +
130 + // Otherwise create the thumbnail
131 + $thumb = $this->imageTool->make($storage->get($image->path));
132 + if ($keepRatio) {
133 + $thumb->resize($width, null, function ($constraint) {
134 + $constraint->aspectRatio();
135 + $constraint->upsize();
136 + });
137 + } else {
138 + $thumb->fit($width, $height);
139 + }
140 +
141 + $thumbData = (string)$thumb->encode();
142 + $storage->put($thumbFilePath, $thumbData);
143 + $this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 72);
144 +
145 + return $this->getPublicUrl($thumbFilePath);
146 + }
147 +
148 + /**
149 + * Destroys an Image object along with its files and thumbnails.
150 + * @param Image $image
151 + * @return bool
152 + */
153 + public function destroyImage(Image $image)
154 + {
155 + $storage = $this->getStorage();
156 +
157 + $imageFolder = dirname($image->path);
158 + $imageFileName = basename($image->path);
159 + $allImages = collect($storage->allFiles($imageFolder));
160 +
161 + $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
162 + $expectedIndex = strlen($imagePath) - strlen($imageFileName);
163 + return strpos($imagePath, $imageFileName) === $expectedIndex;
164 + });
165 +
166 + $storage->delete($imagesToDelete->all());
167 +
168 + // Cleanup of empty folders
169 + foreach ($storage->directories($imageFolder) as $directory) {
170 + if ($this->isFolderEmpty($directory)) $storage->deleteDirectory($directory);
171 + }
172 + if ($this->isFolderEmpty($imageFolder)) $storage->deleteDirectory($imageFolder);
173 +
174 + $image->delete();
175 + return true;
176 + }
177 +
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 + $emailHash = md5(strtolower(trim($user->email)));
187 + $url = 'http://www.gravatar.com/avatar/' . $emailHash . '?s=' . $size . '&d=identicon';
188 + $imageName = str_replace(' ', '-', $user->name . '-gravatar.png');
189 + $image = $this->saveNewFromUrl($url, 'user', $imageName);
190 + $image->created_by = $user->id;
191 + $image->save();
192 + return $image;
193 + }
194 +
195 + /**
196 + * Get the storage that will be used for storing images.
197 + * @return FileSystemInstance
198 + */
199 + private function getStorage()
200 + {
201 + if ($this->storageInstance !== null) return $this->storageInstance;
202 +
203 + $storageType = env('STORAGE_TYPE');
204 + $this->storageInstance = $this->fileSystem->disk($storageType);
205 +
206 + return $this->storageInstance;
207 + }
208 +
209 + /**
210 + * Check whether or not a folder is empty.
211 + * @param $path
212 + * @return int
213 + */
214 + private function isFolderEmpty($path)
215 + {
216 + $files = $this->getStorage()->files($path);
217 + $folders = $this->getStorage()->directories($path);
218 + return count($files) === 0 && count($folders) === 0;
219 + }
220 +
221 + /**
222 + * Gets a public facing url for an image by checking relevant environment variables.
223 + * @param $filePath
224 + * @return string
225 + */
226 + private function getPublicUrl($filePath)
227 + {
228 + if ($this->storageUrl === null) {
229 + $storageUrl = env('STORAGE_URL');
230 +
231 + // Get the standard public s3 url if s3 is set as storage type
232 + if ($storageUrl == false && env('STORAGE_TYPE') === 's3') {
233 + $storageDetails = config('filesystems.disks.s3');
234 + $storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
235 + }
236 +
237 + $this->storageUrl = $storageUrl;
238 + }
239 +
240 + return ($this->storageUrl == false ? '' : rtrim($this->storageUrl, '/')) . $filePath;
241 + }
242 +
243 +
244 +}
...\ No newline at end of file ...\ No newline at end of file
...@@ -44,6 +44,29 @@ class ViewService ...@@ -44,6 +44,29 @@ class ViewService
44 return 1; 44 return 1;
45 } 45 }
46 46
47 +
48 + /**
49 + * Get the entities with the most views.
50 + * @param int $count
51 + * @param int $page
52 + * @param bool|false $filterModel
53 + */
54 + public function getPopular($count = 10, $page = 0, $filterModel = false)
55 + {
56 + $skipCount = $count * $page;
57 + $query = $this->view->select('id', 'viewable_id', 'viewable_type', \DB::raw('SUM(views) as view_count'))
58 + ->groupBy('viewable_id', 'viewable_type')
59 + ->orderBy('view_count', 'desc');
60 +
61 + if($filterModel) $query->where('viewable_type', '=', get_class($filterModel));
62 +
63 + $views = $query->with('viewable')->skip($skipCount)->take($count)->get();
64 + $viewedEntities = $views->map(function ($item) {
65 + return $item->viewable()->getResults();
66 + });
67 + return $viewedEntities;
68 + }
69 +
47 /** 70 /**
48 * Get all recently viewed entities for the current user. 71 * Get all recently viewed entities for the current user.
49 * @param int $count 72 * @param int $count
......
...@@ -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 === '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, false);
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 +if (! function_exists('versioned_asset')) {
4 + /**
5 + * Get the path to a versioned file.
6 + *
7 + * @param string $file
8 + * @return string
9 + *
10 + * @throws \InvalidArgumentException
11 + */
12 + function versioned_asset($file)
13 + {
14 + static $manifest = null;
15 +
16 + if (is_null($manifest)) {
17 + $manifest = json_decode(file_get_contents(public_path('build/manifest.json')), true);
18 + }
19 +
20 + if (isset($manifest[$file])) {
21 + return '/' . $manifest[$file];
22 + }
23 +
24 + if (file_exists(public_path($file))) {
25 + return '/' . $file;
26 + }
27 +
28 + throw new InvalidArgumentException("File {$file} not defined in asset manifest.");
29 + }
30 +}
...\ No newline at end of file ...\ No newline at end of file
...@@ -8,15 +8,16 @@ ...@@ -8,15 +8,16 @@
8 "php": ">=5.5.9", 8 "php": ">=5.5.9",
9 "laravel/framework": "5.1.*", 9 "laravel/framework": "5.1.*",
10 "intervention/image": "^2.3", 10 "intervention/image": "^2.3",
11 - "laravel/socialite": "^2.0" 11 + "laravel/socialite": "^2.0",
12 + "barryvdh/laravel-ide-helper": "^2.1",
13 + "barryvdh/laravel-debugbar": "^2.0",
14 + "league/flysystem-aws-s3-v3": "^1.0"
12 }, 15 },
13 "require-dev": { 16 "require-dev": {
14 "fzaninotto/faker": "~1.4", 17 "fzaninotto/faker": "~1.4",
15 "mockery/mockery": "0.9.*", 18 "mockery/mockery": "0.9.*",
16 "phpunit/phpunit": "~4.0", 19 "phpunit/phpunit": "~4.0",
17 - "phpspec/phpspec": "~2.1", 20 + "phpspec/phpspec": "~2.1"
18 - "barryvdh/laravel-ide-helper": "^2.1",
19 - "barryvdh/laravel-debugbar": "^2.0"
20 }, 21 },
21 "autoload": { 22 "autoload": {
22 "classmap": [ 23 "classmap": [
...@@ -24,7 +25,10 @@ ...@@ -24,7 +25,10 @@
24 ], 25 ],
25 "psr-4": { 26 "psr-4": {
26 "BookStack\\": "app/" 27 "BookStack\\": "app/"
27 - } 28 + },
29 + "files": [
30 + "app/helpers.php"
31 + ]
28 }, 32 },
29 "autoload-dev": { 33 "autoload-dev": {
30 "classmap": [ 34 "classmap": [
......
...@@ -217,6 +217,7 @@ return [ ...@@ -217,6 +217,7 @@ return [
217 'Activity' => BookStack\Services\Facades\Activity::class, 217 'Activity' => BookStack\Services\Facades\Activity::class,
218 'Setting' => BookStack\Services\Facades\Setting::class, 218 'Setting' => BookStack\Services\Facades\Setting::class,
219 'Views' => BookStack\Services\Facades\Views::class, 219 'Views' => BookStack\Services\Facades\Views::class,
220 + 'Images' => \BookStack\Services\Facades\Images::class,
220 221
221 ], 222 ],
222 223
......
...@@ -45,7 +45,7 @@ return [ ...@@ -45,7 +45,7 @@ return [
45 45
46 'local' => [ 46 'local' => [
47 'driver' => 'local', 47 'driver' => 'local',
48 - 'root' => storage_path('app'), 48 + 'root' => public_path(),
49 ], 49 ],
50 50
51 'ftp' => [ 51 'ftp' => [
...@@ -64,10 +64,10 @@ return [ ...@@ -64,10 +64,10 @@ return [
64 64
65 's3' => [ 65 's3' => [
66 'driver' => 's3', 66 'driver' => 's3',
67 - 'key' => 'your-key', 67 + 'key' => env('STORAGE_S3_KEY', 'your-key'),
68 - 'secret' => 'your-secret', 68 + 'secret' => env('STORAGE_S3_SECRET', 'your-secret'),
69 - 'region' => 'your-region', 69 + 'region' => env('STORAGE_S3_REGION', 'your-region'),
70 - 'bucket' => 'your-bucket', 70 + 'bucket' => env('STORAGE_S3_BUCKET', 'your-bucket'),
71 ], 71 ],
72 72
73 'rackspace' => [ 73 'rackspace' => [
......
1 +<?php
2 +
3 +use Illuminate\Database\Schema\Blueprint;
4 +use Illuminate\Database\Migrations\Migration;
5 +
6 +class FulltextWeighting extends Migration
7 +{
8 + /**
9 + * Run the migrations.
10 + *
11 + * @return void
12 + */
13 + public function up()
14 + {
15 + DB::statement('ALTER TABLE pages ADD FULLTEXT name_search(name)');
16 + DB::statement('ALTER TABLE books ADD FULLTEXT name_search(name)');
17 + DB::statement('ALTER TABLE chapters ADD FULLTEXT name_search(name)');
18 + }
19 +
20 + /**
21 + * Reverse the migrations.
22 + *
23 + * @return void
24 + */
25 + public function down()
26 + {
27 + Schema::table('pages', function(Blueprint $table) {
28 + $table->dropIndex('name_search');
29 + });
30 + Schema::table('books', function(Blueprint $table) {
31 + $table->dropIndex('name_search');
32 + });
33 + Schema::table('chapters', function(Blueprint $table) {
34 + $table->dropIndex('name_search');
35 + });
36 + }
37 +}
1 +<?php
2 +
3 +use BookStack\Image;
4 +use Illuminate\Database\Schema\Blueprint;
5 +use Illuminate\Database\Migrations\Migration;
6 +
7 +class AddImageUploadTypes extends Migration
8 +{
9 + /**
10 + * Run the migrations.
11 + *
12 + * @return void
13 + */
14 + public function up()
15 + {
16 + Schema::table('images', function (Blueprint $table) {
17 + $table->string('path', 400);
18 + $table->string('type')->index();
19 + });
20 +
21 + Image::all()->each(function($image) {
22 + $image->path = $image->url;
23 + $image->type = 'gallery';
24 + $image->save();
25 + });
26 + }
27 +
28 + /**
29 + * Reverse the migrations.
30 + *
31 + * @return void
32 + */
33 + public function down()
34 + {
35 + Schema::table('images', function (Blueprint $table) {
36 + $table->dropColumn('type');
37 + $table->dropColumn('path');
38 + });
39 +
40 + }
41 +}
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 +}
...@@ -23,7 +23,9 @@ class DummyContentSeeder extends Seeder ...@@ -23,7 +23,9 @@ class DummyContentSeeder extends Seeder
23 $pages = factory(\BookStack\Page::class, 10)->make(['created_by' => $user->id, 'updated_by' => $user->id, 'book_id' => $book->id]); 23 $pages = factory(\BookStack\Page::class, 10)->make(['created_by' => $user->id, 'updated_by' => $user->id, 'book_id' => $book->id]);
24 $chapter->pages()->saveMany($pages); 24 $chapter->pages()->saveMany($pages);
25 }); 25 });
26 + $pages = factory(\BookStack\Page::class, 3)->make(['created_by' => $user->id, 'updated_by' => $user->id]);
26 $book->chapters()->saveMany($chapters); 27 $book->chapters()->saveMany($chapters);
28 + $book->pages()->saveMany($pages);
27 }); 29 });
28 } 30 }
29 } 31 }
......
1 var elixir = require('laravel-elixir'); 1 var elixir = require('laravel-elixir');
2 2
3 +// Custom extensions
4 +var gulp = require('gulp');
5 +var Task = elixir.Task;
6 +var fs = require('fs');
7 +
8 +elixir.extend('queryVersion', function(inputFiles) {
9 + new Task('queryVersion', function() {
10 + var manifestObject = {};
11 + var uidString = Date.now().toString(16).slice(4);
12 + for (var i = 0; i < inputFiles.length; i++) {
13 + var file = inputFiles[i];
14 + manifestObject[file] = file + '?version=' + uidString;
15 + }
16 + var fileContents = JSON.stringify(manifestObject, null, 1);
17 + fs.writeFileSync('public/build/manifest.json', fileContents);
18 + }).watch(['./public/css/*.css', './public/js/*.js']);
19 +});
20 +
3 elixir(function(mix) { 21 elixir(function(mix) {
4 mix.sass('styles.scss') 22 mix.sass('styles.scss')
5 .sass('print-styles.scss') 23 .sass('print-styles.scss')
6 .browserify(['jquery-extensions.js', 'global.js'], 'public/js/common.js') 24 .browserify(['jquery-extensions.js', 'global.js'], 'public/js/common.js')
7 - .version(['css/styles.css', 'css/print-styles.css', 'js/common.js']); 25 + .queryVersion(['css/styles.css', 'css/print-styles.css', 'js/common.js']);
8 }); 26 });
......
1 { 1 {
2 "private": true, 2 "private": true,
3 "devDependencies": { 3 "devDependencies": {
4 - "gulp": "^3.8.8", 4 + "gulp": "^3.9.0",
5 "insert-css": "^0.2.0" 5 "insert-css": "^0.2.0"
6 }, 6 },
7 "dependencies": { 7 "dependencies": {
......
...@@ -26,5 +26,6 @@ ...@@ -26,5 +26,6 @@
26 <env name="QUEUE_DRIVER" value="sync"/> 26 <env name="QUEUE_DRIVER" value="sync"/>
27 <env name="DB_CONNECTION" value="mysql_testing"/> 27 <env name="DB_CONNECTION" value="mysql_testing"/>
28 <env name="MAIL_PRETEND" value="true"/> 28 <env name="MAIL_PRETEND" value="true"/>
29 + <env name="DISABLE_EXTERNAL_SERVICES" value="true"/>
29 </php> 30 </php>
30 </phpunit> 31 </phpunit>
......
...@@ -5,7 +5,7 @@ A platform to create documentation/wiki content. General information about BookS ...@@ -5,7 +5,7 @@ A platform to create documentation/wiki content. General information about BookS
5 5
6 ## Requirements 6 ## Requirements
7 7
8 -BookStack has the similar requirements to Laravel. On top of those are some front-end build tools which are only required when developing. 8 +BookStack has similar requirements to Laravel. On top of those are some front-end build tools which are only required when developing.
9 9
10 * PHP >= 5.5.9 10 * PHP >= 5.5.9
11 * OpenSSL PHP Extension 11 * OpenSSL PHP Extension
...@@ -25,7 +25,7 @@ Ensure the requirements are met before installing. ...@@ -25,7 +25,7 @@ Ensure the requirements are met before installing.
25 25
26 This project currently uses the `release` branch of this repository as a stable channel for providing updates. 26 This project currently uses the `release` branch of this repository as a stable channel for providing updates.
27 27
28 -The installation is currently somewhat complicated. Some PHP/Laravel experience will benefit. 28 +The installation is currently somewhat complicated and will be made simpler in future releases. Some PHP/Laravel experience will currently benefit.
29 29
30 1. Clone the release branch of this repository into a folder. 30 1. Clone the release branch of this repository into a folder.
31 31
...@@ -37,7 +37,7 @@ git clone https://github.com/ssddanbrown/BookStack.git --branch release --single ...@@ -37,7 +37,7 @@ git clone https://github.com/ssddanbrown/BookStack.git --branch release --single
37 3. Copy the `.env.example` file to `.env` and fill with your own database and mail details. 37 3. Copy the `.env.example` file to `.env` and fill with your own database and mail details.
38 4. Ensure the `storage` & `bootstrap/cache` folders are writable by the web server. 38 4. Ensure the `storage` & `bootstrap/cache` folders are writable by the web server.
39 5. In the application root, Run `php artisan key:generate` to generate a unique application key. 39 5. In the application root, Run `php artisan key:generate` to generate a unique application key.
40 -6. If not using apache or `.htaccess` files are disable you will have to create some URL rewrite rules as shown below. 40 +6. If not using apache or if `.htaccess` files are disabled you will have to create some URL rewrite rules as shown below.
41 7. Run `php migrate` to update the database. 41 7. Run `php migrate` to update the database.
42 8. Done! You can now login using the default admin details `admin@admin.com` with a password of `password`. It is recommended to change these details directly after first logging in. 42 8. Done! You can now login using the default admin details `admin@admin.com` with a password of `password`. It is recommended to change these details directly after first logging in.
43 43
...@@ -76,3 +76,17 @@ Once done you can run `phpunit` in the application root directory to run all tes ...@@ -76,3 +76,17 @@ Once done you can run `phpunit` in the application root directory to run all tes
76 ## License 76 ## License
77 77
78 BookStack is provided under the MIT License. 78 BookStack is provided under the MIT License.
79 +
80 +## Attribution
81 +
82 +These are the great projects used to help build BookStack:
83 +
84 +* [Laravel](http://laravel.com/)
85 +* [VueJS](http://vuejs.org/)
86 +* [jQuery](https://jquery.com/)
87 +* [TinyMCE](https://www.tinymce.com/)
88 +* [highlight.js](https://highlightjs.org/)
89 +* [jQuery Sortable](https://johnny.github.io/jquery-sortable/)
90 +* [Material Design Iconic Font](http://zavoloklom.github.io/material-design-iconic-font/icons.html)
91 +* [Dropzone.js](http://www.dropzonejs.com/)
92 +* [ZeroClipboard](http://zeroclipboard.org/)
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
7 <div v-for="image in images"> 7 <div v-for="image in images">
8 <img class="anim fadeIn" 8 <img class="anim fadeIn"
9 :class="{selected: (image==selectedImage)}" 9 :class="{selected: (image==selectedImage)}"
10 - :src="image.thumbnail" :alt="image.title" :title="image.name" 10 + :src="image.thumbs.gallery" :alt="image.title" :title="image.name"
11 @click="imageClick(image)" 11 @click="imageClick(image)"
12 :style="{animationDelay: ($index > 26) ? '160ms' : ($index * 25) + 'ms'}"> 12 :style="{animationDelay: ($index > 26) ? '160ms' : ($index * 25) + 'ms'}">
13 </div> 13 </div>
...@@ -76,6 +76,13 @@ ...@@ -76,6 +76,13 @@
76 } 76 }
77 }, 77 },
78 78
79 + props: {
80 + imageType: {
81 + type: String,
82 + required: true
83 + }
84 + },
85 +
79 created: function () { 86 created: function () {
80 window.ImageManager = this; 87 window.ImageManager = this;
81 }, 88 },
...@@ -88,7 +95,7 @@ ...@@ -88,7 +95,7 @@
88 methods: { 95 methods: {
89 fetchData: function () { 96 fetchData: function () {
90 var _this = this; 97 var _this = this;
91 - this.$http.get('/images/all/' + _this.page, function (data) { 98 + this.$http.get('/images/' + _this.imageType + '/all/' + _this.page, function (data) {
92 _this.images = _this.images.concat(data.images); 99 _this.images = _this.images.concat(data.images);
93 _this.hasMore = data.hasMore; 100 _this.hasMore = data.hasMore;
94 _this.page++; 101 _this.page++;
...@@ -98,7 +105,7 @@ ...@@ -98,7 +105,7 @@
98 setupDropZone: function () { 105 setupDropZone: function () {
99 var _this = this; 106 var _this = this;
100 var dropZone = new Dropzone(_this.$els.dropZone, { 107 var dropZone = new Dropzone(_this.$els.dropZone, {
101 - url: '/upload/image', 108 + url: '/images/' + _this.imageType + '/upload',
102 init: function () { 109 init: function () {
103 var dz = this; 110 var dz = this;
104 this.on("sending", function (file, xhr, data) { 111 this.on("sending", function (file, xhr, data) {
...@@ -110,8 +117,8 @@ ...@@ -110,8 +117,8 @@
110 dz.removeFile(file); 117 dz.removeFile(file);
111 }); 118 });
112 }); 119 });
113 - this.on('error', function(file, errorMessage, xhr) { 120 + this.on('error', function (file, errorMessage, xhr) {
114 - if(errorMessage.file) { 121 + if (errorMessage.file) {
115 $(file.previewElement).find('[data-dz-errormessage]').text(errorMessage.file[0]); 122 $(file.previewElement).find('[data-dz-errormessage]').text(errorMessage.file[0]);
116 } 123 }
117 console.log(errorMessage); 124 console.log(errorMessage);
...@@ -120,6 +127,10 @@ ...@@ -120,6 +127,10 @@
120 }); 127 });
121 }, 128 },
122 129
130 + returnCallback: function (image) {
131 + this.callback(image);
132 + },
133 +
123 imageClick: function (image) { 134 imageClick: function (image) {
124 var dblClickTime = 380; 135 var dblClickTime = 380;
125 var cTime = (new Date()).getTime(); 136 var cTime = (new Date()).getTime();
...@@ -127,7 +138,7 @@ ...@@ -127,7 +138,7 @@
127 if (this.cClickTime !== 0 && timeDiff < dblClickTime && this.selectedImage === image) { 138 if (this.cClickTime !== 0 && timeDiff < dblClickTime && this.selectedImage === image) {
128 // DoubleClick 139 // DoubleClick
129 if (this.callback) { 140 if (this.callback) {
130 - this.callback(image); 141 + this.returnCallback(image);
131 } 142 }
132 this.hide(); 143 this.hide();
133 } else { 144 } else {
...@@ -139,7 +150,7 @@ ...@@ -139,7 +150,7 @@
139 150
140 selectButtonClick: function () { 151 selectButtonClick: function () {
141 if (this.callback) { 152 if (this.callback) {
142 - this.callback(this.selectedImage); 153 + this.returnCallback(this.selectedImage);
143 } 154 }
144 this.hide(); 155 this.hide();
145 }, 156 },
......
...@@ -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.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 };
......
...@@ -100,7 +100,7 @@ module.exports = { ...@@ -100,7 +100,7 @@ module.exports = {
100 onclick: function() { 100 onclick: function() {
101 ImageManager.show(function(image) { 101 ImageManager.show(function(image) {
102 var html = '<a href="'+image.url+'" target="_blank">'; 102 var html = '<a href="'+image.url+'" target="_blank">';
103 - html += '<img src="'+image.display+'" alt="'+image.name+'">'; 103 + html += '<img src="'+image.thumbs.display+'" alt="'+image.name+'">';
104 html += '</a>'; 104 html += '</a>';
105 editor.execCommand('mceInsertContent', false, html); 105 editor.execCommand('mceInsertContent', false, html);
106 }); 106 });
......
...@@ -118,6 +118,9 @@ ...@@ -118,6 +118,9 @@
118 text-decoration: none; 118 text-decoration: none;
119 } 119 }
120 } 120 }
121 + li a i {
122 + padding-right: $-xs + 2px;
123 + }
121 li, a { 124 li, a {
122 display: block; 125 display: block;
123 } 126 }
...@@ -150,11 +153,11 @@ ...@@ -150,11 +153,11 @@
150 } 153 }
151 .list-item-page { 154 .list-item-page {
152 border-bottom: none; 155 border-bottom: none;
156 + border-left: 5px solid $color-page;
157 + margin: 10px 10px;
153 } 158 }
154 .page { 159 .page {
155 color: $color-page !important; 160 color: $color-page !important;
156 - border-left: 5px solid $color-page;
157 - margin: 10px 10px;
158 border-bottom: none; 161 border-bottom: none;
159 &.selected { 162 &.selected {
160 background-color: rgba($color-page, 0.1); 163 background-color: rgba($color-page, 0.1);
......
...@@ -32,10 +32,16 @@ body.dragging, body.dragging * { ...@@ -32,10 +32,16 @@ body.dragging, body.dragging * {
32 .avatar { 32 .avatar {
33 border-radius: 100%; 33 border-radius: 100%;
34 background-color: #EEE; 34 background-color: #EEE;
35 + width: 30px;
36 + height: 30px;
35 &.med { 37 &.med {
36 width: 40px; 38 width: 40px;
37 height: 40px; 39 height: 40px;
38 } 40 }
41 + &.large {
42 + width: 80px;
43 + height: 80px;
44 + }
39 } 45 }
40 46
41 // System wide notifications 47 // System wide notifications
......
1 <!DOCTYPE html> 1 <!DOCTYPE html>
2 <html> 2 <html>
3 <head> 3 <head>
4 - <title>BookStack</title> 4 + <title>{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ Setting::get('app-name', 'BookStack') }}</title>
5 5
6 <!-- Meta --> 6 <!-- Meta -->
7 <meta name="viewport" content="width=device-width"> 7 <meta name="viewport" content="width=device-width">
...@@ -9,8 +9,8 @@ ...@@ -9,8 +9,8 @@
9 <meta charset="utf-8"> 9 <meta charset="utf-8">
10 10
11 <!-- Styles and Fonts --> 11 <!-- Styles and Fonts -->
12 - <link rel="stylesheet" href="{{ elixir('css/styles.css') }}"> 12 + <link rel="stylesheet" href="{{ versioned_asset('css/styles.css') }}">
13 - <link rel="stylesheet" media="print" href="{{ elixir('css/print-styles.css') }}"> 13 + <link rel="stylesheet" media="print" href="{{ versioned_asset('css/print-styles.css') }}">
14 <link href='//fonts.googleapis.com/css?family=Roboto:400,400italic,500,500italic,700,700italic,300italic,100,300' rel='stylesheet' type='text/css'> 14 <link href='//fonts.googleapis.com/css?family=Roboto:400,400italic,500,500italic,700,700italic,300italic,100,300' rel='stylesheet' type='text/css'>
15 <link rel="stylesheet" href="/libs/material-design-iconic-font/css/material-design-iconic-font.min.css"> 15 <link rel="stylesheet" href="/libs/material-design-iconic-font/css/material-design-iconic-font.min.css">
16 16
...@@ -79,6 +79,6 @@ ...@@ -79,6 +79,6 @@
79 </section> 79 </section>
80 80
81 @yield('bottom') 81 @yield('bottom')
82 -<script src="{{ elixir('js/common.js') }}"></script> 82 +<script src="{{ versioned_asset('js/common.js') }}"></script>
83 </body> 83 </body>
84 </html> 84 </html>
......
...@@ -34,12 +34,23 @@ ...@@ -34,12 +34,23 @@
34 @endif 34 @endif
35 </div> 35 </div>
36 <div class="col-sm-4 col-sm-offset-1"> 36 <div class="col-sm-4 col-sm-offset-1">
37 - <div class="margin-top large">&nbsp;</div> 37 + <div id="recents">
38 @if($recents) 38 @if($recents)
39 + <div class="margin-top large">&nbsp;</div>
39 <h3>Recently Viewed</h3> 40 <h3>Recently Viewed</h3>
40 @include('partials/entity-list', ['entities' => $recents]) 41 @include('partials/entity-list', ['entities' => $recents])
41 @endif 42 @endif
42 </div> 43 </div>
44 + <div class="margin-top large">&nbsp;</div>
45 + <div id="popular">
46 + <h3>Popular Books</h3>
47 + @if(count($popular) > 0)
48 + @include('partials/entity-list', ['entities' => $popular])
49 + @else
50 + <p class="text-muted">The most popular books will appear here.</p>
51 + @endif
52 + </div>
53 + </div>
43 </div> 54 </div>
44 </div> 55 </div>
45 56
......
...@@ -58,7 +58,7 @@ ...@@ -58,7 +58,7 @@
58 <p class="text-muted small"> 58 <p class="text-muted small">
59 Created {{$book->created_at->diffForHumans()}} @if($book->createdBy) by {{$book->createdBy->name}} @endif 59 Created {{$book->created_at->diffForHumans()}} @if($book->createdBy) by {{$book->createdBy->name}} @endif
60 <br> 60 <br>
61 - Last Updated {{$book->updated_at->diffForHumans()}} @if($book->createdBy) by {{$book->updatedBy->name}} @endif 61 + Last Updated {{$book->updated_at->diffForHumans()}} @if($book->updatedBy) by {{$book->updatedBy->name}} @endif
62 </p> 62 </p>
63 </div> 63 </div>
64 </div> 64 </div>
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
2 <h3 class="text-book"><i class="zmdi zmdi-book"></i>{{ $book->name }}</h3> 2 <h3 class="text-book"><i class="zmdi zmdi-book"></i>{{ $book->name }}</h3>
3 <ul class="sortable-page-list sort-list"> 3 <ul class="sortable-page-list sort-list">
4 @foreach($bookChildren as $bookChild) 4 @foreach($bookChildren as $bookChild)
5 - <li data-id="{{$bookChild->id}}" data-type="{{ $bookChild->getName() }}" class="text-{{ $bookChild->getName() }}"> 5 + <li data-id="{{$bookChild->id}}" data-type="{{ $bookChild->getClassName() }}" class="text-{{ $bookChild->getClassName() }}">
6 <i class="zmdi {{ $bookChild->isA('chapter') ? 'zmdi-collection-bookmark':'zmdi-file-text'}}"></i>{{ $bookChild->name }} 6 <i class="zmdi {{ $bookChild->isA('chapter') ? 'zmdi-collection-bookmark':'zmdi-file-text'}}"></i>{{ $bookChild->name }}
7 @if($bookChild->isA('chapter')) 7 @if($bookChild->isA('chapter'))
8 <ul> 8 <ul>
......
...@@ -56,7 +56,7 @@ ...@@ -56,7 +56,7 @@
56 <p class="text-muted small"> 56 <p class="text-muted small">
57 Created {{$chapter->created_at->diffForHumans()}} @if($chapter->createdBy) by {{$chapter->createdBy->name}} @endif 57 Created {{$chapter->created_at->diffForHumans()}} @if($chapter->createdBy) by {{$chapter->createdBy->name}} @endif
58 <br> 58 <br>
59 - Last Updated {{$chapter->updated_at->diffForHumans()}} @if($chapter->createdBy) by {{$chapter->updatedBy->name}} @endif 59 + Last Updated {{$chapter->updated_at->diffForHumans()}} @if($chapter->updatedBy) by {{$chapter->updatedBy->name}} @endif
60 </p> 60 </p>
61 </div> 61 </div>
62 <div class="col-md-3 col-md-offset-1"> 62 <div class="col-md-3 col-md-offset-1">
......
...@@ -16,5 +16,5 @@ ...@@ -16,5 +16,5 @@
16 @endif 16 @endif
17 </form> 17 </form>
18 </div> 18 </div>
19 - <image-manager></image-manager> 19 + <image-manager image-type="gallery"></image-manager>
20 @stop 20 @stop
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -14,6 +14,6 @@ ...@@ -14,6 +14,6 @@
14 @include('pages/form', ['model' => $page]) 14 @include('pages/form', ['model' => $page])
15 </form> 15 </form>
16 </div> 16 </div>
17 - <image-manager></image-manager> 17 + <image-manager image-type="gallery"></image-manager>
18 18
19 @stop 19 @stop
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -32,8 +32,12 @@ ...@@ -32,8 +32,12 @@
32 @foreach($page->revisions as $revision) 32 @foreach($page->revisions as $revision)
33 <tr> 33 <tr>
34 <td>{{$revision->name}}</td> 34 <td>{{$revision->name}}</td>
35 - <td style="line-height: 0;"><img class="avatar" src="{{ $revision->createdBy->getAvatar(30) }}" alt="{{$revision->createdBy->name}}"></td> 35 + <td style="line-height: 0;">
36 - <td> {{$revision->createdBy->name}}</td> 36 + @if($revision->createdBy)
37 + <img class="avatar" src="{{ $revision->createdBy->getAvatar(30) }}" alt="{{$revision->createdBy->name}}">
38 + @endif
39 + </td>
40 + <td> @if($revision->createdBy) {{$revision->createdBy->name}} @else Deleted User @endif</td>
37 <td><small>{{$revision->created_at->format('jS F, Y H:i:s')}} ({{$revision->created_at->diffForHumans()}})</small></td> 41 <td><small>{{$revision->created_at->format('jS F, Y H:i:s')}} ({{$revision->created_at->diffForHumans()}})</small></td>
38 <td> 42 <td>
39 <a href="{{$revision->getUrl()}}" target="_blank">Preview</a> 43 <a href="{{$revision->getUrl()}}" target="_blank">Preview</a>
......
...@@ -7,12 +7,12 @@ ...@@ -7,12 +7,12 @@
7 <div class="row"> 7 <div class="row">
8 <div class="col-sm-6 faded"> 8 <div class="col-sm-6 faded">
9 <div class="breadcrumbs"> 9 <div class="breadcrumbs">
10 - <a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->name }}</a> 10 + <a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
11 @if($page->hasChapter()) 11 @if($page->hasChapter())
12 <span class="sep">&raquo;</span> 12 <span class="sep">&raquo;</span>
13 <a href="{{ $page->chapter->getUrl() }}" class="text-chapter text-button"> 13 <a href="{{ $page->chapter->getUrl() }}" class="text-chapter text-button">
14 <i class="zmdi zmdi-collection-bookmark"></i> 14 <i class="zmdi zmdi-collection-bookmark"></i>
15 - {{$page->chapter->name}} 15 + {{$page->chapter->getShortName()}}
16 </a> 16 </a>
17 @endif 17 @endif
18 </div> 18 </div>
...@@ -53,7 +53,7 @@ ...@@ -53,7 +53,7 @@
53 <p class="text-muted small"> 53 <p class="text-muted small">
54 Created {{$page->created_at->diffForHumans()}} @if($page->createdBy) by {{$page->createdBy->name}} @endif 54 Created {{$page->created_at->diffForHumans()}} @if($page->createdBy) by {{$page->createdBy->name}} @endif
55 <br> 55 <br>
56 - Last Updated {{$page->updated_at->diffForHumans()}} @if($page->createdBy) by {{$page->updatedBy->name}} @endif 56 + Last Updated {{$page->updated_at->diffForHumans()}} @if($page->updatedBy) by {{$page->updatedBy->name}} @endif
57 </p> 57 </p>
58 58
59 </div> 59 </div>
......
...@@ -6,8 +6,8 @@ ...@@ -6,8 +6,8 @@
6 6
7 7
8 @foreach($sidebarTree as $bookChild) 8 @foreach($sidebarTree as $bookChild)
9 - <li class="list-item-{{ $bookChild->getName() }} {{ $bookChild->getName() }}"> 9 + <li class="list-item-{{ $bookChild->getClassName() }} {{ $bookChild->getClassName() }}">
10 - <a href="{{$bookChild->getUrl()}}" class="{{ $bookChild->getName() }} {{ $current->matches($bookChild)? 'selected' : '' }}"> 10 + <a href="{{$bookChild->getUrl()}}" class="{{ $bookChild->getClassName() }} {{ $current->matches($bookChild)? 'selected' : '' }}">
11 @if($bookChild->isA('chapter'))<i class="zmdi zmdi-collection-bookmark"></i>@else <i class="zmdi zmdi-file-text"></i>@endif{{ $bookChild->name }} 11 @if($bookChild->isA('chapter'))<i class="zmdi zmdi-collection-bookmark"></i>@else <i class="zmdi zmdi-file-text"></i>@endif{{ $bookChild->name }}
12 </a> 12 </a>
13 13
......
...@@ -10,6 +10,8 @@ ...@@ -10,6 +10,8 @@
10 <div class="right"> 10 <div class="right">
11 @if($activity->user) 11 @if($activity->user)
12 {{$activity->user->name}} 12 {{$activity->user->name}}
13 + @else
14 + A deleted user
13 @endif 15 @endif
14 16
15 {{ $activity->getText() }} 17 {{ $activity->getText() }}
......
1 1
2 @if(count($entities) > 0) 2 @if(count($entities) > 0)
3 - @foreach($entities as $entity) 3 + @foreach($entities as $index => $entity)
4 @if($entity->isA('page')) 4 @if($entity->isA('page'))
5 @include('pages/list-item', ['page' => $entity]) 5 @include('pages/list-item', ['page' => $entity])
6 @elseif($entity->isA('book')) 6 @elseif($entity->isA('book'))
...@@ -8,7 +8,11 @@ ...@@ -8,7 +8,11 @@
8 @elseif($entity->isA('chapter')) 8 @elseif($entity->isA('chapter'))
9 @include('chapters/list-item', ['chapter' => $entity, 'hidePages' => true]) 9 @include('chapters/list-item', ['chapter' => $entity, 'hidePages' => true])
10 @endif 10 @endif
11 +
12 + @if($index !== count($entities) - 1)
11 <hr> 13 <hr>
14 + @endif
15 +
12 @endforeach 16 @endforeach
13 @else 17 @else
14 <p class="text-muted"> 18 <p class="text-muted">
......
...@@ -23,12 +23,17 @@ ...@@ -23,12 +23,17 @@
23 <label>Allow public viewing?</label> 23 <label>Allow public viewing?</label>
24 <toggle-switch name="setting-app-public" value="{{ Setting::get('app-public') }}"></toggle-switch> 24 <toggle-switch name="setting-app-public" value="{{ Setting::get('app-public') }}"></toggle-switch>
25 </div> 25 </div>
26 + <div class="form-group">
27 + <label>Enable higher security image uploads?</label>
28 + <p class="small">For performance reasons, all images are public by default, This option adds a random, hard-to-guess characters in front of image names. Ensure directory indexes are not enabled to prevent easy access.</p>
29 + <toggle-switch name="setting-app-secure-images" value="{{ Setting::get('app-secure-images') }}"></toggle-switch>
30 + </div>
26 </div> 31 </div>
27 <div class="col-md-6"> 32 <div class="col-md-6">
28 <div class="form-group" id="logo-control"> 33 <div class="form-group" id="logo-control">
29 <label for="setting-app-logo">Application Logo</label> 34 <label for="setting-app-logo">Application Logo</label>
30 - <p class="small">This image should be 43px in height. </p> 35 + <p class="small">This image should be 43px in height. <br>Large images will be scaled down.</p>
31 - <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>
32 </div> 37 </div>
33 </div> 38 </div>
34 </div> 39 </div>
...@@ -57,7 +62,7 @@ ...@@ -57,7 +62,7 @@
57 </select> 62 </select>
58 </div> 63 </div>
59 <div class="form-group"> 64 <div class="form-group">
60 - <label for="setting-registration-confirmation">Require Email Confirmation?</label> 65 + <label for="setting-registration-confirmation">Require email confirmation?</label>
61 <p class="small">If domain restriction is used then email confirmation will be required and the below value will be ignored.</p> 66 <p class="small">If domain restriction is used then email confirmation will be required and the below value will be ignored.</p>
62 <toggle-switch name="setting-registration-confirmation" value="{{ Setting::get('registration-confirmation') }}"></toggle-switch> 67 <toggle-switch name="setting-registration-confirmation" value="{{ Setting::get('registration-confirmation') }}"></toggle-switch>
63 </div> 68 </div>
...@@ -81,6 +86,6 @@ ...@@ -81,6 +86,6 @@
81 86
82 </div> 87 </div>
83 88
84 -<image-manager></image-manager> 89 +<image-manager image-type="system"></image-manager>
85 90
86 @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 +
......
1 +<?php
2 +
3 +use Illuminate\Foundation\Testing\WithoutMiddleware;
4 +use Illuminate\Foundation\Testing\DatabaseMigrations;
5 +use Illuminate\Foundation\Testing\DatabaseTransactions;
6 +
7 +class ActivityTrackingTest extends TestCase
8 +{
9 +
10 + public function testRecentlyViewedBooks()
11 + {
12 + $books = \BookStack\Book::all()->take(10);
13 +
14 + $this->asAdmin()->visit('/books')
15 + ->dontSeeInElement('#recents', $books[0]->name)
16 + ->dontSeeInElement('#recents', $books[1]->name)
17 + ->visit($books[0]->getUrl())
18 + ->visit($books[1]->getUrl())
19 + ->visit('/books')
20 + ->seeInElement('#recents', $books[0]->name)
21 + ->seeInElement('#recents', $books[1]->name);
22 + }
23 +
24 + public function testPopularBooks()
25 + {
26 + $books = \BookStack\Book::all()->take(10);
27 +
28 + $this->asAdmin()->visit('/books')
29 + ->dontSeeInElement('#popular', $books[0]->name)
30 + ->dontSeeInElement('#popular', $books[1]->name)
31 + ->visit($books[0]->getUrl())
32 + ->visit($books[1]->getUrl())
33 + ->visit($books[0]->getUrl())
34 + ->visit('/books')
35 + ->seeInNthElement('#popular .book', 0, $books[0]->name)
36 + ->seeInNthElement('#popular .book', 1, $books[1]->name);
37 + }
38 +}
...@@ -171,4 +171,43 @@ class EntityTest extends TestCase ...@@ -171,4 +171,43 @@ class EntityTest extends TestCase
171 } 171 }
172 172
173 173
174 + public function testEntitiesViewableAfterCreatorDeletion()
175 + {
176 + // Create required assets and revisions
177 + $creator = $this->getNewUser();
178 + $updater = $this->getNewUser();
179 + $entities = $this->createEntityChainBelongingToUser($creator, $updater);
180 + $this->actingAs($creator);
181 + app('BookStack\Repos\UserRepo')->destroy($creator);
182 + app('BookStack\Repos\PageRepo')->saveRevision($entities['page']);
183 +
184 + $this->checkEntitiesViewable($entities);
185 + }
186 +
187 + public function testEntitiesViewableAfterUpdaterDeletion()
188 + {
189 + // Create required assets and revisions
190 + $creator = $this->getNewUser();
191 + $updater = $this->getNewUser();
192 + $entities = $this->createEntityChainBelongingToUser($creator, $updater);
193 + $this->actingAs($updater);
194 + app('BookStack\Repos\UserRepo')->destroy($updater);
195 + app('BookStack\Repos\PageRepo')->saveRevision($entities['page']);
196 +
197 + $this->checkEntitiesViewable($entities);
198 + }
199 +
200 + private function checkEntitiesViewable($entities)
201 + {
202 + // Check pages and books are visible.
203 + $this->asAdmin();
204 + $this->visit($entities['book']->getUrl())->seeStatusCode(200)
205 + ->visit($entities['chapter']->getUrl())->seeStatusCode(200)
206 + ->visit($entities['page']->getUrl())->seeStatusCode(200);
207 + // Check revision listing shows no errors.
208 + $this->visit($entities['page']->getUrl())
209 + ->click('Revisions')->seeStatusCode(200);
210 + }
211 +
212 +
174 } 213 }
......
...@@ -48,4 +48,65 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase ...@@ -48,4 +48,65 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
48 $settings->put($key, $value); 48 $settings->put($key, $value);
49 } 49 }
50 } 50 }
51 +
52 + /**
53 + * Create a group of entities that belong to a specific user.
54 + * @param $creatorUser
55 + * @param $updaterUser
56 + * @return array
57 + */
58 + protected function createEntityChainBelongingToUser($creatorUser, $updaterUser = false)
59 + {
60 + if ($updaterUser === false) $updaterUser = $creatorUser;
61 + $book = factory(BookStack\Book::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id]);
62 + $chapter = factory(BookStack\Chapter::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id]);
63 + $page = factory(BookStack\Page::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id]);
64 + $book->chapters()->saveMany([$chapter]);
65 + $chapter->pages()->saveMany([$page]);
66 + return [
67 + 'book' => $book,
68 + 'chapter' => $chapter,
69 + 'page' => $page
70 + ];
71 + }
72 +
73 + /**
74 + * Quick way to create a new user
75 + * @param array $attributes
76 + * @return mixed
77 + */
78 + protected function getNewUser($attributes = [])
79 + {
80 + $user = factory(\BookStack\User::class)->create($attributes);
81 + $userRepo = app('BookStack\Repos\UserRepo');
82 + $userRepo->attachDefaultRole($user);
83 + return $user;
84 + }
85 +
86 + /**
87 + * Assert that a given string is seen inside an element.
88 + *
89 + * @param bool|string|null $element
90 + * @param integer $position
91 + * @param string $text
92 + * @param bool $negate
93 + * @return $this
94 + */
95 + protected function seeInNthElement($element, $position, $text, $negate = false)
96 + {
97 + $method = $negate ? 'assertNotRegExp' : 'assertRegExp';
98 +
99 + $rawPattern = preg_quote($text, '/');
100 +
101 + $escapedPattern = preg_quote(e($text), '/');
102 +
103 + $content = $this->crawler->filter($element)->eq($position)->html();
104 +
105 + $pattern = $rawPattern == $escapedPattern
106 + ? $rawPattern : "({$rawPattern}|{$escapedPattern})";
107 +
108 + $this->$method("/$pattern/i", $content);
109 +
110 + return $this;
111 + }
51 } 112 }
......