Dan Brown
Committed by GitHub

Merge pull request #205 from ssddanbrown/attachments

Implementation of File Attachments
1 +<?php namespace BookStack\Exceptions;
2 +
3 +
4 +class FileUploadException extends PrettyException {}
...\ No newline at end of file ...\ No newline at end of file
1 +<?php namespace BookStack;
2 +
3 +
4 +class File extends Ownable
5 +{
6 + protected $fillable = ['name', 'order'];
7 +
8 + /**
9 + * Get the downloadable file name for this upload.
10 + * @return mixed|string
11 + */
12 + public function getFileName()
13 + {
14 + if (str_contains($this->name, '.')) return $this->name;
15 + return $this->name . '.' . $this->extension;
16 + }
17 +
18 + /**
19 + * Get the page this file was uploaded to.
20 + * @return Page
21 + */
22 + public function page()
23 + {
24 + return $this->belongsTo(Page::class, 'uploaded_to');
25 + }
26 +
27 + /**
28 + * Get the url of this file.
29 + * @return string
30 + */
31 + public function getUrl()
32 + {
33 + return baseUrl('/files/' . $this->id);
34 + }
35 +
36 +}
...@@ -3,13 +3,11 @@ ...@@ -3,13 +3,11 @@
3 namespace BookStack\Http\Controllers; 3 namespace BookStack\Http\Controllers;
4 4
5 use BookStack\Ownable; 5 use BookStack\Ownable;
6 -use HttpRequestException;
7 use Illuminate\Foundation\Bus\DispatchesJobs; 6 use Illuminate\Foundation\Bus\DispatchesJobs;
8 use Illuminate\Http\Exception\HttpResponseException; 7 use Illuminate\Http\Exception\HttpResponseException;
8 +use Illuminate\Http\Request;
9 use Illuminate\Routing\Controller as BaseController; 9 use Illuminate\Routing\Controller as BaseController;
10 use Illuminate\Foundation\Validation\ValidatesRequests; 10 use Illuminate\Foundation\Validation\ValidatesRequests;
11 -use Illuminate\Support\Facades\Auth;
12 -use Illuminate\Support\Facades\Session;
13 use BookStack\User; 11 use BookStack\User;
14 12
15 abstract class Controller extends BaseController 13 abstract class Controller extends BaseController
...@@ -71,8 +69,13 @@ abstract class Controller extends BaseController ...@@ -71,8 +69,13 @@ abstract class Controller extends BaseController
71 */ 69 */
72 protected function showPermissionError() 70 protected function showPermissionError()
73 { 71 {
74 - Session::flash('error', trans('errors.permission')); 72 + if (request()->wantsJson()) {
75 - $response = request()->wantsJson() ? response()->json(['error' => trans('errors.permissionJson')], 403) : redirect('/'); 73 + $response = response()->json(['error' => trans('errors.permissionJson')], 403);
74 + } else {
75 + $response = redirect('/');
76 + session()->flash('error', trans('errors.permission'));
77 + }
78 +
76 throw new HttpResponseException($response); 79 throw new HttpResponseException($response);
77 } 80 }
78 81
...@@ -83,7 +86,7 @@ abstract class Controller extends BaseController ...@@ -83,7 +86,7 @@ abstract class Controller extends BaseController
83 */ 86 */
84 protected function checkPermission($permissionName) 87 protected function checkPermission($permissionName)
85 { 88 {
86 - if (!$this->currentUser || !$this->currentUser->can($permissionName)) { 89 + if (!user() || !user()->can($permissionName)) {
87 $this->showPermissionError(); 90 $this->showPermissionError();
88 } 91 }
89 return true; 92 return true;
...@@ -125,4 +128,22 @@ abstract class Controller extends BaseController ...@@ -125,4 +128,22 @@ abstract class Controller extends BaseController
125 return response()->json(['message' => $messageText], $statusCode); 128 return response()->json(['message' => $messageText], $statusCode);
126 } 129 }
127 130
131 + /**
132 + * Create the response for when a request fails validation.
133 + *
134 + * @param \Illuminate\Http\Request $request
135 + * @param array $errors
136 + * @return \Symfony\Component\HttpFoundation\Response
137 + */
138 + protected function buildFailedValidationResponse(Request $request, array $errors)
139 + {
140 + if ($request->expectsJson()) {
141 + return response()->json(['validation' => $errors], 422);
142 + }
143 +
144 + return redirect()->to($this->getRedirectUrl())
145 + ->withInput($request->input())
146 + ->withErrors($errors, $this->errorBag());
147 + }
148 +
128 } 149 }
......
1 +<?php namespace BookStack\Http\Controllers;
2 +
3 +use BookStack\Exceptions\FileUploadException;
4 +use BookStack\File;
5 +use BookStack\Repos\PageRepo;
6 +use BookStack\Services\FileService;
7 +use Illuminate\Http\Request;
8 +
9 +use BookStack\Http\Requests;
10 +
11 +class FileController extends Controller
12 +{
13 + protected $fileService;
14 + protected $file;
15 + protected $pageRepo;
16 +
17 + /**
18 + * FileController constructor.
19 + * @param FileService $fileService
20 + * @param File $file
21 + * @param PageRepo $pageRepo
22 + */
23 + public function __construct(FileService $fileService, File $file, PageRepo $pageRepo)
24 + {
25 + $this->fileService = $fileService;
26 + $this->file = $file;
27 + $this->pageRepo = $pageRepo;
28 + }
29 +
30 +
31 + /**
32 + * Endpoint at which files are uploaded to.
33 + * @param Request $request
34 + */
35 + public function upload(Request $request)
36 + {
37 + $this->validate($request, [
38 + 'uploaded_to' => 'required|integer|exists:pages,id',
39 + 'file' => 'required|file'
40 + ]);
41 +
42 + $pageId = $request->get('uploaded_to');
43 + $page = $this->pageRepo->getById($pageId);
44 +
45 + $this->checkPermission('file-create-all');
46 + $this->checkOwnablePermission('page-update', $page);
47 +
48 + $uploadedFile = $request->file('file');
49 +
50 + try {
51 + $file = $this->fileService->saveNewUpload($uploadedFile, $pageId);
52 + } catch (FileUploadException $e) {
53 + return response($e->getMessage(), 500);
54 + }
55 +
56 + return response()->json($file);
57 + }
58 +
59 + /**
60 + * Update an uploaded file.
61 + * @param int $fileId
62 + * @param Request $request
63 + * @return mixed
64 + */
65 + public function uploadUpdate($fileId, Request $request)
66 + {
67 + $this->validate($request, [
68 + 'uploaded_to' => 'required|integer|exists:pages,id',
69 + 'file' => 'required|file'
70 + ]);
71 +
72 + $pageId = $request->get('uploaded_to');
73 + $page = $this->pageRepo->getById($pageId);
74 + $file = $this->file->findOrFail($fileId);
75 +
76 + $this->checkOwnablePermission('page-update', $page);
77 + $this->checkOwnablePermission('file-create', $file);
78 +
79 + if (intval($pageId) !== intval($file->uploaded_to)) {
80 + return $this->jsonError('Page mismatch during attached file update');
81 + }
82 +
83 + $uploadedFile = $request->file('file');
84 +
85 + try {
86 + $file = $this->fileService->saveUpdatedUpload($uploadedFile, $file);
87 + } catch (FileUploadException $e) {
88 + return response($e->getMessage(), 500);
89 + }
90 +
91 + return response()->json($file);
92 + }
93 +
94 + /**
95 + * Update the details of an existing file.
96 + * @param $fileId
97 + * @param Request $request
98 + * @return File|mixed
99 + */
100 + public function update($fileId, Request $request)
101 + {
102 + $this->validate($request, [
103 + 'uploaded_to' => 'required|integer|exists:pages,id',
104 + 'name' => 'required|string|min:1|max:255',
105 + 'link' => 'url|min:1|max:255'
106 + ]);
107 +
108 + $pageId = $request->get('uploaded_to');
109 + $page = $this->pageRepo->getById($pageId);
110 + $file = $this->file->findOrFail($fileId);
111 +
112 + $this->checkOwnablePermission('page-update', $page);
113 + $this->checkOwnablePermission('file-create', $file);
114 +
115 + if (intval($pageId) !== intval($file->uploaded_to)) {
116 + return $this->jsonError('Page mismatch during attachment update');
117 + }
118 +
119 + $file = $this->fileService->updateFile($file, $request->all());
120 + return $file;
121 + }
122 +
123 + /**
124 + * Attach a link to a page as a file.
125 + * @param Request $request
126 + * @return mixed
127 + */
128 + public function attachLink(Request $request)
129 + {
130 + $this->validate($request, [
131 + 'uploaded_to' => 'required|integer|exists:pages,id',
132 + 'name' => 'required|string|min:1|max:255',
133 + 'link' => 'required|url|min:1|max:255'
134 + ]);
135 +
136 + $pageId = $request->get('uploaded_to');
137 + $page = $this->pageRepo->getById($pageId);
138 +
139 + $this->checkPermission('file-create-all');
140 + $this->checkOwnablePermission('page-update', $page);
141 +
142 + $fileName = $request->get('name');
143 + $link = $request->get('link');
144 + $file = $this->fileService->saveNewFromLink($fileName, $link, $pageId);
145 +
146 + return response()->json($file);
147 + }
148 +
149 + /**
150 + * Get the files for a specific page.
151 + * @param $pageId
152 + * @return mixed
153 + */
154 + public function listForPage($pageId)
155 + {
156 + $page = $this->pageRepo->getById($pageId);
157 + $this->checkOwnablePermission('page-view', $page);
158 + return response()->json($page->files);
159 + }
160 +
161 + /**
162 + * Update the file sorting.
163 + * @param $pageId
164 + * @param Request $request
165 + * @return mixed
166 + */
167 + public function sortForPage($pageId, Request $request)
168 + {
169 + $this->validate($request, [
170 + 'files' => 'required|array',
171 + 'files.*.id' => 'required|integer',
172 + ]);
173 + $page = $this->pageRepo->getById($pageId);
174 + $this->checkOwnablePermission('page-update', $page);
175 +
176 + $files = $request->get('files');
177 + $this->fileService->updateFileOrderWithinPage($files, $pageId);
178 + return response()->json(['message' => 'Attachment order updated']);
179 + }
180 +
181 + /**
182 + * Get a file from storage.
183 + * @param $fileId
184 + */
185 + public function get($fileId)
186 + {
187 + $file = $this->file->findOrFail($fileId);
188 + $page = $this->pageRepo->getById($file->uploaded_to);
189 + $this->checkOwnablePermission('page-view', $page);
190 +
191 + if ($file->external) {
192 + return redirect($file->path);
193 + }
194 +
195 + $fileContents = $this->fileService->getFile($file);
196 + return response($fileContents, 200, [
197 + 'Content-Type' => 'application/octet-stream',
198 + 'Content-Disposition' => 'attachment; filename="'. $file->getFileName() .'"'
199 + ]);
200 + }
201 +
202 + /**
203 + * Delete a specific file in the system.
204 + * @param $fileId
205 + * @return mixed
206 + */
207 + public function delete($fileId)
208 + {
209 + $file = $this->file->findOrFail($fileId);
210 + $this->checkOwnablePermission('file-delete', $file);
211 + $this->fileService->deleteFile($file);
212 + return response()->json(['message' => 'Attachment deleted']);
213 + }
214 +}
...@@ -55,6 +55,15 @@ class Page extends Entity ...@@ -55,6 +55,15 @@ class Page extends Entity
55 } 55 }
56 56
57 /** 57 /**
58 + * Get the files attached to this page.
59 + * @return \Illuminate\Database\Eloquent\Relations\HasMany
60 + */
61 + public function files()
62 + {
63 + return $this->hasMany(File::class, 'uploaded_to')->orderBy('order', 'asc');
64 + }
65 +
66 + /**
58 * Get the url for this page. 67 * Get the url for this page.
59 * @param string|bool $path 68 * @param string|bool $path
60 * @return string 69 * @return string
......
...@@ -5,6 +5,7 @@ use BookStack\Book; ...@@ -5,6 +5,7 @@ use BookStack\Book;
5 use BookStack\Chapter; 5 use BookStack\Chapter;
6 use BookStack\Entity; 6 use BookStack\Entity;
7 use BookStack\Exceptions\NotFoundException; 7 use BookStack\Exceptions\NotFoundException;
8 +use BookStack\Services\FileService;
8 use Carbon\Carbon; 9 use Carbon\Carbon;
9 use DOMDocument; 10 use DOMDocument;
10 use DOMXPath; 11 use DOMXPath;
...@@ -48,7 +49,7 @@ class PageRepo extends EntityRepo ...@@ -48,7 +49,7 @@ class PageRepo extends EntityRepo
48 * Get a page via a specific ID. 49 * Get a page via a specific ID.
49 * @param $id 50 * @param $id
50 * @param bool $allowDrafts 51 * @param bool $allowDrafts
51 - * @return mixed 52 + * @return Page
52 */ 53 */
53 public function getById($id, $allowDrafts = false) 54 public function getById($id, $allowDrafts = false)
54 { 55 {
...@@ -633,6 +634,13 @@ class PageRepo extends EntityRepo ...@@ -633,6 +634,13 @@ class PageRepo extends EntityRepo
633 $page->revisions()->delete(); 634 $page->revisions()->delete();
634 $page->permissions()->delete(); 635 $page->permissions()->delete();
635 $this->permissionService->deleteJointPermissionsForEntity($page); 636 $this->permissionService->deleteJointPermissionsForEntity($page);
637 +
638 + // Delete AttachedFiles
639 + $fileService = app(FileService::class);
640 + foreach ($page->files as $file) {
641 + $fileService->deleteFile($file);
642 + }
643 +
636 $page->delete(); 644 $page->delete();
637 } 645 }
638 646
......
1 +<?php namespace BookStack\Services;
2 +
3 +
4 +use BookStack\Exceptions\FileUploadException;
5 +use BookStack\File;
6 +use Exception;
7 +use Illuminate\Contracts\Filesystem\FileNotFoundException;
8 +use Illuminate\Support\Collection;
9 +use Symfony\Component\HttpFoundation\File\UploadedFile;
10 +
11 +class FileService extends UploadService
12 +{
13 +
14 + /**
15 + * Get a file from storage.
16 + * @param File $file
17 + * @return string
18 + */
19 + public function getFile(File $file)
20 + {
21 + $filePath = $this->getStorageBasePath() . $file->path;
22 + return $this->getStorage()->get($filePath);
23 + }
24 +
25 + /**
26 + * Store a new file upon user upload.
27 + * @param UploadedFile $uploadedFile
28 + * @param int $page_id
29 + * @return File
30 + * @throws FileUploadException
31 + */
32 + public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
33 + {
34 + $fileName = $uploadedFile->getClientOriginalName();
35 + $filePath = $this->putFileInStorage($fileName, $uploadedFile);
36 + $largestExistingOrder = File::where('uploaded_to', '=', $page_id)->max('order');
37 +
38 + $file = File::forceCreate([
39 + 'name' => $fileName,
40 + 'path' => $filePath,
41 + 'extension' => $uploadedFile->getClientOriginalExtension(),
42 + 'uploaded_to' => $page_id,
43 + 'created_by' => user()->id,
44 + 'updated_by' => user()->id,
45 + 'order' => $largestExistingOrder + 1
46 + ]);
47 +
48 + return $file;
49 + }
50 +
51 + /**
52 + * Store a upload, saving to a file and deleting any existing uploads
53 + * attached to that file.
54 + * @param UploadedFile $uploadedFile
55 + * @param File $file
56 + * @return File
57 + * @throws FileUploadException
58 + */
59 + public function saveUpdatedUpload(UploadedFile $uploadedFile, File $file)
60 + {
61 + if (!$file->external) {
62 + $this->deleteFileInStorage($file);
63 + }
64 +
65 + $fileName = $uploadedFile->getClientOriginalName();
66 + $filePath = $this->putFileInStorage($fileName, $uploadedFile);
67 +
68 + $file->name = $fileName;
69 + $file->path = $filePath;
70 + $file->external = false;
71 + $file->extension = $uploadedFile->getClientOriginalExtension();
72 + $file->save();
73 + return $file;
74 + }
75 +
76 + /**
77 + * Save a new File attachment from a given link and name.
78 + * @param string $name
79 + * @param string $link
80 + * @param int $page_id
81 + * @return File
82 + */
83 + public function saveNewFromLink($name, $link, $page_id)
84 + {
85 + $largestExistingOrder = File::where('uploaded_to', '=', $page_id)->max('order');
86 + return File::forceCreate([
87 + 'name' => $name,
88 + 'path' => $link,
89 + 'external' => true,
90 + 'extension' => '',
91 + 'uploaded_to' => $page_id,
92 + 'created_by' => user()->id,
93 + 'updated_by' => user()->id,
94 + 'order' => $largestExistingOrder + 1
95 + ]);
96 + }
97 +
98 + /**
99 + * Get the file storage base path, amended for storage type.
100 + * This allows us to keep a generic path in the database.
101 + * @return string
102 + */
103 + private function getStorageBasePath()
104 + {
105 + return $this->isLocal() ? 'storage/' : '';
106 + }
107 +
108 + /**
109 + * Updates the file ordering for a listing of attached files.
110 + * @param array $fileList
111 + * @param $pageId
112 + */
113 + public function updateFileOrderWithinPage($fileList, $pageId)
114 + {
115 + foreach ($fileList as $index => $file) {
116 + File::where('uploaded_to', '=', $pageId)->where('id', '=', $file['id'])->update(['order' => $index]);
117 + }
118 + }
119 +
120 +
121 + /**
122 + * Update the details of a file.
123 + * @param File $file
124 + * @param $requestData
125 + * @return File
126 + */
127 + public function updateFile(File $file, $requestData)
128 + {
129 + $file->name = $requestData['name'];
130 + if (isset($requestData['link']) && trim($requestData['link']) !== '') {
131 + $file->path = $requestData['link'];
132 + if (!$file->external) {
133 + $this->deleteFileInStorage($file);
134 + $file->external = true;
135 + }
136 + }
137 + $file->save();
138 + return $file;
139 + }
140 +
141 + /**
142 + * Delete a File from the database and storage.
143 + * @param File $file
144 + */
145 + public function deleteFile(File $file)
146 + {
147 + if ($file->external) {
148 + $file->delete();
149 + return;
150 + }
151 +
152 + $this->deleteFileInStorage($file);
153 + $file->delete();
154 + }
155 +
156 + /**
157 + * Delete a file from the filesystem it sits on.
158 + * Cleans any empty leftover folders.
159 + * @param File $file
160 + */
161 + protected function deleteFileInStorage(File $file)
162 + {
163 + $storedFilePath = $this->getStorageBasePath() . $file->path;
164 + $storage = $this->getStorage();
165 + $dirPath = dirname($storedFilePath);
166 +
167 + $storage->delete($storedFilePath);
168 + if (count($storage->allFiles($dirPath)) === 0) {
169 + $storage->deleteDirectory($dirPath);
170 + }
171 + }
172 +
173 + /**
174 + * Store a file in storage with the given filename
175 + * @param $fileName
176 + * @param UploadedFile $uploadedFile
177 + * @return string
178 + * @throws FileUploadException
179 + */
180 + protected function putFileInStorage($fileName, UploadedFile $uploadedFile)
181 + {
182 + $fileData = file_get_contents($uploadedFile->getRealPath());
183 +
184 + $storage = $this->getStorage();
185 + $fileBasePath = 'uploads/files/' . Date('Y-m-M') . '/';
186 + $storageBasePath = $this->getStorageBasePath() . $fileBasePath;
187 +
188 + $uploadFileName = $fileName;
189 + while ($storage->exists($storageBasePath . $uploadFileName)) {
190 + $uploadFileName = str_random(3) . $uploadFileName;
191 + }
192 +
193 + $filePath = $fileBasePath . $uploadFileName;
194 + $fileStoragePath = $this->getStorageBasePath() . $filePath;
195 +
196 + try {
197 + $storage->put($fileStoragePath, $fileData);
198 + } catch (Exception $e) {
199 + throw new FileUploadException('File path ' . $fileStoragePath . ' could not be uploaded to. Ensure it is writable to the server.');
200 + }
201 + return $filePath;
202 + }
203 +
204 +}
...\ No newline at end of file ...\ No newline at end of file
...@@ -9,20 +9,13 @@ use Intervention\Image\ImageManager; ...@@ -9,20 +9,13 @@ use Intervention\Image\ImageManager;
9 use Illuminate\Contracts\Filesystem\Factory as FileSystem; 9 use Illuminate\Contracts\Filesystem\Factory as FileSystem;
10 use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance; 10 use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
11 use Illuminate\Contracts\Cache\Repository as Cache; 11 use Illuminate\Contracts\Cache\Repository as Cache;
12 -use Setting;
13 use Symfony\Component\HttpFoundation\File\UploadedFile; 12 use Symfony\Component\HttpFoundation\File\UploadedFile;
14 13
15 -class ImageService 14 +class ImageService extends UploadService
16 { 15 {
17 16
18 protected $imageTool; 17 protected $imageTool;
19 - protected $fileSystem;
20 protected $cache; 18 protected $cache;
21 -
22 - /**
23 - * @var FileSystemInstance
24 - */
25 - protected $storageInstance;
26 protected $storageUrl; 19 protected $storageUrl;
27 20
28 /** 21 /**
...@@ -34,8 +27,8 @@ class ImageService ...@@ -34,8 +27,8 @@ class ImageService
34 public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache) 27 public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
35 { 28 {
36 $this->imageTool = $imageTool; 29 $this->imageTool = $imageTool;
37 - $this->fileSystem = $fileSystem;
38 $this->cache = $cache; 30 $this->cache = $cache;
31 + parent::__construct($fileSystem);
39 } 32 }
40 33
41 /** 34 /**
...@@ -88,6 +81,9 @@ class ImageService ...@@ -88,6 +81,9 @@ class ImageService
88 if ($secureUploads) $imageName = str_random(16) . '-' . $imageName; 81 if ($secureUploads) $imageName = str_random(16) . '-' . $imageName;
89 82
90 $imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/'; 83 $imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/';
84 +
85 + if ($this->isLocal()) $imagePath = '/public' . $imagePath;
86 +
91 while ($storage->exists($imagePath . $imageName)) { 87 while ($storage->exists($imagePath . $imageName)) {
92 $imageName = str_random(3) . $imageName; 88 $imageName = str_random(3) . $imageName;
93 } 89 }
...@@ -100,6 +96,8 @@ class ImageService ...@@ -100,6 +96,8 @@ class ImageService
100 throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.'); 96 throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.');
101 } 97 }
102 98
99 + if ($this->isLocal()) $fullPath = str_replace_first('/public', '', $fullPath);
100 +
103 $imageDetails = [ 101 $imageDetails = [
104 'name' => $imageName, 102 'name' => $imageName,
105 'path' => $fullPath, 103 'path' => $fullPath,
...@@ -120,6 +118,16 @@ class ImageService ...@@ -120,6 +118,16 @@ class ImageService
120 } 118 }
121 119
122 /** 120 /**
121 + * Get the storage path, Dependant of storage type.
122 + * @param Image $image
123 + * @return mixed|string
124 + */
125 + protected function getPath(Image $image)
126 + {
127 + return ($this->isLocal()) ? ('public/' . $image->path) : $image->path;
128 + }
129 +
130 + /**
123 * Get the thumbnail for an image. 131 * Get the thumbnail for an image.
124 * If $keepRatio is true only the width will be used. 132 * If $keepRatio is true only the width will be used.
125 * Checks the cache then storage to avoid creating / accessing the filesystem on every check. 133 * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
...@@ -135,7 +143,8 @@ class ImageService ...@@ -135,7 +143,8 @@ class ImageService
135 public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false) 143 public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
136 { 144 {
137 $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/'; 145 $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
138 - $thumbFilePath = dirname($image->path) . $thumbDirName . basename($image->path); 146 + $imagePath = $this->getPath($image);
147 + $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
139 148
140 if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) { 149 if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) {
141 return $this->getPublicUrl($thumbFilePath); 150 return $this->getPublicUrl($thumbFilePath);
...@@ -148,7 +157,7 @@ class ImageService ...@@ -148,7 +157,7 @@ class ImageService
148 } 157 }
149 158
150 try { 159 try {
151 - $thumb = $this->imageTool->make($storage->get($image->path)); 160 + $thumb = $this->imageTool->make($storage->get($imagePath));
152 } catch (Exception $e) { 161 } catch (Exception $e) {
153 if ($e instanceof \ErrorException || $e instanceof NotSupportedException) { 162 if ($e instanceof \ErrorException || $e instanceof NotSupportedException) {
154 throw new ImageUploadException('The server cannot create thumbnails. Please check you have the GD PHP extension installed.'); 163 throw new ImageUploadException('The server cannot create thumbnails. Please check you have the GD PHP extension installed.');
...@@ -183,8 +192,8 @@ class ImageService ...@@ -183,8 +192,8 @@ class ImageService
183 { 192 {
184 $storage = $this->getStorage(); 193 $storage = $this->getStorage();
185 194
186 - $imageFolder = dirname($image->path); 195 + $imageFolder = dirname($this->getPath($image));
187 - $imageFileName = basename($image->path); 196 + $imageFileName = basename($this->getPath($image));
188 $allImages = collect($storage->allFiles($imageFolder)); 197 $allImages = collect($storage->allFiles($imageFolder));
189 198
190 $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) { 199 $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
...@@ -223,34 +232,8 @@ class ImageService ...@@ -223,34 +232,8 @@ class ImageService
223 } 232 }
224 233
225 /** 234 /**
226 - * Get the storage that will be used for storing images.
227 - * @return FileSystemInstance
228 - */
229 - private function getStorage()
230 - {
231 - if ($this->storageInstance !== null) return $this->storageInstance;
232 -
233 - $storageType = config('filesystems.default');
234 - $this->storageInstance = $this->fileSystem->disk($storageType);
235 -
236 - return $this->storageInstance;
237 - }
238 -
239 - /**
240 - * Check whether or not a folder is empty.
241 - * @param $path
242 - * @return int
243 - */
244 - private function isFolderEmpty($path)
245 - {
246 - $files = $this->getStorage()->files($path);
247 - $folders = $this->getStorage()->directories($path);
248 - return count($files) === 0 && count($folders) === 0;
249 - }
250 -
251 - /**
252 * Gets a public facing url for an image by checking relevant environment variables. 235 * Gets a public facing url for an image by checking relevant environment variables.
253 - * @param $filePath 236 + * @param string $filePath
254 * @return string 237 * @return string
255 */ 238 */
256 private function getPublicUrl($filePath) 239 private function getPublicUrl($filePath)
...@@ -273,6 +256,8 @@ class ImageService ...@@ -273,6 +256,8 @@ class ImageService
273 $this->storageUrl = $storageUrl; 256 $this->storageUrl = $storageUrl;
274 } 257 }
275 258
259 + if ($this->isLocal()) $filePath = str_replace_first('public/', '', $filePath);
260 +
276 return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath; 261 return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath;
277 } 262 }
278 263
......
1 +<?php namespace BookStack\Services;
2 +
3 +use Illuminate\Contracts\Filesystem\Factory as FileSystem;
4 +use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
5 +
6 +class UploadService
7 +{
8 +
9 + /**
10 + * @var FileSystem
11 + */
12 + protected $fileSystem;
13 +
14 + /**
15 + * @var FileSystemInstance
16 + */
17 + protected $storageInstance;
18 +
19 +
20 + /**
21 + * FileService constructor.
22 + * @param $fileSystem
23 + */
24 + public function __construct(FileSystem $fileSystem)
25 + {
26 + $this->fileSystem = $fileSystem;
27 + }
28 +
29 + /**
30 + * Get the storage that will be used for storing images.
31 + * @return FileSystemInstance
32 + */
33 + protected function getStorage()
34 + {
35 + if ($this->storageInstance !== null) return $this->storageInstance;
36 +
37 + $storageType = config('filesystems.default');
38 + $this->storageInstance = $this->fileSystem->disk($storageType);
39 +
40 + return $this->storageInstance;
41 + }
42 +
43 +
44 + /**
45 + * Check whether or not a folder is empty.
46 + * @param $path
47 + * @return bool
48 + */
49 + protected function isFolderEmpty($path)
50 + {
51 + $files = $this->getStorage()->files($path);
52 + $folders = $this->getStorage()->directories($path);
53 + return (count($files) === 0 && count($folders) === 0);
54 + }
55 +
56 + /**
57 + * Check if using a local filesystem.
58 + * @return bool
59 + */
60 + protected function isLocal()
61 + {
62 + return strtolower(config('filesystems.default')) === 'local';
63 + }
64 +}
...\ No newline at end of file ...\ No newline at end of file
...@@ -56,7 +56,7 @@ return [ ...@@ -56,7 +56,7 @@ return [
56 56
57 'local' => [ 57 'local' => [
58 'driver' => 'local', 58 'driver' => 'local',
59 - 'root' => public_path(), 59 + 'root' => base_path(),
60 ], 60 ],
61 61
62 'ftp' => [ 62 'ftp' => [
......
1 +<?php
2 +
3 +use Illuminate\Support\Facades\Schema;
4 +use Illuminate\Database\Schema\Blueprint;
5 +use Illuminate\Database\Migrations\Migration;
6 +
7 +class CreateFilesTable extends Migration
8 +{
9 + /**
10 + * Run the migrations.
11 + *
12 + * @return void
13 + */
14 + public function up()
15 + {
16 + Schema::create('files', function (Blueprint $table) {
17 + $table->increments('id');
18 + $table->string('name');
19 + $table->string('path');
20 + $table->string('extension', 20);
21 + $table->integer('uploaded_to');
22 +
23 + $table->boolean('external');
24 + $table->integer('order');
25 +
26 + $table->integer('created_by');
27 + $table->integer('updated_by');
28 +
29 + $table->index('uploaded_to');
30 + $table->timestamps();
31 + });
32 +
33 + // Get roles with permissions we need to change
34 + $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
35 +
36 + // Create & attach new entity permissions
37 + $ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
38 + $entity = 'File';
39 + foreach ($ops as $op) {
40 + $permissionId = DB::table('role_permissions')->insertGetId([
41 + 'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
42 + 'display_name' => $op . ' ' . $entity . 's',
43 + 'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
44 + 'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
45 + ]);
46 + DB::table('permission_role')->insert([
47 + 'role_id' => $adminRoleId,
48 + 'permission_id' => $permissionId
49 + ]);
50 + }
51 +
52 + }
53 +
54 + /**
55 + * Reverse the migrations.
56 + *
57 + * @return void
58 + */
59 + public function down()
60 + {
61 + Schema::dropIfExists('files');
62 +
63 + // Create & attach new entity permissions
64 + $ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
65 + $entity = 'File';
66 + foreach ($ops as $op) {
67 + $permName = strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op));
68 + DB::table('role_permissions')->where('name', '=', $permName)->delete();
69 + }
70 + }
71 +}
...@@ -460,7 +460,7 @@ module.exports = function (ngApp, events) { ...@@ -460,7 +460,7 @@ module.exports = function (ngApp, events) {
460 * Get all tags for the current book and add into scope. 460 * Get all tags for the current book and add into scope.
461 */ 461 */
462 function getTags() { 462 function getTags() {
463 - let url = window.baseUrl('/ajax/tags/get/page/' + pageId); 463 + let url = window.baseUrl(`/ajax/tags/get/page/${pageId}`);
464 $http.get(url).then((responseData) => { 464 $http.get(url).then((responseData) => {
465 $scope.tags = responseData.data; 465 $scope.tags = responseData.data;
466 addEmptyTag(); 466 addEmptyTag();
...@@ -529,6 +529,205 @@ module.exports = function (ngApp, events) { ...@@ -529,6 +529,205 @@ module.exports = function (ngApp, events) {
529 529
530 }]); 530 }]);
531 531
532 +
533 + ngApp.controller('PageAttachmentController', ['$scope', '$http', '$attrs',
534 + function ($scope, $http, $attrs) {
535 +
536 + const pageId = $scope.uploadedTo = $attrs.pageId;
537 + let currentOrder = '';
538 + $scope.files = [];
539 + $scope.editFile = false;
540 + $scope.file = getCleanFile();
541 + $scope.errors = {
542 + link: {},
543 + edit: {}
544 + };
545 +
546 + function getCleanFile() {
547 + return {
548 + page_id: pageId
549 + };
550 + }
551 +
552 + // Angular-UI-Sort options
553 + $scope.sortOptions = {
554 + handle: '.handle',
555 + items: '> tr',
556 + containment: "parent",
557 + axis: "y",
558 + stop: sortUpdate,
559 + };
560 +
561 + /**
562 + * Event listener for sort changes.
563 + * Updates the file ordering on the server.
564 + * @param event
565 + * @param ui
566 + */
567 + function sortUpdate(event, ui) {
568 + let newOrder = $scope.files.map(file => {return file.id}).join(':');
569 + if (newOrder === currentOrder) return;
570 +
571 + currentOrder = newOrder;
572 + $http.put(`/files/sort/page/${pageId}`, {files: $scope.files}).then(resp => {
573 + events.emit('success', resp.data.message);
574 + }, checkError('sort'));
575 + }
576 +
577 + /**
578 + * Used by dropzone to get the endpoint to upload to.
579 + * @returns {string}
580 + */
581 + $scope.getUploadUrl = function (file) {
582 + let suffix = (typeof file !== 'undefined') ? `/${file.id}` : '';
583 + return window.baseUrl(`/files/upload${suffix}`);
584 + };
585 +
586 + /**
587 + * Get files for the current page from the server.
588 + */
589 + function getFiles() {
590 + let url = window.baseUrl(`/files/get/page/${pageId}`)
591 + $http.get(url).then(resp => {
592 + $scope.files = resp.data;
593 + currentOrder = resp.data.map(file => {return file.id}).join(':');
594 + }, checkError('get'));
595 + }
596 + getFiles();
597 +
598 + /**
599 + * Runs on file upload, Adds an file to local file list
600 + * and shows a success message to the user.
601 + * @param file
602 + * @param data
603 + */
604 + $scope.uploadSuccess = function (file, data) {
605 + $scope.$apply(() => {
606 + $scope.files.push(data);
607 + });
608 + events.emit('success', 'File uploaded');
609 + };
610 +
611 + /**
612 + * Upload and overwrite an existing file.
613 + * @param file
614 + * @param data
615 + */
616 + $scope.uploadSuccessUpdate = function (file, data) {
617 + $scope.$apply(() => {
618 + let search = filesIndexOf(data);
619 + if (search !== -1) $scope.files[search] = data;
620 +
621 + if ($scope.editFile) {
622 + $scope.editFile = angular.copy(data);
623 + data.link = '';
624 + }
625 + });
626 + events.emit('success', 'File updated');
627 + };
628 +
629 + /**
630 + * Delete a file from the server and, on success, the local listing.
631 + * @param file
632 + */
633 + $scope.deleteFile = function(file) {
634 + if (!file.deleting) {
635 + file.deleting = true;
636 + return;
637 + }
638 + $http.delete(`/files/${file.id}`).then(resp => {
639 + events.emit('success', resp.data.message);
640 + $scope.files.splice($scope.files.indexOf(file), 1);
641 + }, checkError('delete'));
642 + };
643 +
644 + /**
645 + * Attach a link to a page.
646 + * @param fileName
647 + * @param fileLink
648 + */
649 + $scope.attachLinkSubmit = function(file) {
650 + file.uploaded_to = pageId;
651 + $http.post('/files/link', file).then(resp => {
652 + $scope.files.push(resp.data);
653 + events.emit('success', 'Link attached');
654 + $scope.file = getCleanFile();
655 + }, checkError('link'));
656 + };
657 +
658 + /**
659 + * Start the edit mode for a file.
660 + * @param fileId
661 + */
662 + $scope.startEdit = function(file) {
663 + console.log(file);
664 + $scope.editFile = angular.copy(file);
665 + $scope.editFile.link = (file.external) ? file.path : '';
666 + };
667 +
668 + /**
669 + * Cancel edit mode
670 + */
671 + $scope.cancelEdit = function() {
672 + $scope.editFile = false;
673 + };
674 +
675 + /**
676 + * Update the name and link of a file.
677 + * @param file
678 + */
679 + $scope.updateFile = function(file) {
680 + $http.put(`/files/${file.id}`, file).then(resp => {
681 + let search = filesIndexOf(resp.data);
682 + if (search !== -1) $scope.files[search] = resp.data;
683 +
684 + if ($scope.editFile && !file.external) {
685 + $scope.editFile.link = '';
686 + }
687 + $scope.editFile = false;
688 + events.emit('success', 'Attachment details updated');
689 + }, checkError('edit'));
690 + };
691 +
692 + /**
693 + * Get the url of a file.
694 + */
695 + $scope.getFileUrl = function(file) {
696 + return window.baseUrl('/files/' + file.id);
697 + }
698 +
699 + /**
700 + * Search the local files via another file object.
701 + * Used to search via object copies.
702 + * @param file
703 + * @returns int
704 + */
705 + function filesIndexOf(file) {
706 + for (let i = 0; i < $scope.files.length; i++) {
707 + if ($scope.files[i].id == file.id) return i;
708 + }
709 + return -1;
710 + }
711 +
712 + /**
713 + * Check for an error response in a ajax request.
714 + * @param response
715 + */
716 + function checkError(errorGroupName) {
717 + $scope.errors[errorGroupName] = {};
718 + return function(response) {
719 + if (typeof response.data !== 'undefined' && typeof response.data.error !== 'undefined') {
720 + events.emit('error', response.data.error);
721 + }
722 + if (typeof response.data !== 'undefined' && typeof response.data.validation !== 'undefined') {
723 + $scope.errors[errorGroupName] = response.data.validation;
724 + console.log($scope.errors[errorGroupName])
725 + }
726 + }
727 + }
728 +
729 + }]);
730 +
532 }; 731 };
533 732
534 733
......
...@@ -33,6 +33,59 @@ module.exports = function (ngApp, events) { ...@@ -33,6 +33,59 @@ module.exports = function (ngApp, events) {
33 }; 33 };
34 }); 34 });
35 35
36 + /**
37 + * Common tab controls using simple jQuery functions.
38 + */
39 + ngApp.directive('tabContainer', function() {
40 + return {
41 + restrict: 'A',
42 + link: function (scope, element, attrs) {
43 + const $content = element.find('[tab-content]');
44 + const $buttons = element.find('[tab-button]');
45 +
46 + if (attrs.tabContainer) {
47 + let initial = attrs.tabContainer;
48 + $buttons.filter(`[tab-button="${initial}"]`).addClass('selected');
49 + $content.hide().filter(`[tab-content="${initial}"]`).show();
50 + } else {
51 + $content.hide().first().show();
52 + $buttons.first().addClass('selected');
53 + }
54 +
55 + $buttons.click(function() {
56 + let clickedTab = $(this);
57 + $buttons.removeClass('selected');
58 + $content.hide();
59 + let name = clickedTab.addClass('selected').attr('tab-button');
60 + $content.filter(`[tab-content="${name}"]`).show();
61 + });
62 + }
63 + };
64 + });
65 +
66 + /**
67 + * Sub form component to allow inner-form sections to act like thier own forms.
68 + */
69 + ngApp.directive('subForm', function() {
70 + return {
71 + restrict: 'A',
72 + link: function (scope, element, attrs) {
73 + element.on('keypress', e => {
74 + if (e.keyCode === 13) {
75 + submitEvent(e);
76 + }
77 + });
78 +
79 + element.find('button[type="submit"]').click(submitEvent);
80 +
81 + function submitEvent(e) {
82 + e.preventDefault()
83 + if (attrs.subForm) scope.$eval(attrs.subForm);
84 + }
85 + }
86 + };
87 + });
88 +
36 89
37 /** 90 /**
38 * Image Picker 91 * Image Picker
...@@ -116,6 +169,7 @@ module.exports = function (ngApp, events) { ...@@ -116,6 +169,7 @@ module.exports = function (ngApp, events) {
116 uploadedTo: '@' 169 uploadedTo: '@'
117 }, 170 },
118 link: function (scope, element, attrs) { 171 link: function (scope, element, attrs) {
172 + if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder;
119 var dropZone = new DropZone(element[0].querySelector('.dropzone-container'), { 173 var dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
120 url: scope.uploadUrl, 174 url: scope.uploadUrl,
121 init: function () { 175 init: function () {
...@@ -488,8 +542,8 @@ module.exports = function (ngApp, events) { ...@@ -488,8 +542,8 @@ module.exports = function (ngApp, events) {
488 link: function (scope, elem, attrs) { 542 link: function (scope, elem, attrs) {
489 543
490 // Get common elements 544 // Get common elements
491 - const $buttons = elem.find('[tab-button]'); 545 + const $buttons = elem.find('[toolbox-tab-button]');
492 - const $content = elem.find('[tab-content]'); 546 + const $content = elem.find('[toolbox-tab-content]');
493 const $toggle = elem.find('[toolbox-toggle]'); 547 const $toggle = elem.find('[toolbox-toggle]');
494 548
495 // Handle toolbox toggle click 549 // Handle toolbox toggle click
...@@ -501,17 +555,17 @@ module.exports = function (ngApp, events) { ...@@ -501,17 +555,17 @@ module.exports = function (ngApp, events) {
501 function setActive(tabName, openToolbox) { 555 function setActive(tabName, openToolbox) {
502 $buttons.removeClass('active'); 556 $buttons.removeClass('active');
503 $content.hide(); 557 $content.hide();
504 - $buttons.filter(`[tab-button="${tabName}"]`).addClass('active'); 558 + $buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active');
505 - $content.filter(`[tab-content="${tabName}"]`).show(); 559 + $content.filter(`[toolbox-tab-content="${tabName}"]`).show();
506 if (openToolbox) elem.addClass('open'); 560 if (openToolbox) elem.addClass('open');
507 } 561 }
508 562
509 // Set the first tab content active on load 563 // Set the first tab content active on load
510 - setActive($content.first().attr('tab-content'), false); 564 + setActive($content.first().attr('toolbox-tab-content'), false);
511 565
512 // Handle tab button click 566 // Handle tab button click
513 $buttons.click(function (e) { 567 $buttons.click(function (e) {
514 - let name = $(this).attr('tab-button'); 568 + let name = $(this).attr('toolbox-tab-button');
515 setActive(name, true); 569 setActive(name, true);
516 }); 570 });
517 } 571 }
......
...@@ -43,10 +43,6 @@ ...@@ -43,10 +43,6 @@
43 } 43 }
44 } 44 }
45 45
46 -//body.ie .popup-body {
47 -// min-height: 100%;
48 -//}
49 -
50 .corner-button { 46 .corner-button {
51 position: absolute; 47 position: absolute;
52 top: 0; 48 top: 0;
...@@ -82,7 +78,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { ...@@ -82,7 +78,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
82 min-height: 70vh; 78 min-height: 70vh;
83 } 79 }
84 80
85 -#image-manager .dropzone-container { 81 +.dropzone-container {
86 position: relative; 82 position: relative;
87 border: 3px dashed #DDD; 83 border: 3px dashed #DDD;
88 } 84 }
...@@ -456,3 +452,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { ...@@ -456,3 +452,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
456 border-right: 6px solid transparent; 452 border-right: 6px solid transparent;
457 border-bottom: 6px solid $negative; 453 border-bottom: 6px solid $negative;
458 } 454 }
455 +
456 +
457 +[tab-container] .nav-tabs {
458 + text-align: left;
459 + border-bottom: 1px solid #DDD;
460 + margin-bottom: $-m;
461 + .tab-item {
462 + padding: $-s;
463 + color: #666;
464 + &.selected {
465 + border-bottom-width: 3px;
466 + }
467 + }
468 +}
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -150,7 +150,6 @@ ...@@ -150,7 +150,6 @@
150 background-color: #FFF; 150 background-color: #FFF;
151 border: 1px solid #DDD; 151 border: 1px solid #DDD;
152 right: $-xl*2; 152 right: $-xl*2;
153 - z-index: 99;
154 width: 48px; 153 width: 48px;
155 overflow: hidden; 154 overflow: hidden;
156 align-items: stretch; 155 align-items: stretch;
...@@ -201,7 +200,7 @@ ...@@ -201,7 +200,7 @@
201 color: #444; 200 color: #444;
202 background-color: rgba(0, 0, 0, 0.1); 201 background-color: rgba(0, 0, 0, 0.1);
203 } 202 }
204 - div[tab-content] { 203 + div[toolbox-tab-content] {
205 padding-bottom: 45px; 204 padding-bottom: 45px;
206 display: flex; 205 display: flex;
207 flex: 1; 206 flex: 1;
...@@ -209,7 +208,7 @@ ...@@ -209,7 +208,7 @@
209 min-height: 0px; 208 min-height: 0px;
210 overflow-y: scroll; 209 overflow-y: scroll;
211 } 210 }
212 - div[tab-content] .padded { 211 + div[toolbox-tab-content] .padded {
213 flex: 1; 212 flex: 1;
214 padding-top: 0; 213 padding-top: 0;
215 } 214 }
...@@ -228,21 +227,6 @@ ...@@ -228,21 +227,6 @@
228 padding-top: $-s; 227 padding-top: $-s;
229 position: relative; 228 position: relative;
230 } 229 }
231 - button.pos {
232 - position: absolute;
233 - bottom: 0;
234 - display: block;
235 - width: 100%;
236 - padding: $-s;
237 - height: 45px;
238 - border: 0;
239 - margin: 0;
240 - box-shadow: none;
241 - border-radius: 0;
242 - &:hover{
243 - box-shadow: none;
244 - }
245 - }
246 .handle { 230 .handle {
247 user-select: none; 231 user-select: none;
248 cursor: move; 232 cursor: move;
...@@ -256,7 +240,7 @@ ...@@ -256,7 +240,7 @@
256 } 240 }
257 } 241 }
258 242
259 -[tab-content] { 243 +[toolbox-tab-content] {
260 display: none; 244 display: none;
261 } 245 }
262 246
......
...@@ -51,4 +51,14 @@ table.list-table { ...@@ -51,4 +51,14 @@ table.list-table {
51 vertical-align: middle; 51 vertical-align: middle;
52 padding: $-xs; 52 padding: $-xs;
53 } 53 }
54 +}
55 +
56 +table.file-table {
57 + @extend .no-style;
58 + td {
59 + padding: $-xs;
60 + }
61 + .ui-sortable-helper {
62 + display: table;
63 + }
54 } 64 }
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -3,10 +3,13 @@ ...@@ -3,10 +3,13 @@
3 3
4 <div class="tabs primary-background-light"> 4 <div class="tabs primary-background-light">
5 <span toolbox-toggle><i class="zmdi zmdi-caret-left-circle"></i></span> 5 <span toolbox-toggle><i class="zmdi zmdi-caret-left-circle"></i></span>
6 - <span tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span> 6 + <span toolbox-tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span>
7 + @if(userCan('file-create-all'))
8 + <span toolbox-tab-button="files" title="Attachments"><i class="zmdi zmdi-attachment"></i></span>
9 + @endif
7 </div> 10 </div>
8 11
9 - <div tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}"> 12 + <div toolbox-tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
10 <h4>Page Tags</h4> 13 <h4>Page Tags</h4>
11 <div class="padded tags"> 14 <div class="padded tags">
12 <p class="muted small">Add some tags to better categorise your content. <br> You can assign a value to a tag for more in-depth organisation.</p> 15 <p class="muted small">Add some tags to better categorise your content. <br> You can assign a value to a tag for more in-depth organisation.</p>
...@@ -34,4 +37,98 @@ ...@@ -34,4 +37,98 @@
34 </div> 37 </div>
35 </div> 38 </div>
36 39
40 + @if(userCan('file-create-all'))
41 + <div toolbox-tab-content="files" ng-controller="PageAttachmentController" page-id="{{ $page->id or 0 }}">
42 + <h4>Attachments</h4>
43 + <div class="padded files">
44 +
45 + <div id="file-list" ng-show="!editFile">
46 + <p class="muted small">Upload some files or attach some link to display on your page. This are visible in the page sidebar.</p>
47 +
48 + <div tab-container>
49 + <div class="nav-tabs">
50 + <div tab-button="list" class="tab-item">File List</div>
51 + <div tab-button="file" class="tab-item">Upload File</div>
52 + <div tab-button="link" class="tab-item">Attach Link</div>
53 + </div>
54 + <div tab-content="list">
55 + <table class="file-table" style="width: 100%;">
56 + <tbody ui-sortable="sortOptions" ng-model="files" >
57 + <tr ng-repeat="file in files track by $index">
58 + <td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
59 + <td>
60 + <a ng-href="@{{getFileUrl(file)}}" target="_blank" ng-bind="file.name"></a>
61 + <div ng-if="file.deleting">
62 + <span class="neg small">Click delete again to confirm you want to delete this attachment.</span>
63 + <br>
64 + <span class="text-primary small" ng-click="file.deleting=false;">Cancel</span>
65 + </div>
66 + </td>
67 + <td width="10" ng-click="startEdit(file)" class="text-center text-primary" style="padding: 0;"><i class="zmdi zmdi-edit"></i></td>
68 + <td width="5"></td>
69 + <td width="10" ng-click="deleteFile(file)" class="text-center text-neg" style="padding: 0;"><i class="zmdi zmdi-close"></i></td>
70 + </tr>
71 + </tbody>
72 + </table>
73 + <p class="small muted" ng-if="files.length == 0">
74 + No files have been uploaded.
75 + </p>
76 + </div>
77 + <div tab-content="file">
78 + <drop-zone upload-url="@{{getUploadUrl()}}" uploaded-to="@{{uploadedTo}}" event-success="uploadSuccess"></drop-zone>
79 + </div>
80 + <div tab-content="link" sub-form="attachLinkSubmit(file)">
81 + <p class="muted small">You can attach a link if you'd prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.</p>
82 + <div class="form-group">
83 + <label for="attachment-via-link">Link Name</label>
84 + <input type="text" placeholder="Link name" ng-model="file.name">
85 + <p class="small neg" ng-repeat="error in errors.link.name" ng-bind="error"></p>
86 + </div>
87 + <div class="form-group">
88 + <label for="attachment-via-link">Link to file</label>
89 + <input type="text" placeholder="Url of site or file" ng-model="file.link">
90 + <p class="small neg" ng-repeat="error in errors.link.link" ng-bind="error"></p>
91 + </div>
92 + <button type="submit" class="button pos">Attach</button>
93 +
94 + </div>
95 + </div>
96 +
97 + </div>
98 +
99 + <div id="file-edit" ng-if="editFile" sub-form="updateFile(editFile)">
100 + <h5>Edit File</h5>
101 +
102 + <div class="form-group">
103 + <label for="attachment-name-edit">File Name</label>
104 + <input type="text" id="attachment-name-edit" placeholder="File name" ng-model="editFile.name">
105 + <p class="small neg" ng-repeat="error in errors.edit.name" ng-bind="error"></p>
106 + </div>
107 +
108 + <div tab-container="@{{ editFile.external ? 'link' : 'file' }}">
109 + <div class="nav-tabs">
110 + <div tab-button="file" class="tab-item">Upload File</div>
111 + <div tab-button="link" class="tab-item">Set Link</div>
112 + </div>
113 + <div tab-content="file">
114 + <drop-zone upload-url="@{{getUploadUrl(editFile)}}" uploaded-to="@{{uploadedTo}}" placeholder="Drop files or click here to upload and overwrite" event-success="uploadSuccessUpdate"></drop-zone>
115 + <br>
116 + </div>
117 + <div tab-content="link">
118 + <div class="form-group">
119 + <label for="attachment-link-edit">Link to file</label>
120 + <input type="text" id="attachment-link-edit" placeholder="Attachment link" ng-model="editFile.link">
121 + <p class="small neg" ng-repeat="error in errors.edit.link" ng-bind="error"></p>
122 + </div>
123 + </div>
124 + </div>
125 +
126 + <button type="button" class="button" ng-click="cancelEdit()">Back</button>
127 + <button type="submit" class="button pos">Save</button>
128 + </div>
129 +
130 + </div>
131 + </div>
132 + @endif
133 +
37 </div> 134 </div>
...\ No newline at end of file ...\ No newline at end of file
......
1 1
2 <div class="book-tree" ng-non-bindable> 2 <div class="book-tree" ng-non-bindable>
3 3
4 + @if (isset($page) && $page->files->count() > 0)
5 + <h6 class="text-muted">Attachments</h6>
6 + @foreach($page->files as $file)
7 + <div class="attachment">
8 + <a href="{{ $file->getUrl() }}" @if($file->external) target="_blank" @endif><i class="zmdi zmdi-{{ $file->external ? 'open-in-new' : 'file' }}"></i> {{ $file->name }}</a>
9 + </div>
10 + @endforeach
11 + @endif
12 +
4 @if (isset($pageNav) && $pageNav) 13 @if (isset($pageNav) && $pageNav)
5 <h6 class="text-muted">Page Navigation</h6> 14 <h6 class="text-muted">Page Navigation</h6>
6 <div class="sidebar-page-nav menu"> 15 <div class="sidebar-page-nav menu">
...@@ -10,8 +19,6 @@ ...@@ -10,8 +19,6 @@
10 </li> 19 </li>
11 @endforeach 20 @endforeach
12 </div> 21 </div>
13 -
14 -
15 @endif 22 @endif
16 23
17 <h6 class="text-muted">Book Navigation</h6> 24 <h6 class="text-muted">Book Navigation</h6>
......
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
14 .nav-tabs a.selected, .nav-tabs .tab-item.selected { 14 .nav-tabs a.selected, .nav-tabs .tab-item.selected {
15 border-bottom-color: {{ setting('app-color') }}; 15 border-bottom-color: {{ setting('app-color') }};
16 } 16 }
17 - p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus { 17 + .text-primary, p.primary, p .primary, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
18 color: {{ setting('app-color') }}; 18 color: {{ setting('app-color') }};
19 } 19 }
20 </style> 20 </style>
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -106,6 +106,19 @@ ...@@ -106,6 +106,19 @@
106 <label>@include('settings/roles/checkbox', ['permission' => 'image-delete-all']) All</label> 106 <label>@include('settings/roles/checkbox', ['permission' => 'image-delete-all']) All</label>
107 </td> 107 </td>
108 </tr> 108 </tr>
109 + <tr>
110 + <td>Attached <br>Files</td>
111 + <td>@include('settings/roles/checkbox', ['permission' => 'file-create-all'])</td>
112 + <td style="line-height:1.2;"><small class="faded">Controlled by the asset they are uploaded to</small></td>
113 + <td>
114 + <label>@include('settings/roles/checkbox', ['permission' => 'file-update-own']) Own</label>
115 + <label>@include('settings/roles/checkbox', ['permission' => 'file-update-all']) All</label>
116 + </td>
117 + <td>
118 + <label>@include('settings/roles/checkbox', ['permission' => 'file-delete-own']) Own</label>
119 + <label>@include('settings/roles/checkbox', ['permission' => 'file-delete-all']) All</label>
120 + </td>
121 + </tr>
109 </table> 122 </table>
110 </div> 123 </div>
111 </div> 124 </div>
......
...@@ -87,6 +87,16 @@ Route::group(['middleware' => 'auth'], function () { ...@@ -87,6 +87,16 @@ Route::group(['middleware' => 'auth'], function () {
87 Route::delete('/{imageId}', 'ImageController@destroy'); 87 Route::delete('/{imageId}', 'ImageController@destroy');
88 }); 88 });
89 89
90 + // File routes
91 + Route::get('/files/{id}', 'FileController@get');
92 + Route::post('/files/upload', 'FileController@upload');
93 + Route::post('/files/upload/{id}', 'FileController@uploadUpdate');
94 + Route::post('/files/link', 'FileController@attachLink');
95 + Route::put('/files/{id}', 'FileController@update');
96 + Route::get('/files/get/page/{pageId}', 'FileController@listForPage');
97 + Route::put('/files/sort/page/{pageId}', 'FileController@sortForPage');
98 + Route::delete('/files/{id}', 'FileController@delete');
99 +
90 // AJAX routes 100 // AJAX routes
91 Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft'); 101 Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft');
92 Route::get('/ajax/page/{id}', 'PageController@getPageAjax'); 102 Route::get('/ajax/page/{id}', 'PageController@getPageAjax');
......
1 +<?php
2 +
3 +class AttachmentTest extends TestCase
4 +{
5 + /**
6 + * Get a test file that can be uploaded
7 + * @param $fileName
8 + * @return \Illuminate\Http\UploadedFile
9 + */
10 + protected function getTestFile($fileName)
11 + {
12 + return new \Illuminate\Http\UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true);
13 + }
14 +
15 + /**
16 + * Uploads a file with the given name.
17 + * @param $name
18 + * @param int $uploadedTo
19 + * @return string
20 + */
21 + protected function uploadFile($name, $uploadedTo = 0)
22 + {
23 + $file = $this->getTestFile($name);
24 + return $this->call('POST', '/files/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
25 + }
26 +
27 + /**
28 + * Get the expected upload path for a file.
29 + * @param $fileName
30 + * @return string
31 + */
32 + protected function getUploadPath($fileName)
33 + {
34 + return 'uploads/files/' . Date('Y-m-M') . '/' . $fileName;
35 + }
36 +
37 + /**
38 + * Delete all uploaded files.
39 + * To assist with cleanup.
40 + */
41 + protected function deleteUploads()
42 + {
43 + $fileService = $this->app->make(\BookStack\Services\FileService::class);
44 + foreach (\BookStack\File::all() as $file) {
45 + $fileService->deleteFile($file);
46 + }
47 + }
48 +
49 + public function test_file_upload()
50 + {
51 + $page = \BookStack\Page::first();
52 + $this->asAdmin();
53 + $admin = $this->getAdmin();
54 + $fileName = 'upload_test_file.txt';
55 +
56 + $expectedResp = [
57 + 'name' => $fileName,
58 + 'uploaded_to'=> $page->id,
59 + 'extension' => 'txt',
60 + 'order' => 1,
61 + 'created_by' => $admin->id,
62 + 'updated_by' => $admin->id,
63 + 'path' => $this->getUploadPath($fileName)
64 + ];
65 +
66 + $this->uploadFile($fileName, $page->id);
67 + $this->assertResponseOk();
68 + $this->seeJsonContains($expectedResp);
69 + $this->seeInDatabase('files', $expectedResp);
70 +
71 + $this->deleteUploads();
72 + }
73 +
74 + public function test_file_display_and_access()
75 + {
76 + $page = \BookStack\Page::first();
77 + $this->asAdmin();
78 + $admin = $this->getAdmin();
79 + $fileName = 'upload_test_file.txt';
80 +
81 + $this->uploadFile($fileName, $page->id);
82 + $this->assertResponseOk();
83 + $this->visit($page->getUrl())
84 + ->seeLink($fileName)
85 + ->click($fileName)
86 + ->see('Hi, This is a test file for testing the upload process.');
87 +
88 + $this->deleteUploads();
89 + }
90 +
91 + public function test_attaching_link_to_page()
92 + {
93 + $page = \BookStack\Page::first();
94 + $admin = $this->getAdmin();
95 + $this->asAdmin();
96 +
97 + $this->call('POST', 'files/link', [
98 + 'link' => 'https://example.com',
99 + 'name' => 'Example Attachment Link',
100 + 'uploaded_to' => $page->id,
101 + ]);
102 +
103 + $expectedResp = [
104 + 'path' => 'https://example.com',
105 + 'name' => 'Example Attachment Link',
106 + 'uploaded_to' => $page->id,
107 + 'created_by' => $admin->id,
108 + 'updated_by' => $admin->id,
109 + 'external' => true,
110 + 'order' => 1,
111 + 'extension' => ''
112 + ];
113 +
114 + $this->assertResponseOk();
115 + $this->seeJsonContains($expectedResp);
116 + $this->seeInDatabase('files', $expectedResp);
117 +
118 + $this->visit($page->getUrl())->seeLink('Example Attachment Link')
119 + ->click('Example Attachment Link')->seePageIs('https://example.com');
120 +
121 + $this->deleteUploads();
122 + }
123 +
124 + public function test_attachment_updating()
125 + {
126 + $page = \BookStack\Page::first();
127 + $this->asAdmin();
128 +
129 + $this->call('POST', 'files/link', [
130 + 'link' => 'https://example.com',
131 + 'name' => 'Example Attachment Link',
132 + 'uploaded_to' => $page->id,
133 + ]);
134 +
135 + $attachmentId = \BookStack\File::first()->id;
136 +
137 + $this->call('PUT', 'files/' . $attachmentId, [
138 + 'uploaded_to' => $page->id,
139 + 'name' => 'My new attachment name',
140 + 'link' => 'https://test.example.com'
141 + ]);
142 +
143 + $expectedResp = [
144 + 'path' => 'https://test.example.com',
145 + 'name' => 'My new attachment name',
146 + 'uploaded_to' => $page->id
147 + ];
148 +
149 + $this->assertResponseOk();
150 + $this->seeJsonContains($expectedResp);
151 + $this->seeInDatabase('files', $expectedResp);
152 +
153 + $this->deleteUploads();
154 + }
155 +
156 + public function test_file_deletion()
157 + {
158 + $page = \BookStack\Page::first();
159 + $this->asAdmin();
160 + $fileName = 'deletion_test.txt';
161 + $this->uploadFile($fileName, $page->id);
162 +
163 + $filePath = base_path('storage/' . $this->getUploadPath($fileName));
164 +
165 + $this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist');
166 +
167 + $attachmentId = \BookStack\File::first()->id;
168 + $this->call('DELETE', 'files/' . $attachmentId);
169 +
170 + $this->dontSeeInDatabase('files', [
171 + 'name' => $fileName
172 + ]);
173 + $this->assertFalse(file_exists($filePath), 'File at path ' . $filePath . ' was not deleted as expected');
174 +
175 + $this->deleteUploads();
176 + }
177 +
178 + public function test_attachment_deletion_on_page_deletion()
179 + {
180 + $page = \BookStack\Page::first();
181 + $this->asAdmin();
182 + $fileName = 'deletion_test.txt';
183 + $this->uploadFile($fileName, $page->id);
184 +
185 + $filePath = base_path('storage/' . $this->getUploadPath($fileName));
186 +
187 + $this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist');
188 + $this->seeInDatabase('files', [
189 + 'name' => $fileName
190 + ]);
191 +
192 + $this->call('DELETE', $page->getUrl());
193 +
194 + $this->dontSeeInDatabase('files', [
195 + 'name' => $fileName
196 + ]);
197 + $this->assertFalse(file_exists($filePath), 'File at path ' . $filePath . ' was not deleted as expected');
198 +
199 + $this->deleteUploads();
200 + }
201 +}
...@@ -10,7 +10,7 @@ class ImageTest extends TestCase ...@@ -10,7 +10,7 @@ class ImageTest extends TestCase
10 */ 10 */
11 protected function getTestImage($fileName) 11 protected function getTestImage($fileName)
12 { 12 {
13 - return new \Illuminate\Http\UploadedFile(base_path('tests/test-image.jpg'), $fileName, 'image/jpeg', 5238); 13 + return new \Illuminate\Http\UploadedFile(base_path('tests/test-data/test-image.jpg'), $fileName, 'image/jpeg', 5238);
14 } 14 }
15 15
16 /** 16 /**
......
1 +Hi, This is a test file for testing the upload process.
...\ No newline at end of file ...\ No newline at end of file