Dan Brown

Merge branch 'master' into release

Showing 127 changed files with 2221 additions and 559 deletions
......@@ -10,4 +10,6 @@ Homestead.yaml
/public/bower
/storage/images
_ide_helper.php
/storage/debugbar
\ No newline at end of file
/storage/debugbar
.phpstorm.meta.php
yarn.lock
......
<?php namespace BookStack;
class Attachment extends Ownable
{
protected $fillable = ['name', 'order'];
/**
* Get the downloadable file name for this upload.
* @return mixed|string
*/
public function getFileName()
{
if (str_contains($this->name, '.')) return $this->name;
return $this->name . '.' . $this->extension;
}
/**
* Get the page this file was uploaded to.
* @return Page
*/
public function page()
{
return $this->belongsTo(Page::class, 'uploaded_to');
}
/**
* Get the url of this file.
* @return string
*/
public function getUrl()
{
return baseUrl('/attachments/' . $this->id);
}
}
......@@ -13,9 +13,9 @@ class Book extends Entity
public function getUrl($path = false)
{
if ($path !== false) {
return baseUrl('/books/' . $this->slug . '/' . trim($path, '/'));
return baseUrl('/books/' . urlencode($this->slug) . '/' . trim($path, '/'));
}
return baseUrl('/books/' . $this->slug);
return baseUrl('/books/' . urlencode($this->slug));
}
/*
......
......@@ -32,9 +32,9 @@ class Chapter extends Entity
{
$bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
if ($path !== false) {
return baseUrl('/books/' . $bookSlug. '/chapter/' . $this->slug . '/' . trim($path, '/'));
return baseUrl('/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug) . '/' . trim($path, '/'));
}
return baseUrl('/books/' . $bookSlug. '/chapter/' . $this->slug);
return baseUrl('/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug));
}
/**
......
<?php namespace BookStack;
class EmailConfirmation extends Model
{
protected $fillable = ['user_id', 'token'];
/**
* Get the user that this confirmation is attached to.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo(User::class);
}
}
......@@ -162,18 +162,21 @@ class Entity extends Ownable
$exactTerms = [];
$fuzzyTerms = [];
$search = static::newQuery();
foreach ($terms as $key => $term) {
$safeTerm = htmlentities($term, ENT_QUOTES);
$safeTerm = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $safeTerm);
if (preg_match('/&quot;.*?&quot;/', $safeTerm) || is_numeric($safeTerm)) {
$safeTerm = preg_replace('/^"(.*?)"$/', '$1', $term);
$exactTerms[] = '%' . $safeTerm . '%';
$term = htmlentities($term, ENT_QUOTES);
$term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term);
if (preg_match('/&quot;.*?&quot;/', $term) || is_numeric($term)) {
$term = str_replace('&quot;', '', $term);
$exactTerms[] = '%' . $term . '%';
} else {
$safeTerm = '' . $safeTerm . '*';
if (trim($safeTerm) !== '*') $fuzzyTerms[] = $safeTerm;
$term = '' . $term . '*';
if ($term !== '*') $fuzzyTerms[] = $term;
}
}
$isFuzzy = count($exactTerms) === 0 || count($fuzzyTerms) > 0;
$isFuzzy = count($exactTerms) === 0 && count($fuzzyTerms) > 0;
// Perform fulltext search if relevant terms exist.
if ($isFuzzy) {
......@@ -193,6 +196,7 @@ class Entity extends Ownable
}
});
}
$orderBy = $isFuzzy ? 'title_relevance' : 'updated_at';
// Add additional where terms
......
<?php
namespace BookStack\Events;
abstract class Event
{
//
}
<?php namespace BookStack\Exceptions;
class FileUploadException extends PrettyException {}
\ No newline at end of file
......@@ -87,4 +87,20 @@ class Handler extends ExceptionHandler
} while ($e = $e->getPrevious());
return $message;
}
/**
* Convert an authentication exception into an unauthenticated response.
*
* @param \Illuminate\Http\Request $request
* @param \Illuminate\Auth\AuthenticationException $exception
* @return \Illuminate\Http\Response
*/
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->expectsJson()) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}
return redirect()->guest('login');
}
}
......
<?php namespace BookStack\Http\Controllers;
use BookStack\Exceptions\FileUploadException;
use BookStack\Attachment;
use BookStack\Repos\PageRepo;
use BookStack\Services\AttachmentService;
use Illuminate\Http\Request;
class AttachmentController extends Controller
{
protected $attachmentService;
protected $attachment;
protected $pageRepo;
/**
* AttachmentController constructor.
* @param AttachmentService $attachmentService
* @param Attachment $attachment
* @param PageRepo $pageRepo
*/
public function __construct(AttachmentService $attachmentService, Attachment $attachment, PageRepo $pageRepo)
{
$this->attachmentService = $attachmentService;
$this->attachment = $attachment;
$this->pageRepo = $pageRepo;
parent::__construct();
}
/**
* Endpoint at which attachments are uploaded to.
* @param Request $request
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
*/
public function upload(Request $request)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'file' => 'required|file'
]);
$pageId = $request->get('uploaded_to');
$page = $this->pageRepo->getById($pageId, true);
$this->checkPermission('attachment-create-all');
$this->checkOwnablePermission('page-update', $page);
$uploadedFile = $request->file('file');
try {
$attachment = $this->attachmentService->saveNewUpload($uploadedFile, $pageId);
} catch (FileUploadException $e) {
return response($e->getMessage(), 500);
}
return response()->json($attachment);
}
/**
* Update an uploaded attachment.
* @param int $attachmentId
* @param Request $request
* @return mixed
*/
public function uploadUpdate($attachmentId, Request $request)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'file' => 'required|file'
]);
$pageId = $request->get('uploaded_to');
$page = $this->pageRepo->getById($pageId, true);
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('attachment-create', $attachment);
if (intval($pageId) !== intval($attachment->uploaded_to)) {
return $this->jsonError('Page mismatch during attached file update');
}
$uploadedFile = $request->file('file');
try {
$attachment = $this->attachmentService->saveUpdatedUpload($uploadedFile, $attachment);
} catch (FileUploadException $e) {
return response($e->getMessage(), 500);
}
return response()->json($attachment);
}
/**
* Update the details of an existing file.
* @param $attachmentId
* @param Request $request
* @return Attachment|mixed
*/
public function update($attachmentId, Request $request)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'name' => 'required|string|min:1|max:255',
'link' => 'url|min:1|max:255'
]);
$pageId = $request->get('uploaded_to');
$page = $this->pageRepo->getById($pageId, true);
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('attachment-create', $attachment);
if (intval($pageId) !== intval($attachment->uploaded_to)) {
return $this->jsonError('Page mismatch during attachment update');
}
$attachment = $this->attachmentService->updateFile($attachment, $request->all());
return $attachment;
}
/**
* Attach a link to a page.
* @param Request $request
* @return mixed
*/
public function attachLink(Request $request)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'name' => 'required|string|min:1|max:255',
'link' => 'required|url|min:1|max:255'
]);
$pageId = $request->get('uploaded_to');
$page = $this->pageRepo->getById($pageId, true);
$this->checkPermission('attachment-create-all');
$this->checkOwnablePermission('page-update', $page);
$attachmentName = $request->get('name');
$link = $request->get('link');
$attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, $pageId);
return response()->json($attachment);
}
/**
* Get the attachments for a specific page.
* @param $pageId
* @return mixed
*/
public function listForPage($pageId)
{
$page = $this->pageRepo->getById($pageId, true);
$this->checkOwnablePermission('page-view', $page);
return response()->json($page->attachments);
}
/**
* Update the attachment sorting.
* @param $pageId
* @param Request $request
* @return mixed
*/
public function sortForPage($pageId, Request $request)
{
$this->validate($request, [
'files' => 'required|array',
'files.*.id' => 'required|integer',
]);
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-update', $page);
$attachments = $request->get('files');
$this->attachmentService->updateFileOrderWithinPage($attachments, $pageId);
return response()->json(['message' => 'Attachment order updated']);
}
/**
* Get an attachment from storage.
* @param $attachmentId
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Symfony\Component\HttpFoundation\Response
*/
public function get($attachmentId)
{
$attachment = $this->attachment->findOrFail($attachmentId);
$page = $this->pageRepo->getById($attachment->uploaded_to);
$this->checkOwnablePermission('page-view', $page);
if ($attachment->external) {
return redirect($attachment->path);
}
$attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
return response($attachmentContents, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'. $attachment->getFileName() .'"'
]);
}
/**
* Delete a specific attachment in the system.
* @param $attachmentId
* @return mixed
*/
public function delete($attachmentId)
{
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('attachment-delete', $attachment);
$this->attachmentService->deleteFile($attachment);
return response()->json(['message' => 'Attachment deleted']);
}
}
<?php
namespace BookStack\Http\Controllers\Auth;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use Illuminate\Http\Request;
use Password;
class ForgotPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset emails and
| includes a trait which assists in sending these notifications from
| your application to your users. Feel free to explore this trait.
|
*/
use SendsPasswordResetEmails;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
parent::__construct();
}
/**
* Send a reset link to the given user.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse
*/
public function sendResetLinkEmail(Request $request)
{
$this->validate($request, ['email' => 'required|email']);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$response = $this->broker()->sendResetLink(
$request->only('email')
);
if ($response === Password::RESET_LINK_SENT) {
$message = 'A password reset link has been sent to ' . $request->get('email') . '.';
session()->flash('success', $message);
return back()->with('status', trans($response));
}
// If an error was returned by the password broker, we will get this message
// translated so we can notify a user of the problem. We'll redirect back
// to where the users came from so they can attempt this process again.
return back()->withErrors(
['email' => trans($response)]
);
}
}
\ No newline at end of file
<?php
namespace BookStack\Http\Controllers\Auth;
use BookStack\Http\Controllers\Controller;
use BookStack\Repos\UserRepo;
use BookStack\Services\SocialAuthService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
class LoginController extends Controller
{
/*
|--------------------------------------------------------------------------
| Login Controller
|--------------------------------------------------------------------------
|
| This controller handles authenticating users for the application and
| redirecting them to your home screen. The controller uses a trait
| to conveniently provide its functionality to your applications.
|
*/
use AuthenticatesUsers;
/**
* Where to redirect users after login.
*
* @var string
*/
protected $redirectTo = '/';
protected $redirectPath = '/';
protected $redirectAfterLogout = '/login';
protected $socialAuthService;
protected $userRepo;
/**
* Create a new controller instance.
*
* @param SocialAuthService $socialAuthService
* @param UserRepo $userRepo
*/
public function __construct(SocialAuthService $socialAuthService, UserRepo $userRepo)
{
$this->middleware('guest', ['only' => ['getLogin', 'postLogin']]);
$this->socialAuthService = $socialAuthService;
$this->userRepo = $userRepo;
$this->redirectPath = baseUrl('/');
$this->redirectAfterLogout = baseUrl('/login');
parent::__construct();
}
public function username()
{
return config('auth.method') === 'standard' ? 'email' : 'username';
}
/**
* Overrides the action when a user is authenticated.
* If the user authenticated but does not exist in the user table we create them.
* @param Request $request
* @param Authenticatable $user
* @return \Illuminate\Http\RedirectResponse
* @throws AuthException
*/
protected function authenticated(Request $request, Authenticatable $user)
{
// Explicitly log them out for now if they do no exist.
if (!$user->exists) auth()->logout($user);
if (!$user->exists && $user->email === null && !$request->has('email')) {
$request->flash();
session()->flash('request-email', true);
return redirect('/login');
}
if (!$user->exists && $user->email === null && $request->has('email')) {
$user->email = $request->get('email');
}
if (!$user->exists) {
// Check for users with same email already
$alreadyUser = $user->newQuery()->where('email', '=', $user->email)->count() > 0;
if ($alreadyUser) {
throw new AuthException('A user with the email ' . $user->email . ' already exists but with different credentials.');
}
$user->save();
$this->userRepo->attachDefaultRole($user);
auth()->login($user);
}
$path = session()->pull('url.intended', '/');
$path = baseUrl($path, true);
return redirect($path);
}
/**
* Show the application login form.
* @return \Illuminate\Http\Response
*/
public function getLogin()
{
$socialDrivers = $this->socialAuthService->getActiveDrivers();
$authMethod = config('auth.method');
return view('auth/login', ['socialDrivers' => $socialDrivers, 'authMethod' => $authMethod]);
}
/**
* Redirect to the relevant social site.
* @param $socialDriver
* @return \Symfony\Component\HttpFoundation\RedirectResponse
*/
public function getSocialLogin($socialDriver)
{
session()->put('social-callback', 'login');
return $this->socialAuthService->startLogIn($socialDriver);
}
}
\ No newline at end of file
......@@ -4,10 +4,8 @@ namespace BookStack\Http\Controllers\Auth;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Http\Request;
use Password;
class PasswordController extends Controller
class ResetPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
......@@ -25,52 +23,27 @@ class PasswordController extends Controller
protected $redirectTo = '/';
/**
* Create a new password controller instance.
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
}
/**
* Send a reset link to the given user.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function sendResetLinkEmail(Request $request)
{
$this->validate($request, ['email' => 'required|email']);
$broker = $this->getBroker();
$response = Password::broker($broker)->sendResetLink(
$request->only('email'), $this->resetEmailBuilder()
);
switch ($response) {
case Password::RESET_LINK_SENT:
$message = 'A password reset link has been sent to ' . $request->get('email') . '.';
session()->flash('success', $message);
return $this->getSendResetLinkEmailSuccessResponse($response);
case Password::INVALID_USER:
default:
return $this->getSendResetLinkEmailFailureResponse($response);
}
parent::__construct();
}
/**
* Get the response for after a successful password reset.
* Get the response for a successful password reset.
*
* @param string $response
* @return \Symfony\Component\HttpFoundation\Response
* @return \Illuminate\Http\Response
*/
protected function getResetSuccessResponse($response)
protected function sendResetResponse($response)
{
$message = 'Your password has been successfully reset.';
session()->flash('success', $message);
return redirect($this->redirectPath())->with('status', trans($response));
return redirect($this->redirectPath())
->with('status', trans($response));
}
}
}
\ No newline at end of file
......
......@@ -115,9 +115,11 @@ class ChapterController extends Controller
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$this->checkOwnablePermission('chapter-update', $chapter);
if ($chapter->name !== $request->get('name')) {
$chapter->slug = $this->chapterRepo->findSuitableSlug($request->get('name'), $book->id, $chapter->id);
}
$chapter->fill($request->all());
$chapter->slug = $this->chapterRepo->findSuitableSlug($chapter->name, $book->id, $chapter->id);
$chapter->updated_by = auth()->user()->id;
$chapter->updated_by = user()->id;
$chapter->save();
Activity::add($chapter, 'chapter_update', $book->id);
return redirect($chapter->getUrl());
......
......@@ -3,13 +3,11 @@
namespace BookStack\Http\Controllers;
use BookStack\Ownable;
use HttpRequestException;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Http\Exception\HttpResponseException;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use BookStack\User;
abstract class Controller extends BaseController
......@@ -30,17 +28,21 @@ abstract class Controller extends BaseController
*/
public function __construct()
{
// Get a user instance for the current user
$user = auth()->user();
if (!$user) $user = User::getDefault();
$this->middleware(function ($request, $next) {
// Share variables with views
view()->share('signedIn', auth()->check());
view()->share('currentUser', $user);
// Get a user instance for the current user
$user = user();
// Share variables with controllers
$this->currentUser = $user;
$this->signedIn = auth()->check();
// Share variables with controllers
$this->currentUser = $user;
$this->signedIn = auth()->check();
// Share variables with views
view()->share('signedIn', $this->signedIn);
view()->share('currentUser', $user);
return $next($request);
});
}
/**
......@@ -67,8 +69,13 @@ abstract class Controller extends BaseController
*/
protected function showPermissionError()
{
Session::flash('error', trans('errors.permission'));
$response = request()->wantsJson() ? response()->json(['error' => trans('errors.permissionJson')], 403) : redirect('/');
if (request()->wantsJson()) {
$response = response()->json(['error' => trans('errors.permissionJson')], 403);
} else {
$response = redirect('/');
session()->flash('error', trans('errors.permission'));
}
throw new HttpResponseException($response);
}
......@@ -79,7 +86,7 @@ abstract class Controller extends BaseController
*/
protected function checkPermission($permissionName)
{
if (!$this->currentUser || !$this->currentUser->can($permissionName)) {
if (!user() || !user()->can($permissionName)) {
$this->showPermissionError();
}
return true;
......@@ -121,4 +128,22 @@ abstract class Controller extends BaseController
return response()->json(['message' => $messageText], $statusCode);
}
/**
* Create the response for when a request fails validation.
*
* @param \Illuminate\Http\Request $request
* @param array $errors
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function buildFailedValidationResponse(Request $request, array $errors)
{
if ($request->expectsJson()) {
return response()->json(['validation' => $errors], 422);
}
return redirect()->to($this->getRedirectUrl())
->withInput($request->input())
->withErrors($errors, $this->errorBag());
}
}
......
......@@ -17,10 +17,7 @@ class SettingController extends Controller
$this->setPageTitle('Settings');
// Get application version
$version = false;
if (function_exists('exec')) {
$version = exec('git describe --always --tags ');
}
$version = trim(file_get_contents(base_path('version')));
return view('settings/index', ['version' => $version]);
}
......
......@@ -3,6 +3,7 @@
namespace BookStack\Http\Controllers;
use BookStack\Activity;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
......@@ -56,7 +57,7 @@ class UserController extends Controller
{
$this->checkPermission('users-manage');
$authMethod = config('auth.method');
$roles = $this->userRepo->getAssignableRoles();
$roles = $this->userRepo->getAllRoles();
return view('users/create', ['authMethod' => $authMethod, 'roles' => $roles]);
}
......@@ -100,9 +101,14 @@ class UserController extends Controller
// Get avatar from gravatar and save
if (!config('services.disable_services')) {
$avatar = \Images::saveUserGravatar($user);
$user->avatar()->associate($avatar);
$user->save();
try {
$avatar = \Images::saveUserGravatar($user);
$user->avatar()->associate($avatar);
$user->save();
} catch (Exception $e) {
\Log::error('Failed to save user gravatar image');
}
}
return redirect('/settings/users');
......@@ -120,12 +126,13 @@ class UserController extends Controller
return $this->currentUser->id == $id;
});
$authMethod = config('auth.method');
$user = $this->user->findOrFail($id);
$authMethod = ($user->system_name) ? 'system' : config('auth.method');
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
$this->setPageTitle('User Profile');
$roles = $this->userRepo->getAssignableRoles();
$roles = $this->userRepo->getAllRoles();
return view('users/edit', ['user' => $user, 'activeSocialDrivers' => $activeSocialDrivers, 'authMethod' => $authMethod, 'roles' => $roles]);
}
......@@ -180,7 +187,7 @@ class UserController extends Controller
/**
* Show the user delete page.
* @param $id
* @param int $id
* @return \Illuminate\View\View
*/
public function delete($id)
......@@ -213,6 +220,11 @@ class UserController extends Controller
return redirect($user->getEditUrl());
}
if ($user->system_name === 'public') {
session()->flash('error', 'You cannot delete the guest user');
return redirect($user->getEditUrl());
}
$this->userRepo->destroy($user);
session()->flash('success', 'User successfully removed');
......
......@@ -9,15 +9,32 @@ class Kernel extends HttpKernel
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
\Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode::class,
\BookStack\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\BookStack\Http\Middleware\VerifyCsrfToken::class,
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\BookStack\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\BookStack\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
'throttle:60,1',
'bindings',
],
];
/**
......@@ -26,6 +43,7 @@ class Kernel extends HttpKernel
* @var array
*/
protected $routeMiddleware = [
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'auth' => \BookStack\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'guest' => \BookStack\Http\Middleware\RedirectIfAuthenticated::class,
......
......@@ -33,7 +33,7 @@ class Authenticate
public function handle($request, Closure $next)
{
if ($this->auth->check() && setting('registration-confirmation') && !$this->auth->user()->email_confirmed) {
return redirect()->guest(baseUrl('/register/confirm/awaiting'));
return redirect(baseUrl('/register/confirm/awaiting'));
}
if ($this->auth->guest() && !setting('app-public')) {
......
......@@ -34,7 +34,8 @@ class RedirectIfAuthenticated
*/
public function handle($request, Closure $next)
{
if ($this->auth->check()) {
$requireConfirmation = setting('registration-confirmation');
if ($this->auth->check() && (!$requireConfirmation || ($requireConfirmation && $this->auth->user()->email_confirmed))) {
return redirect('/');
}
......
<?php
namespace BookStack\Jobs;
use Illuminate\Bus\Queueable;
abstract class Job
{
/*
|--------------------------------------------------------------------------
| Queueable Jobs
|--------------------------------------------------------------------------
|
| This job base class provides a central location to place any logic that
| is shared across all of your jobs. The trait included with the class
| provides access to the "queueOn" and "delay" queue helper methods.
|
*/
use Queueable;
}
<?php
namespace BookStack\Notifications;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class ConfirmEmail extends Notification
{
public $token;
/**
* Create a new notification instance.
* @param string $token
*/
public function __construct($token)
{
$this->token = $token;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
$appName = ['appName' => setting('app-name')];
return (new MailMessage)
->subject(trans('auth.email_confirm_subject', $appName))
->greeting(trans('auth.email_confirm_greeting', $appName))
->line(trans('auth.email_confirm_text'))
->action(trans('auth.email_confirm_action'), baseUrl('/register/confirm/' . $this->token));
}
}
<?php
namespace BookStack\Notifications;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class ResetPassword extends Notification
{
/**
* The password reset token.
*
* @var string
*/
public $token;
/**
* Create a notification instance.
*
* @param string $token
*/
public function __construct($token)
{
$this->token = $token;
}
/**
* Get the notification's channels.
*
* @param mixed $notifiable
* @return array|string
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Build the mail representation of the notification.
*
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail()
{
return (new MailMessage)
->line('You are receiving this email because we received a password reset request for your account.')
->action('Reset Password', baseUrl('password/reset/' . $this->token))
->line('If you did not request a password reset, no further action is required.');
}
}
......@@ -55,6 +55,15 @@ class Page extends Entity
}
/**
* Get the attachments assigned to this page.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function attachments()
{
return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
}
/**
* Get the url for this page.
* @param string|bool $path
* @return string
......@@ -63,13 +72,13 @@ class Page extends Entity
{
$bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
$midText = $this->draft ? '/draft/' : '/page/';
$idComponent = $this->draft ? $this->id : $this->slug;
$idComponent = $this->draft ? $this->id : urlencode($this->slug);
if ($path !== false) {
return baseUrl('/books/' . $bookSlug . $midText . $idComponent . '/' . trim($path, '/'));
return baseUrl('/books/' . urlencode($bookSlug) . $midText . $idComponent . '/' . trim($path, '/'));
}
return baseUrl('/books/' . $bookSlug . $midText . $idComponent);
return baseUrl('/books/' . urlencode($bookSlug) . $midText . $idComponent);
}
/**
......
......@@ -25,11 +25,26 @@ class PageRevision extends Model
/**
* Get the url for this revision.
* @param null|string $path
* @return string
*/
public function getUrl()
public function getUrl($path = null)
{
return $this->page->getUrl() . '/revisions/' . $this->id;
$url = $this->page->getUrl() . '/revisions/' . $this->id;
if ($path) return $url . '/' . trim($path, '/');
return $url;
}
/**
* Get the previous revision for the same page if existing
* @return \BookStack\PageRevision|null
*/
public function getPrevious()
{
if ($id = static::where('page_id', '=', $this->page_id)->where('id', '<', $this->id)->max('id')) {
return static::find($id);
}
return null;
}
}
......
<?php
namespace BookStack\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Broadcast;
class BroadcastServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
// Broadcast::routes();
//
// /*
// * Authenticate the user's personal channel...
// */
// Broadcast::channel('BookStack.User.*', function ($user, $userId) {
// return (int) $user->id === (int) $userId;
// });
}
}
......@@ -21,13 +21,10 @@ class EventServiceProvider extends ServiceProvider
/**
* Register any other events for your application.
*
* @param \Illuminate\Contracts\Events\Dispatcher $events
* @return void
*/
public function boot(DispatcherContract $events)
public function boot()
{
parent::boot($events);
//
parent::boot();
}
}
......
<?php namespace BookStack\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Pagination\PaginationServiceProvider as IlluminatePaginationServiceProvider;
use Illuminate\Pagination\Paginator;
class PaginationServiceProvider extends ServiceProvider
class PaginationServiceProvider extends IlluminatePaginationServiceProvider
{
/**
* Register the service provider.
*
......@@ -13,6 +14,10 @@ class PaginationServiceProvider extends ServiceProvider
*/
public function register()
{
Paginator::viewFactoryResolver(function () {
return $this->app['view'];
});
Paginator::currentPathResolver(function () {
return baseUrl($this->app['request']->path());
});
......
......@@ -4,6 +4,7 @@ namespace BookStack\Providers;
use Illuminate\Routing\Router;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Route;
class RouteServiceProvider extends ServiceProvider
{
......@@ -19,26 +20,54 @@ class RouteServiceProvider extends ServiceProvider
/**
* Define your route model bindings, pattern filters, etc.
*
* @param \Illuminate\Routing\Router $router
* @return void
*/
public function boot(Router $router)
public function boot()
{
//
parent::boot($router);
parent::boot();
}
/**
* Define the routes for the application.
*
* @param \Illuminate\Routing\Router $router
* @return void
*/
public function map(Router $router)
public function map()
{
$this->mapWebRoutes();
// $this->mapApiRoutes();
}
/**
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*
* @return void
*/
protected function mapWebRoutes()
{
Route::group([
'middleware' => 'web',
'namespace' => $this->namespace,
], function ($router) {
require base_path('routes/web.php');
});
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*
* @return void
*/
protected function mapApiRoutes()
{
$router->group(['namespace' => $this->namespace], function ($router) {
require app_path('Http/routes.php');
Route::group([
'middleware' => 'api',
'namespace' => $this->namespace,
'prefix' => 'api',
], function ($router) {
require base_path('routes/api.php');
});
}
}
......
......@@ -132,8 +132,8 @@ class BookRepo extends EntityRepo
{
$book = $this->book->newInstance($input);
$book->slug = $this->findSuitableSlug($book->name);
$book->created_by = auth()->user()->id;
$book->updated_by = auth()->user()->id;
$book->created_by = user()->id;
$book->updated_by = user()->id;
$book->save();
$this->permissionService->buildJointPermissionsForEntity($book);
return $book;
......@@ -147,9 +147,11 @@ class BookRepo extends EntityRepo
*/
public function updateFromInput(Book $book, $input)
{
if ($book->name !== $input['name']) {
$book->slug = $this->findSuitableSlug($input['name'], $book->id);
}
$book->fill($input);
$book->slug = $this->findSuitableSlug($book->name, $book->id);
$book->updated_by = auth()->user()->id;
$book->updated_by = user()->id;
$book->save();
$this->permissionService->buildJointPermissionsForEntity($book);
return $book;
......@@ -208,8 +210,7 @@ class BookRepo extends EntityRepo
*/
public function findSuitableSlug($name, $currentId = false)
{
$slug = Str::slug($name);
if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
$slug = $this->nameToSlug($name);
while ($this->doesSlugExist($slug, $currentId)) {
$slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
}
......
......@@ -98,8 +98,8 @@ class ChapterRepo extends EntityRepo
{
$chapter = $this->chapter->newInstance($input);
$chapter->slug = $this->findSuitableSlug($chapter->name, $book->id);
$chapter->created_by = auth()->user()->id;
$chapter->updated_by = auth()->user()->id;
$chapter->created_by = user()->id;
$chapter->updated_by = user()->id;
$chapter = $book->chapters()->save($chapter);
$this->permissionService->buildJointPermissionsForEntity($chapter);
return $chapter;
......@@ -150,8 +150,7 @@ class ChapterRepo extends EntityRepo
*/
public function findSuitableSlug($name, $bookId, $currentId = false)
{
$slug = Str::slug($name);
if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
$slug = $this->nameToSlug($name);
while ($this->doesSlugExist($slug, $bookId, $currentId)) {
$slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
}
......
......@@ -132,9 +132,8 @@ class EntityRepo
*/
public function getUserDraftPages($count = 20, $page = 0)
{
$user = auth()->user();
return $this->page->where('draft', '=', true)
->where('created_by', '=', $user->id)
->where('created_by', '=', user()->id)
->orderBy('updated_at', 'desc')
->skip($count * $page)->take($count)->get();
}
......@@ -270,6 +269,19 @@ class EntityRepo
$this->permissionService->buildJointPermissionsForEntities($collection);
}
/**
* Format a name as a url slug.
* @param $name
* @return string
*/
protected function nameToSlug($name)
{
$slug = str_replace(' ', '-', strtolower($name));
$slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', $slug);
if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
return $slug;
}
}
......
......@@ -5,6 +5,7 @@ use BookStack\Image;
use BookStack\Page;
use BookStack\Services\ImageService;
use BookStack\Services\PermissionService;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Setting;
use Symfony\Component\HttpFoundation\File\UploadedFile;
......@@ -191,7 +192,12 @@ class ImageRepo
*/
public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
{
return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
try {
return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
} catch (FileNotFoundException $exception) {
$image->delete();
return [];
}
}
......
......@@ -5,8 +5,10 @@ use BookStack\Book;
use BookStack\Chapter;
use BookStack\Entity;
use BookStack\Exceptions\NotFoundException;
use BookStack\Services\AttachmentService;
use Carbon\Carbon;
use DOMDocument;
use DOMXPath;
use Illuminate\Support\Str;
use BookStack\Page;
use BookStack\PageRevision;
......@@ -47,7 +49,7 @@ class PageRepo extends EntityRepo
* Get a page via a specific ID.
* @param $id
* @param bool $allowDrafts
* @return mixed
* @return Page
*/
public function getById($id, $allowDrafts = false)
{
......@@ -58,7 +60,7 @@ class PageRepo extends EntityRepo
* Get a page identified by the given slug.
* @param $slug
* @param $bookId
* @return mixed
* @return Page
* @throws NotFoundException
*/
public function getBySlug($slug, $bookId)
......@@ -111,31 +113,6 @@ class PageRepo extends EntityRepo
}
/**
* Save a new page into the system.
* Input validation must be done beforehand.
* @param array $input
* @param Book $book
* @param int $chapterId
* @return Page
*/
public function saveNew(array $input, Book $book, $chapterId = null)
{
$page = $this->newFromInput($input);
$page->slug = $this->findSuitableSlug($page->name, $book->id);
if ($chapterId) $page->chapter_id = $chapterId;
$page->html = $this->formatHtml($input['html']);
$page->text = strip_tags($page->html);
$page->created_by = auth()->user()->id;
$page->updated_by = auth()->user()->id;
$book->pages()->save($page);
return $page;
}
/**
* Publish a draft page to make it a normal page.
* Sets the slug and updates the content.
* @param Page $draftPage
......@@ -172,8 +149,8 @@ class PageRepo extends EntityRepo
{
$page = $this->page->newInstance();
$page->name = 'New Page';
$page->created_by = auth()->user()->id;
$page->updated_by = auth()->user()->id;
$page->created_by = user()->id;
$page->updated_by = user()->id;
$page->draft = true;
if ($chapter) $page->chapter_id = $chapter->id;
......@@ -184,6 +161,35 @@ class PageRepo extends EntityRepo
}
/**
* Parse te headers on the page to get a navigation menu
* @param Page $page
* @return array
*/
public function getPageNav(Page $page)
{
if ($page->html == '') return null;
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($page->html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
if (is_null($headers)) return null;
$tree = [];
foreach ($headers as $header) {
$text = $header->nodeValue;
$tree[] = [
'nodeName' => strtolower($header->nodeName),
'level' => intval(str_replace('h', '', $header->nodeName)),
'link' => '#' . $header->getAttribute('id'),
'text' => strlen($text) > 30 ? substr($text, 0, 27) . '...' : $text
];
}
return $tree;
}
/**
* Formats a page's html to be tagged correctly
* within the system.
* @param string $htmlText
......@@ -325,7 +331,7 @@ class PageRepo extends EntityRepo
}
// Update with new details
$userId = auth()->user()->id;
$userId = user()->id;
$page->fill($input);
$page->html = $this->formatHtml($input['html']);
$page->text = strip_tags($page->html);
......@@ -358,7 +364,7 @@ class PageRepo extends EntityRepo
$page->fill($revision->toArray());
$page->slug = $this->findSuitableSlug($page->name, $book->id, $page->id);
$page->text = strip_tags($page->html);
$page->updated_by = auth()->user()->id;
$page->updated_by = user()->id;
$page->save();
return $page;
}
......@@ -371,21 +377,23 @@ class PageRepo extends EntityRepo
*/
public function saveRevision(Page $page, $summary = null)
{
$revision = $this->pageRevision->fill($page->toArray());
$revision = $this->pageRevision->newInstance($page->toArray());
if (setting('app-editor') !== 'markdown') $revision->markdown = '';
$revision->page_id = $page->id;
$revision->slug = $page->slug;
$revision->book_slug = $page->book->slug;
$revision->created_by = auth()->user()->id;
$revision->created_by = user()->id;
$revision->created_at = $page->updated_at;
$revision->type = 'version';
$revision->summary = $summary;
$revision->save();
// Clear old revisions
if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) {
$this->pageRevision->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc')->skip(50)->take(5)->delete();
}
return $revision;
}
......@@ -397,7 +405,7 @@ class PageRepo extends EntityRepo
*/
public function saveUpdateDraft(Page $page, $data = [])
{
$userId = auth()->user()->id;
$userId = user()->id;
$drafts = $this->userUpdateDraftsQuery($page, $userId)->get();
if ($drafts->count() > 0) {
......@@ -528,7 +536,7 @@ class PageRepo extends EntityRepo
$query = $this->pageRevision->where('type', '=', 'update_draft')
->where('page_id', '=', $page->id)
->where('updated_at', '>', $page->updated_at)
->where('created_by', '!=', auth()->user()->id)
->where('created_by', '!=', user()->id)
->with('createdBy');
if ($minRange !== null) {
......@@ -541,7 +549,7 @@ class PageRepo extends EntityRepo
/**
* Gets a single revision via it's id.
* @param $id
* @return mixed
* @return PageRevision
*/
public function getRevisionById($id)
{
......@@ -606,8 +614,7 @@ class PageRepo extends EntityRepo
*/
public function findSuitableSlug($name, $bookId, $currentId = false)
{
$slug = Str::slug($name);
if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
$slug = $this->nameToSlug($name);
while ($this->doesSlugExist($slug, $bookId, $currentId)) {
$slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
}
......@@ -626,12 +633,20 @@ class PageRepo extends EntityRepo
$page->revisions()->delete();
$page->permissions()->delete();
$this->permissionService->deleteJointPermissionsForEntity($page);
// Delete AttachedFiles
$attachmentService = app(AttachmentService::class);
foreach ($page->attachments as $attachment) {
$attachmentService->deleteFile($attachment);
}
$page->delete();
}
/**
* Get the latest pages added to the system.
* @param $count
* @return mixed
*/
public function getRecentlyCreatedPaginated($count = 20)
{
......@@ -641,6 +656,7 @@ class PageRepo extends EntityRepo
/**
* Get the latest pages added to the system.
* @param $count
* @return mixed
*/
public function getRecentlyUpdatedPaginated($count = 20)
{
......
......@@ -35,7 +35,7 @@ class PermissionsRepo
*/
public function getAllRoles()
{
return $this->role->where('hidden', '=', false)->get();
return $this->role->all();
}
/**
......@@ -45,7 +45,7 @@ class PermissionsRepo
*/
public function getAllRolesExcept(Role $role)
{
return $this->role->where('id', '!=', $role->id)->where('hidden', '=', false)->get();
return $this->role->where('id', '!=', $role->id)->get();
}
/**
......@@ -90,8 +90,6 @@ class PermissionsRepo
{
$role = $this->role->findOrFail($roleId);
if ($role->hidden) throw new PermissionsException("Cannot update a hidden role");
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$this->assignRolePermissions($role, $permissions);
......
......@@ -2,6 +2,7 @@
use BookStack\Role;
use BookStack\User;
use Exception;
use Setting;
class UserRepo
......@@ -84,9 +85,14 @@ class UserRepo
// Get avatar from gravatar and save
if (!config('services.disable_services')) {
$avatar = \Images::saveUserGravatar($user);
$user->avatar()->associate($avatar);
$user->save();
try {
$avatar = \Images::saveUserGravatar($user);
$user->avatar()->associate($avatar);
$user->save();
} catch (Exception $e) {
$user->save();
\Log::error('Failed to save user gravatar image');
}
}
return $user;
......@@ -193,9 +199,9 @@ class UserRepo
* Get the roles in the system that are assignable to a user.
* @return mixed
*/
public function getAssignableRoles()
public function getAllRoles()
{
return $this->role->visible();
return $this->role->all();
}
/**
......@@ -205,7 +211,7 @@ class UserRepo
*/
public function getRestrictableRoles()
{
return $this->role->where('hidden', '=', false)->where('system_name', '=', '')->get();
return $this->role->where('system_name', '!=', 'admin')->get();
}
}
\ No newline at end of file
......
......@@ -66,7 +66,7 @@ class Role extends Model
/**
* Get the role object for the specified role.
* @param $roleName
* @return mixed
* @return Role
*/
public static function getRole($roleName)
{
......@@ -76,7 +76,7 @@ class Role extends Model
/**
* Get the role object for the specified system role.
* @param $roleName
* @return mixed
* @return Role
*/
public static function getSystemRole($roleName)
{
......
......@@ -19,7 +19,7 @@ class ActivityService
{
$this->activity = $activity;
$this->permissionService = $permissionService;
$this->user = auth()->user();
$this->user = user();
}
/**
......
<?php namespace BookStack\Services;
use BookStack\Exceptions\FileUploadException;
use BookStack\Attachment;
use Exception;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class AttachmentService extends UploadService
{
/**
* Get an attachment from storage.
* @param Attachment $attachment
* @return string
*/
public function getAttachmentFromStorage(Attachment $attachment)
{
$attachmentPath = $this->getStorageBasePath() . $attachment->path;
return $this->getStorage()->get($attachmentPath);
}
/**
* Store a new attachment upon user upload.
* @param UploadedFile $uploadedFile
* @param int $page_id
* @return Attachment
* @throws FileUploadException
*/
public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
{
$attachmentName = $uploadedFile->getClientOriginalName();
$attachmentPath = $this->putFileInStorage($attachmentName, $uploadedFile);
$largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
$attachment = Attachment::forceCreate([
'name' => $attachmentName,
'path' => $attachmentPath,
'extension' => $uploadedFile->getClientOriginalExtension(),
'uploaded_to' => $page_id,
'created_by' => user()->id,
'updated_by' => user()->id,
'order' => $largestExistingOrder + 1
]);
return $attachment;
}
/**
* Store a upload, saving to a file and deleting any existing uploads
* attached to that file.
* @param UploadedFile $uploadedFile
* @param Attachment $attachment
* @return Attachment
* @throws FileUploadException
*/
public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment)
{
if (!$attachment->external) {
$this->deleteFileInStorage($attachment);
}
$attachmentName = $uploadedFile->getClientOriginalName();
$attachmentPath = $this->putFileInStorage($attachmentName, $uploadedFile);
$attachment->name = $attachmentName;
$attachment->path = $attachmentPath;
$attachment->external = false;
$attachment->extension = $uploadedFile->getClientOriginalExtension();
$attachment->save();
return $attachment;
}
/**
* Save a new File attachment from a given link and name.
* @param string $name
* @param string $link
* @param int $page_id
* @return Attachment
*/
public function saveNewFromLink($name, $link, $page_id)
{
$largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
return Attachment::forceCreate([
'name' => $name,
'path' => $link,
'external' => true,
'extension' => '',
'uploaded_to' => $page_id,
'created_by' => user()->id,
'updated_by' => user()->id,
'order' => $largestExistingOrder + 1
]);
}
/**
* Get the file storage base path, amended for storage type.
* This allows us to keep a generic path in the database.
* @return string
*/
private function getStorageBasePath()
{
return $this->isLocal() ? 'storage/' : '';
}
/**
* Updates the file ordering for a listing of attached files.
* @param array $attachmentList
* @param $pageId
*/
public function updateFileOrderWithinPage($attachmentList, $pageId)
{
foreach ($attachmentList as $index => $attachment) {
Attachment::where('uploaded_to', '=', $pageId)->where('id', '=', $attachment['id'])->update(['order' => $index]);
}
}
/**
* Update the details of a file.
* @param Attachment $attachment
* @param $requestData
* @return Attachment
*/
public function updateFile(Attachment $attachment, $requestData)
{
$attachment->name = $requestData['name'];
if (isset($requestData['link']) && trim($requestData['link']) !== '') {
$attachment->path = $requestData['link'];
if (!$attachment->external) {
$this->deleteFileInStorage($attachment);
$attachment->external = true;
}
}
$attachment->save();
return $attachment;
}
/**
* Delete a File from the database and storage.
* @param Attachment $attachment
*/
public function deleteFile(Attachment $attachment)
{
if ($attachment->external) {
$attachment->delete();
return;
}
$this->deleteFileInStorage($attachment);
$attachment->delete();
}
/**
* Delete a file from the filesystem it sits on.
* Cleans any empty leftover folders.
* @param Attachment $attachment
*/
protected function deleteFileInStorage(Attachment $attachment)
{
$storedFilePath = $this->getStorageBasePath() . $attachment->path;
$storage = $this->getStorage();
$dirPath = dirname($storedFilePath);
$storage->delete($storedFilePath);
if (count($storage->allFiles($dirPath)) === 0) {
$storage->deleteDirectory($dirPath);
}
}
/**
* Store a file in storage with the given filename
* @param $attachmentName
* @param UploadedFile $uploadedFile
* @return string
* @throws FileUploadException
*/
protected function putFileInStorage($attachmentName, UploadedFile $uploadedFile)
{
$attachmentData = file_get_contents($uploadedFile->getRealPath());
$storage = $this->getStorage();
$attachmentBasePath = 'uploads/files/' . Date('Y-m-M') . '/';
$storageBasePath = $this->getStorageBasePath() . $attachmentBasePath;
$uploadFileName = $attachmentName;
while ($storage->exists($storageBasePath . $uploadFileName)) {
$uploadFileName = str_random(3) . $uploadFileName;
}
$attachmentPath = $attachmentBasePath . $uploadFileName;
$attachmentStoragePath = $this->getStorageBasePath() . $attachmentPath;
try {
$storage->put($attachmentStoragePath, $attachmentData);
} catch (Exception $e) {
throw new FileUploadException('File path ' . $attachmentStoragePath . ' could not be uploaded to. Ensure it is writable to the server.');
}
return $attachmentPath;
}
}
\ No newline at end of file
<?php namespace BookStack\Services;
use BookStack\Notifications\ConfirmEmail;
use BookStack\Repos\UserRepo;
use Carbon\Carbon;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Mail\Message;
use BookStack\EmailConfirmation;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Repos\UserRepo;
use BookStack\Setting;
use BookStack\User;
use Illuminate\Database\Connection as Database;
class EmailConfirmationService
{
protected $mailer;
protected $emailConfirmation;
protected $db;
protected $users;
/**
* EmailConfirmationService constructor.
* @param Mailer $mailer
* @param EmailConfirmation $emailConfirmation
* @param Database $db
* @param UserRepo $users
*/
public function __construct(Mailer $mailer, EmailConfirmation $emailConfirmation)
public function __construct(Database $db, UserRepo $users)
{
$this->mailer = $mailer;
$this->emailConfirmation = $emailConfirmation;
$this->db = $db;
$this->users = $users;
}
/**
......@@ -38,16 +35,28 @@ class EmailConfirmationService
if ($user->email_confirmed) {
throw new ConfirmationEmailException('Email has already been confirmed, Try logging in.', '/login');
}
$this->deleteConfirmationsByUser($user);
$token = $this->createEmailConfirmation($user);
$user->notify(new ConfirmEmail($token));
}
/**
* Creates a new email confirmation in the database and returns the token.
* @param User $user
* @return string
*/
public function createEmailConfirmation(User $user)
{
$token = $this->getToken();
$this->emailConfirmation->create([
$this->db->table('email_confirmations')->insert([
'user_id' => $user->id,
'token' => $token,
'token' => $token,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now()
]);
$this->mailer->send('emails/email-confirmation', ['token' => $token], function (Message $message) use ($user) {
$appName = setting('app-name', 'BookStack');
$message->to($user->email, $user->name)->subject('Confirm your email on ' . $appName . '.');
});
return $token;
}
/**
......@@ -59,22 +68,24 @@ class EmailConfirmationService
*/
public function getEmailConfirmationFromToken($token)
{
$emailConfirmation = $this->emailConfirmation->where('token', '=', $token)->first();
// If not found
$emailConfirmation = $this->db->table('email_confirmations')->where('token', '=', $token)->first();
// If not found show error
if ($emailConfirmation === null) {
throw new UserRegistrationException('This confirmation token is not valid or has already been used, Please try registering again.', '/register');
}
// If more than a day old
if (Carbon::now()->subDay()->gt($emailConfirmation->created_at)) {
$this->sendConfirmation($emailConfirmation->user);
if (Carbon::now()->subDay()->gt(new Carbon($emailConfirmation->created_at))) {
$user = $this->users->getById($emailConfirmation->user_id);
$this->sendConfirmation($user);
throw new UserRegistrationException('The confirmation token has expired, A new confirmation email has been sent.', '/register/confirm');
}
$emailConfirmation->user = $this->users->getById($emailConfirmation->user_id);
return $emailConfirmation;
}
/**
* Delete all email confirmations that belong to a user.
* @param User $user
......@@ -82,7 +93,7 @@ class EmailConfirmationService
*/
public function deleteConfirmationsByUser(User $user)
{
return $this->emailConfirmation->where('user_id', '=', $user->id)->delete();
return $this->db->table('email_confirmations')->where('user_id', '=', $user->id)->delete();
}
/**
......@@ -92,7 +103,7 @@ class EmailConfirmationService
protected function getToken()
{
$token = str_random(24);
while ($this->emailConfirmation->where('token', '=', $token)->exists()) {
while ($this->db->table('email_confirmations')->where('token', '=', $token)->exists()) {
$token = str_random(25);
}
return $token;
......
......@@ -9,20 +9,13 @@ use Intervention\Image\ImageManager;
use Illuminate\Contracts\Filesystem\Factory as FileSystem;
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
use Illuminate\Contracts\Cache\Repository as Cache;
use Setting;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImageService
class ImageService extends UploadService
{
protected $imageTool;
protected $fileSystem;
protected $cache;
/**
* @var FileSystemInstance
*/
protected $storageInstance;
protected $storageUrl;
/**
......@@ -34,8 +27,8 @@ class ImageService
public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
{
$this->imageTool = $imageTool;
$this->fileSystem = $fileSystem;
$this->cache = $cache;
parent::__construct($fileSystem);
}
/**
......@@ -88,6 +81,9 @@ class ImageService
if ($secureUploads) $imageName = str_random(16) . '-' . $imageName;
$imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/';
if ($this->isLocal()) $imagePath = '/public' . $imagePath;
while ($storage->exists($imagePath . $imageName)) {
$imageName = str_random(3) . $imageName;
}
......@@ -100,6 +96,8 @@ class ImageService
throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.');
}
if ($this->isLocal()) $fullPath = str_replace_first('/public', '', $fullPath);
$imageDetails = [
'name' => $imageName,
'path' => $fullPath,
......@@ -108,8 +106,8 @@ class ImageService
'uploaded_to' => $uploadedTo
];
if (auth()->user() && auth()->user()->id !== 0) {
$userId = auth()->user()->id;
if (user()->id !== 0) {
$userId = user()->id;
$imageDetails['created_by'] = $userId;
$imageDetails['updated_by'] = $userId;
}
......@@ -120,6 +118,16 @@ class ImageService
}
/**
* Get the storage path, Dependant of storage type.
* @param Image $image
* @return mixed|string
*/
protected function getPath(Image $image)
{
return ($this->isLocal()) ? ('public/' . $image->path) : $image->path;
}
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
......@@ -135,7 +143,8 @@ class ImageService
public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
{
$thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
$thumbFilePath = dirname($image->path) . $thumbDirName . basename($image->path);
$imagePath = $this->getPath($image);
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) {
return $this->getPublicUrl($thumbFilePath);
......@@ -148,7 +157,7 @@ class ImageService
}
try {
$thumb = $this->imageTool->make($storage->get($image->path));
$thumb = $this->imageTool->make($storage->get($imagePath));
} catch (Exception $e) {
if ($e instanceof \ErrorException || $e instanceof NotSupportedException) {
throw new ImageUploadException('The server cannot create thumbnails. Please check you have the GD PHP extension installed.');
......@@ -183,8 +192,8 @@ class ImageService
{
$storage = $this->getStorage();
$imageFolder = dirname($image->path);
$imageFileName = basename($image->path);
$imageFolder = dirname($this->getPath($image));
$imageFileName = basename($this->getPath($image));
$allImages = collect($storage->allFiles($imageFolder));
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
......@@ -213,7 +222,7 @@ class ImageService
public function saveUserGravatar(User $user, $size = 500)
{
$emailHash = md5(strtolower(trim($user->email)));
$url = 'http://www.gravatar.com/avatar/' . $emailHash . '?s=' . $size . '&d=identicon';
$url = 'https://www.gravatar.com/avatar/' . $emailHash . '?s=' . $size . '&d=identicon';
$imageName = str_replace(' ', '-', $user->name . '-gravatar.png');
$image = $this->saveNewFromUrl($url, 'user', $imageName);
$image->created_by = $user->id;
......@@ -223,34 +232,8 @@ class ImageService
}
/**
* Get the storage that will be used for storing images.
* @return FileSystemInstance
*/
private function getStorage()
{
if ($this->storageInstance !== null) return $this->storageInstance;
$storageType = config('filesystems.default');
$this->storageInstance = $this->fileSystem->disk($storageType);
return $this->storageInstance;
}
/**
* Check whether or not a folder is empty.
* @param $path
* @return int
*/
private function isFolderEmpty($path)
{
$files = $this->getStorage()->files($path);
$folders = $this->getStorage()->directories($path);
return count($files) === 0 && count($folders) === 0;
}
/**
* Gets a public facing url for an image by checking relevant environment variables.
* @param $filePath
* @param string $filePath
* @return string
*/
private function getPublicUrl($filePath)
......@@ -273,6 +256,8 @@ class ImageService
$this->storageUrl = $storageUrl;
}
if ($this->isLocal()) $filePath = str_replace_first('public/', '', $filePath);
return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath;
}
......
......@@ -9,14 +9,15 @@ use BookStack\Page;
use BookStack\Role;
use BookStack\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class PermissionService
{
protected $userRoles;
protected $isAdmin;
protected $currentAction;
protected $currentUser;
protected $isAdminUser;
protected $userRoles = false;
protected $currentUserModel = false;
public $book;
public $chapter;
......@@ -37,12 +38,6 @@ class PermissionService
*/
public function __construct(JointPermission $jointPermission, Book $book, Chapter $chapter, Page $page, Role $role)
{
$this->currentUser = auth()->user();
$userSet = $this->currentUser !== null;
$this->userRoles = false;
$this->isAdmin = $userSet ? $this->currentUser->hasRole('admin') : false;
if (!$userSet) $this->currentUser = new User();
$this->jointPermission = $jointPermission;
$this->role = $role;
$this->book = $book;
......@@ -117,7 +112,7 @@ class PermissionService
}
foreach ($this->currentUser->roles as $role) {
foreach ($this->currentUser()->roles as $role) {
$roles[] = $role->id;
}
return $roles;
......@@ -389,7 +384,11 @@ class PermissionService
*/
public function checkOwnableUserAccess(Ownable $ownable, $permission)
{
if ($this->isAdmin) return true;
if ($this->isAdmin()) {
$this->clean();
return true;
}
$explodedPermission = explode('-', $permission);
$baseQuery = $ownable->where('id', '=', $ownable->id);
......@@ -400,10 +399,10 @@ class PermissionService
// Handle non entity specific jointPermissions
if (in_array($explodedPermission[0], $nonJointPermissions)) {
$allPermission = $this->currentUser && $this->currentUser->can($permission . '-all');
$ownPermission = $this->currentUser && $this->currentUser->can($permission . '-own');
$allPermission = $this->currentUser() && $this->currentUser()->can($permission . '-all');
$ownPermission = $this->currentUser() && $this->currentUser()->can($permission . '-own');
$this->currentAction = 'view';
$isOwner = $this->currentUser && $this->currentUser->id === $ownable->created_by;
$isOwner = $this->currentUser() && $this->currentUser()->id === $ownable->created_by;
return ($allPermission || ($isOwner && $ownPermission));
}
......@@ -413,7 +412,9 @@ class PermissionService
}
return $this->entityRestrictionQuery($baseQuery)->count() > 0;
$q = $this->entityRestrictionQuery($baseQuery)->count() > 0;
$this->clean();
return $q;
}
/**
......@@ -443,7 +444,7 @@ class PermissionService
*/
protected function entityRestrictionQuery($query)
{
return $query->where(function ($parentQuery) {
$q = $query->where(function ($parentQuery) {
$parentQuery->whereHas('jointPermissions', function ($permissionQuery) {
$permissionQuery->whereIn('role_id', $this->getRoles())
->where('action', '=', $this->currentAction)
......@@ -451,11 +452,13 @@ class PermissionService
$query->where('has_permission', '=', true)
->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('created_by', '=', $this->currentUser->id);
->where('created_by', '=', $this->currentUser()->id);
});
});
});
});
$this->clean();
return $q;
}
/**
......@@ -469,9 +472,9 @@ class PermissionService
// Prevent drafts being visible to others.
$query = $query->where(function ($query) {
$query->where('draft', '=', false);
if ($this->currentUser) {
if ($this->currentUser()) {
$query->orWhere(function ($query) {
$query->where('draft', '=', true)->where('created_by', '=', $this->currentUser->id);
$query->where('draft', '=', true)->where('created_by', '=', $this->currentUser()->id);
});
}
});
......@@ -509,7 +512,10 @@ class PermissionService
*/
public function enforceEntityRestrictions($query, $action = 'view')
{
if ($this->isAdmin) return $query;
if ($this->isAdmin()) {
$this->clean();
return $query;
}
$this->currentAction = $action;
return $this->entityRestrictionQuery($query);
}
......@@ -524,11 +530,15 @@ class PermissionService
*/
public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn)
{
if ($this->isAdmin) return $query;
if ($this->isAdmin()) {
$this->clean();
return $query;
}
$this->currentAction = 'view';
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
return $query->where(function ($query) use ($tableDetails) {
$q = $query->where(function ($query) use ($tableDetails) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails) {
$permissionQuery->select('id')->from('joint_permissions')
->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
......@@ -538,12 +548,12 @@ class PermissionService
->where(function ($query) {
$query->where('has_permission', '=', true)->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('created_by', '=', $this->currentUser->id);
->where('created_by', '=', $this->currentUser()->id);
});
});
});
});
return $q;
}
/**
......@@ -555,11 +565,15 @@ class PermissionService
*/
public function filterRelatedPages($query, $tableName, $entityIdColumn)
{
if ($this->isAdmin) return $query;
if ($this->isAdmin()) {
$this->clean();
return $query;
}
$this->currentAction = 'view';
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
return $query->where(function ($query) use ($tableDetails) {
$q = $query->where(function ($query) use ($tableDetails) {
$query->where(function ($query) use (&$tableDetails) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails) {
$permissionQuery->select('id')->from('joint_permissions')
......@@ -570,12 +584,50 @@ class PermissionService
->where(function ($query) {
$query->where('has_permission', '=', true)->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('created_by', '=', $this->currentUser->id);
->where('created_by', '=', $this->currentUser()->id);
});
});
});
})->orWhere($tableDetails['entityIdColumn'], '=', 0);
});
$this->clean();
return $q;
}
/**
* Check if the current user is an admin.
* @return bool
*/
private function isAdmin()
{
if ($this->isAdminUser === null) {
$this->isAdminUser = ($this->currentUser()->id !== null) ? $this->currentUser()->hasRole('admin') : false;
}
return $this->isAdminUser;
}
/**
* Get the current user
* @return User
*/
private function currentUser()
{
if ($this->currentUserModel === false) {
$this->currentUserModel = user();
}
return $this->currentUserModel;
}
/**
* Clean the cached user elements.
*/
private function clean()
{
$this->currentUserModel = false;
$this->userRoles = false;
$this->isAdminUser = null;
}
}
\ No newline at end of file
......
......@@ -100,7 +100,7 @@ class SocialAuthService
$socialAccount = $this->socialAccount->where('driver_id', '=', $socialId)->first();
$user = $this->userRepo->getByEmail($socialUser->getEmail());
$isLoggedIn = auth()->check();
$currentUser = auth()->user();
$currentUser = user();
// When a user is not logged in and a matching SocialAccount exists,
// Simply log the user into the application.
......@@ -214,9 +214,9 @@ class SocialAuthService
public function detachSocialAccount($socialDriver)
{
session();
auth()->user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
session()->flash('success', title_case($socialDriver) . ' account successfully detached');
return redirect(auth()->user()->getEditUrl());
return redirect(user()->getEditUrl());
}
}
\ No newline at end of file
......
<?php namespace BookStack\Services;
use Illuminate\Contracts\Filesystem\Factory as FileSystem;
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
class UploadService
{
/**
* @var FileSystem
*/
protected $fileSystem;
/**
* @var FileSystemInstance
*/
protected $storageInstance;
/**
* FileService constructor.
* @param $fileSystem
*/
public function __construct(FileSystem $fileSystem)
{
$this->fileSystem = $fileSystem;
}
/**
* Get the storage that will be used for storing images.
* @return FileSystemInstance
*/
protected function getStorage()
{
if ($this->storageInstance !== null) return $this->storageInstance;
$storageType = config('filesystems.default');
$this->storageInstance = $this->fileSystem->disk($storageType);
return $this->storageInstance;
}
/**
* Check whether or not a folder is empty.
* @param $path
* @return bool
*/
protected function isFolderEmpty($path)
{
$files = $this->getStorage()->files($path);
$folders = $this->getStorage()->directories($path);
return (count($files) === 0 && count($folders) === 0);
}
/**
* Check if using a local filesystem.
* @return bool
*/
protected function isLocal()
{
return strtolower(config('filesystems.default')) === 'local';
}
}
\ No newline at end of file
......@@ -18,7 +18,7 @@ class ViewService
public function __construct(View $view, PermissionService $permissionService)
{
$this->view = $view;
$this->user = auth()->user();
$this->user = user();
$this->permissionService = $permissionService;
}
......@@ -84,7 +84,7 @@ class ViewService
->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type');
if ($filterModel) $query = $query->where('viewable_type', '=', get_class($filterModel));
$query = $query->where('user_id', '=', auth()->user()->id);
$query = $query->where('user_id', '=', user()->id);
$viewables = $query->with('viewable')->orderBy('updated_at', 'desc')
->skip($count * $page)->take($count)->get()->pluck('viewable');
......
<?php namespace BookStack;
use BookStack\Notifications\ResetPassword;
use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Notifications\Notifiable;
class User extends Model implements AuthenticatableContract, CanResetPasswordContract
{
use Authenticatable, CanResetPassword;
use Authenticatable, CanResetPassword, Notifiable;
/**
* The database table used by the model.
......@@ -34,21 +37,30 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
protected $permissions;
/**
* Returns a default guest user.
* Returns the default public user.
* @return User
*/
public static function getDefault()
{
return new static([
'email' => 'guest',
'name' => 'Guest'
]);
return static::where('system_name', '=', 'public')->first();
}
/**
* Check if the user is the default public user.
* @return bool
*/
public function isDefault()
{
return $this->system_name === 'public';
}
/**
* The roles that belong to the user.
* @return BelongsToMany
*/
public function roles()
{
if ($this->id === 0) return ;
return $this->belongsToMany(Role::class);
}
......@@ -183,4 +195,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return '';
}
/**
* Send the password reset notification.
* @param string $token
* @return void
*/
public function sendPasswordResetNotification($token)
{
$this->notify(new ResetPassword($token));
}
}
......
......@@ -11,29 +11,30 @@ use BookStack\Ownable;
*/
function versioned_asset($file = '')
{
// Don't require css and JS assets for testing
if (config('app.env') === 'testing') return '';
static $manifest = null;
$manifestPath = 'build/manifest.json';
if (is_null($manifest) && file_exists($manifestPath)) {
$manifest = json_decode(file_get_contents(public_path($manifestPath)), true);
} else if (!file_exists($manifestPath)) {
if (config('app.env') !== 'production') {
$path = public_path($manifestPath);
$error = "No {$path} file found, Ensure you have built the css/js assets using gulp.";
} else {
$error = "No {$manifestPath} file found, Ensure you are using the release version of BookStack";
}
throw new \Exception($error);
static $version = null;
if (is_null($version)) {
$versionFile = base_path('version');
$version = trim(file_get_contents($versionFile));
}
if (isset($manifest[$file])) {
return baseUrl($manifest[$file]);
$additional = '';
if (config('app.env') === 'development') {
$additional = sha1_file(public_path($file));
}
throw new InvalidArgumentException("File {$file} not defined in asset manifest.");
$path = $file . '?version=' . urlencode($version) . $additional;
return baseUrl($path);
}
/**
* Helper method to get the current User.
* Defaults to public 'Guest' user if not logged in.
* @return \BookStack\User
*/
function user()
{
return auth()->user() ?: \BookStack\User::getDefault();
}
/**
......@@ -47,7 +48,7 @@ function versioned_asset($file = '')
function userCan($permission, Ownable $ownable = null)
{
if ($ownable === null) {
return auth()->user() && auth()->user()->can($permission);
return user() && user()->can($permission);
}
// Check permission on ownable item
......@@ -63,7 +64,7 @@ function userCan($permission, Ownable $ownable = null)
*/
function setting($key, $default = false)
{
$settingService = app('BookStack\Services\SettingService');
$settingService = app(\BookStack\Services\SettingService::class);
return $settingService->get($key, $default);
}
......@@ -79,6 +80,7 @@ function baseUrl($path, $forceAppDomain = false)
if ($isFullUrl && !$forceAppDomain) return $path;
$path = trim($path, '/');
// Remove non-specified domain if forced and we have a domain
if ($isFullUrl && $forceAppDomain) {
$explodedPath = explode('/', $path);
$path = implode('/', array_splice($explodedPath, 3));
......@@ -127,14 +129,14 @@ function sortUrl($path, $data, $overrideData = [])
{
$queryStringSections = [];
$queryData = array_merge($data, $overrideData);
// Change sorting direction is already sorted on current attribute
if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
$queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
} else {
$queryData['order'] = 'asc';
}
foreach ($queryData as $name => $value) {
$trimmedVal = trim($value);
if ($trimmedVal === '') continue;
......@@ -144,4 +146,4 @@ function sortUrl($path, $data, $overrideData = [])
if (count($queryStringSections) === 0) return $path;
return baseUrl($path . '?' . implode('&', $queryStringSections));
}
\ No newline at end of file
}
......
......@@ -5,23 +5,24 @@
"license": "MIT",
"type": "project",
"require": {
"php": ">=5.5.9",
"laravel/framework": "5.2.*",
"php": ">=5.6.4",
"laravel/framework": "^5.3.4",
"ext-tidy": "*",
"intervention/image": "^2.3",
"laravel/socialite": "^2.0",
"barryvdh/laravel-ide-helper": "^2.1",
"barryvdh/laravel-debugbar": "^2.0",
"barryvdh/laravel-debugbar": "^2.2.3",
"league/flysystem-aws-s3-v3": "^1.0",
"barryvdh/laravel-dompdf": "0.6.*",
"predis/predis": "^1.0"
"barryvdh/laravel-dompdf": "^0.7",
"predis/predis": "^1.1",
"gathercontent/htmldiff": "^0.2.1"
},
"require-dev": {
"fzaninotto/faker": "~1.4",
"mockery/mockery": "0.9.*",
"phpunit/phpunit": "~4.0",
"phpspec/phpspec": "~2.1",
"symfony/dom-crawler": "~3.0",
"symfony/css-selector": "~3.0"
"phpunit/phpunit": "~5.0",
"symfony/css-selector": "3.1.*",
"symfony/dom-crawler": "3.1.*"
},
"autoload": {
"classmap": [
......@@ -37,21 +38,19 @@
]
},
"scripts": {
"post-root-package-install": [
"php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"php artisan key:generate"
],
"post-install-cmd": [
"php artisan clear-compiled",
"Illuminate\\Foundation\\ComposerScripts::postInstall",
"php artisan optimize"
],
"pre-update-cmd": [
"php artisan clear-compiled"
],
"post-update-cmd": [
"Illuminate\\Foundation\\ComposerScripts::postUpdate",
"php artisan optimize"
],
"post-root-package-install": [
"php -r \"copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"php artisan key:generate"
]
},
"config": {
......
This diff could not be displayed because it is too large.
......@@ -57,7 +57,7 @@ return [
|
*/
'locale' => 'en',
'locale' => env('APP_LANG', 'en'),
/*
|--------------------------------------------------------------------------
......@@ -138,6 +138,7 @@ return [
Illuminate\Translation\TranslationServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Laravel\Socialite\SocialiteServiceProvider::class,
/**
......@@ -156,6 +157,7 @@ return [
BookStack\Providers\AuthServiceProvider::class,
BookStack\Providers\AppServiceProvider::class,
BookStack\Providers\BroadcastServiceProvider::class,
BookStack\Providers\EventServiceProvider::class,
BookStack\Providers\RouteServiceProvider::class,
BookStack\Providers\CustomFacadeProvider::class,
......@@ -194,6 +196,7 @@ return [
'Lang' => Illuminate\Support\Facades\Lang::class,
'Log' => Illuminate\Support\Facades\Log::class,
'Mail' => Illuminate\Support\Facades\Mail::class,
'Notification' => Illuminate\Support\Facades\Notification::class,
'Password' => Illuminate\Support\Facades\Password::class,
'Queue' => Illuminate\Support\Facades\Queue::class,
'Redirect' => Illuminate\Support\Facades\Redirect::class,
......
......@@ -56,7 +56,7 @@ return [
'local' => [
'driver' => 'local',
'root' => public_path(),
'root' => base_path(),
],
'ftp' => [
......
......@@ -6,6 +6,7 @@
return [
'app-name' => 'BookStack',
'app-name-header' => true,
'app-editor' => 'wysiwyg',
'app-color' => '#0288D1',
'app-color-light' => 'rgba(21, 101, 192, 0.15)',
......
......@@ -129,7 +129,7 @@ class AddRolesAndPermissions extends Migration
// Set all current users as admins
// (At this point only the initially create user should be an admin)
$users = DB::table('users')->get();
$users = DB::table('users')->get()->all();
foreach ($users as $user) {
DB::table('role_user')->insert([
'role_id' => $adminId,
......
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class RemoveHiddenRoles extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// Remove the hidden property from roles
Schema::table('roles', function(Blueprint $table) {
$table->dropColumn('hidden');
});
// Add column to mark system users
Schema::table('users', function(Blueprint $table) {
$table->string('system_name')->nullable()->index();
});
// Insert our new public system user.
$publicUserId = DB::table('users')->insertGetId([
'email' => 'guest@example.com',
'name' => 'Guest',
'system_name' => 'public',
'email_confirmed' => true,
'created_at' => \Carbon\Carbon::now(),
'updated_at' => \Carbon\Carbon::now(),
]);
// Get the public role
$publicRole = DB::table('roles')->where('system_name', '=', 'public')->first();
// Connect the new public user to the public role
DB::table('role_user')->insert([
'user_id' => $publicUserId,
'role_id' => $publicRole->id
]);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('roles', function(Blueprint $table) {
$table->boolean('hidden')->default(false);
$table->index('hidden');
});
DB::table('users')->where('system_name', '=', 'public')->delete();
Schema::table('users', function(Blueprint $table) {
$table->dropColumn('system_name');
});
DB::table('roles')->where('system_name', '=', 'public')->update(['hidden' => true]);
}
}
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateAttachmentsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('attachments', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('path');
$table->string('extension', 20);
$table->integer('uploaded_to');
$table->boolean('external');
$table->integer('order');
$table->integer('created_by');
$table->integer('updated_by');
$table->index('uploaded_to');
$table->timestamps();
});
// Get roles with permissions we need to change
$adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
// Create & attach new entity permissions
$ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
$entity = 'Attachment';
foreach ($ops as $op) {
$permissionId = DB::table('role_permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
'display_name' => $op . ' ' . $entity . 's',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
]);
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
'permission_id' => $permissionId
]);
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('attachments');
// Create & attach new entity permissions
$ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
$entity = 'Attachment';
foreach ($ops as $op) {
$permName = strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op));
DB::table('role_permissions')->where('name', '=', $permName)->delete();
}
}
}
var elixir = require('laravel-elixir');
// Custom extensions
var gulp = require('gulp');
var Task = elixir.Task;
var fs = require('fs');
elixir.extend('queryVersion', function(inputFiles) {
new Task('queryVersion', function() {
var manifestObject = {};
var uidString = Date.now().toString(16).slice(4);
for (var i = 0; i < inputFiles.length; i++) {
var file = inputFiles[i];
manifestObject[file] = file + '?version=' + uidString;
}
var fileContents = JSON.stringify(manifestObject, null, 1);
fs.writeFileSync('public/build/manifest.json', fileContents);
}).watch(['./public/css/*.css', './public/js/*.js']);
});
elixir(function(mix) {
mix.sass('styles.scss')
.sass('print-styles.scss')
.sass('export-styles.scss')
.browserify('global.js', 'public/js/common.js')
.queryVersion(['css/styles.css', 'css/print-styles.css', 'js/common.js']);
elixir(mix => {
mix.sass('styles.scss');
mix.sass('print-styles.scss');
mix.sass('export-styles.scss');
mix.browserify('global.js', './public/js/common.js');
});
......
{
"private": true,
"devDependencies": {
"gulp": "^3.9.0"
"scripts": {
"prod": "gulp --production",
"dev": "gulp watch"
},
"dependencies": {
"devDependencies": {
"angular": "^1.5.5",
"angular-animate": "^1.5.5",
"angular-resource": "^1.5.5",
"angular-sanitize": "^1.5.5",
"angular-ui-sortable": "^0.14.0",
"babel-runtime": "^5.8.29",
"bootstrap-sass": "^3.0.0",
"angular-ui-sortable": "^0.15.0",
"dropzone": "^4.0.1",
"laravel-elixir": "^5.0.0",
"gulp": "^3.9.0",
"laravel-elixir": "^6.0.0-11",
"laravel-elixir-browserify-official": "^0.1.3",
"marked": "^0.3.5",
"moment": "^2.12.0",
"zeroclipboard": "^2.2.0"
......
suites:
main:
namespace: BookStack
psr4_prefix: BookStack
src_path: app
\ No newline at end of file
......@@ -30,6 +30,7 @@
<env name="AUTH_METHOD" value="standard"/>
<env name="DISABLE_EXTERNAL_SERVICES" value="true"/>
<env name="LDAP_VERSION" value="3"/>
<env name="STORAGE_TYPE" value="local"/>
<env name="GITHUB_APP_ID" value="aaaaaaaaaaaaaa"/>
<env name="GITHUB_APP_SECRET" value="aaaaaaaaaaaaaa"/>
<env name="GOOGLE_APP_ID" value="aaaaaaaaaaaaaa"/>
......
......@@ -2,13 +2,15 @@
[![GitHub release](https://img.shields.io/github/release/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/releases/latest)
[![license](https://img.shields.io/github/license/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/blob/master/LICENSE)
[![Build Status](https://travis-ci.org/ssddanbrown/BookStack.svg)](https://travis-ci.org/ssddanbrown/BookStack)
[![Build Status](https://travis-ci.org/BookStackApp/BookStack.svg)](https://travis-ci.org/BookStackApp/BookStack)
A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/.
* [Installation Instructions](https://www.bookstackapp.com/docs/admin/installation)
* [Documentation](https://www.bookstackapp.com/docs)
* [Demo Instance](https://demo.bookstackapp.com) *(Login username: `admin@example.com`. Password: `password`)*
* [Demo Instance](https://demo.bookstackapp.com)
* *Username: `admin@example.com`*
* *Password: `password`*
* [BookStack Blog](https://www.bookstackapp.com/blog)
## Development & Testing
......@@ -29,7 +31,7 @@ php artisan migrate --database=mysql_testing
php artisan db:seed --class=DummyContentSeeder --database=mysql_testing
```
Once done you can run `phpunit` (or `./vendor/bin/phpunit` if `phpunit` is not found) in the application root directory to run all tests.
Once done you can run `phpunit` in the application root directory to run all tests.
## License
......@@ -51,3 +53,5 @@ These are the great projects used to help build BookStack:
* [TinyColorPicker](http://www.dematte.at/tinyColorPicker/index.html)
* [Marked](https://github.com/chjj/marked)
* [Moment.js](http://momentjs.com/)
Additionally, Thank you [BrowserStack](https://www.browserstack.com/) for supporting us and making cross-browser testing easy.
......
<div class="dropzone-container">
<div class="dz-message">Drop files or click here to upload</div>
</div>
\ No newline at end of file
<div class="image-picker">
<div>
<img ng-if="image && image !== 'none'" ng-src="{{image}}" ng-class="{{imageClass}}" alt="Image Preview">
<img ng-if="image === '' && defaultImage" ng-src="{{defaultImage}}" ng-class="{{imageClass}}" alt="Image Preview">
</div>
<button class="button" type="button" ng-click="showImageManager()">Select Image</button>
<br>
<button class="text-button" ng-click="reset()" type="button">Reset</button>
<span ng-show="showRemove" class="sep">|</span>
<button ng-show="showRemove" class="text-button neg" ng-click="remove()" type="button">Remove</button>
<input type="hidden" ng-attr-name="{{name}}" ng-attr-id="{{name}}" ng-attr-value="{{value}}">
</div>
\ No newline at end of file
<div class="toggle-switch" ng-click="switch()" ng-class="{'active': isActive}">
<input type="hidden" ng-attr-name="{{name}}" ng-attr-value="{{value}}"/>
<div class="switch-handle"></div>
</div>
\ No newline at end of file
......@@ -2,10 +2,6 @@
const DropZone = require('dropzone');
const markdown = require('marked');
const toggleSwitchTemplate = require('./components/toggle-switch.html');
const imagePickerTemplate = require('./components/image-picker.html');
const dropZoneTemplate = require('./components/drop-zone.html');
module.exports = function (ngApp, events) {
/**
......@@ -16,7 +12,12 @@ module.exports = function (ngApp, events) {
ngApp.directive('toggleSwitch', function () {
return {
restrict: 'A',
template: toggleSwitchTemplate,
template: `
<div class="toggle-switch" ng-click="switch()" ng-class="{'active': isActive}">
<input type="hidden" ng-attr-name="{{name}}" ng-attr-value="{{value}}"/>
<div class="switch-handle"></div>
</div>
`,
scope: true,
link: function (scope, element, attrs) {
scope.name = attrs.name;
......@@ -33,6 +34,59 @@ module.exports = function (ngApp, events) {
};
});
/**
* Common tab controls using simple jQuery functions.
*/
ngApp.directive('tabContainer', function() {
return {
restrict: 'A',
link: function (scope, element, attrs) {
const $content = element.find('[tab-content]');
const $buttons = element.find('[tab-button]');
if (attrs.tabContainer) {
let initial = attrs.tabContainer;
$buttons.filter(`[tab-button="${initial}"]`).addClass('selected');
$content.hide().filter(`[tab-content="${initial}"]`).show();
} else {
$content.hide().first().show();
$buttons.first().addClass('selected');
}
$buttons.click(function() {
let clickedTab = $(this);
$buttons.removeClass('selected');
$content.hide();
let name = clickedTab.addClass('selected').attr('tab-button');
$content.filter(`[tab-content="${name}"]`).show();
});
}
};
});
/**
* Sub form component to allow inner-form sections to act like thier own forms.
*/
ngApp.directive('subForm', function() {
return {
restrict: 'A',
link: function (scope, element, attrs) {
element.on('keypress', e => {
if (e.keyCode === 13) {
submitEvent(e);
}
});
element.find('button[type="submit"]').click(submitEvent);
function submitEvent(e) {
e.preventDefault()
if (attrs.subForm) scope.$eval(attrs.subForm);
}
}
};
});
/**
* Image Picker
......@@ -41,7 +95,22 @@ module.exports = function (ngApp, events) {
ngApp.directive('imagePicker', ['$http', 'imageManagerService', function ($http, imageManagerService) {
return {
restrict: 'E',
template: imagePickerTemplate,
template: `
<div class="image-picker">
<div>
<img ng-if="image && image !== 'none'" ng-src="{{image}}" ng-class="{{imageClass}}" alt="Image Preview">
<img ng-if="image === '' && defaultImage" ng-src="{{defaultImage}}" ng-class="{{imageClass}}" alt="Image Preview">
</div>
<button class="button" type="button" ng-click="showImageManager()">Select Image</button>
<br>
<button class="text-button" ng-click="reset()" type="button">Reset</button>
<span ng-show="showRemove" class="sep">|</span>
<button ng-show="showRemove" class="text-button neg" ng-click="remove()" type="button">Remove</button>
<input type="hidden" ng-attr-name="{{name}}" ng-attr-id="{{name}}" ng-attr-value="{{value}}">
</div>
`,
scope: {
name: '@',
resizeHeight: '@',
......@@ -108,7 +177,11 @@ module.exports = function (ngApp, events) {
ngApp.directive('dropZone', [function () {
return {
restrict: 'E',
template: dropZoneTemplate,
template: `
<div class="dropzone-container">
<div class="dz-message">Drop files or click here to upload</div>
</div>
`,
scope: {
uploadUrl: '@',
eventSuccess: '=',
......@@ -116,6 +189,7 @@ module.exports = function (ngApp, events) {
uploadedTo: '@'
},
link: function (scope, element, attrs) {
if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder;
var dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
url: scope.uploadUrl,
init: function () {
......@@ -488,8 +562,8 @@ module.exports = function (ngApp, events) {
link: function (scope, elem, attrs) {
// Get common elements
const $buttons = elem.find('[tab-button]');
const $content = elem.find('[tab-content]');
const $buttons = elem.find('[toolbox-tab-button]');
const $content = elem.find('[toolbox-tab-content]');
const $toggle = elem.find('[toolbox-toggle]');
// Handle toolbox toggle click
......@@ -501,17 +575,17 @@ module.exports = function (ngApp, events) {
function setActive(tabName, openToolbox) {
$buttons.removeClass('active');
$content.hide();
$buttons.filter(`[tab-button="${tabName}"]`).addClass('active');
$content.filter(`[tab-content="${tabName}"]`).show();
$buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active');
$content.filter(`[toolbox-tab-content="${tabName}"]`).show();
if (openToolbox) elem.addClass('open');
}
// Set the first tab content active on load
setActive($content.first().attr('tab-content'), false);
setActive($content.first().attr('toolbox-tab-content'), false);
// Handle tab button click
$buttons.click(function (e) {
let name = $(this).attr('tab-button');
let name = $(this).attr('toolbox-tab-button');
setActive(name, true);
});
}
......@@ -549,7 +623,7 @@ module.exports = function (ngApp, events) {
let val = $input.val();
let url = $input.attr('autosuggest');
let type = $input.attr('autosuggest-type');
// Add name param to request if for a value
if (type.toLowerCase() === 'value') {
let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
......@@ -850,17 +924,3 @@ module.exports = function (ngApp, events) {
};
}]);
};
......
......@@ -38,13 +38,17 @@ class EventManager {
this.listeners[eventName].push(callback);
return this;
}
};
window.Events = new EventManager();
}
window.Events = new EventManager();
var services = require('./services')(ngApp, window.Events);
var directives = require('./directives')(ngApp, window.Events);
var controllers = require('./controllers')(ngApp, window.Events);
// Load in angular specific items
import Services from './services';
import Directives from './directives';
import Controllers from './controllers';
Services(ngApp, window.Events);
Directives(ngApp, window.Events);
Controllers(ngApp, window.Events);
//Global jQuery Config & Extensions
......
......@@ -6,11 +6,11 @@
* @param editor - editor instance
*/
function editorPaste(e, editor) {
if (!e.clipboardData) return
if (!e.clipboardData) return;
let items = e.clipboardData.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") === -1) return
if (items[i].type.indexOf("image") === -1) return;
let file = items[i].getAsFile();
let formData = new FormData();
......@@ -81,9 +81,10 @@ var mceOptions = module.exports = {
toolbar: "undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr | removeformat code fullscreen",
content_style: "body {padding-left: 15px !important; padding-right: 15px !important; margin:0!important; margin-left:auto!important;margin-right:auto!important;}",
style_formats: [
{title: "Header 1", format: "h1"},
{title: "Header 2", format: "h2"},
{title: "Header 3", format: "h3"},
{title: "Header Large", format: "h2"},
{title: "Header Medium", format: "h3"},
{title: "Header Small", format: "h4"},
{title: "Header Tiny", format: "h5"},
{title: "Paragraph", format: "p", exact: true, classes: ''},
{title: "Blockquote", format: "blockquote"},
{title: "Code Block", icon: "code", format: "pre"},
......
......@@ -43,10 +43,6 @@
}
}
//body.ie .popup-body {
// min-height: 100%;
//}
.corner-button {
position: absolute;
top: 0;
......@@ -82,7 +78,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
min-height: 70vh;
}
#image-manager .dropzone-container {
.dropzone-container {
position: relative;
border: 3px dashed #DDD;
}
......@@ -456,3 +452,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
border-right: 6px solid transparent;
border-bottom: 6px solid $negative;
}
[tab-container] .nav-tabs {
text-align: left;
border-bottom: 1px solid #DDD;
margin-bottom: $-m;
.tab-item {
padding: $-s;
color: #666;
&.selected {
border-bottom-width: 3px;
}
}
}
\ No newline at end of file
......
.page-list {
h3 {
h4 {
margin: $-l 0 $-xs 0;
font-size: 1.666em;
}
......@@ -11,11 +11,13 @@
overflow: hidden;
margin-bottom: $-l;
}
h4 {
h5 {
display: block;
margin: $-s 0 0 0;
border-left: 5px solid $color-page;
padding: $-xs 0 $-xs $-m;
font-size: 1.1em;
font-weight: normal;
&.draft {
border-left-color: $color-page-draft;
}
......@@ -67,44 +69,39 @@
}
}
.page-nav-list {
.sidebar-page-nav {
$nav-indent: $-s;
margin-left: 2px;
list-style: none;
margin: $-s 0 $-m 2px;
border-left: 2px dotted #BBB;
li {
//border-left: 1px solid rgba(0, 0, 0, 0.1);
padding-left: $-xs;
border-left: 2px solid #888;
padding-left: $-s;
margin-bottom: 4px;
font-size: 0.95em;
}
li a {
color: #555;
.h1 {
margin-left: -2px;
}
.nav-H2 {
.h2 {
margin-left: -2px;
}
.h3 {
margin-left: $nav-indent;
font-size: 0.95em;
}
.nav-H3 {
.h4 {
margin-left: $nav-indent*2;
font-size: 0.90em
}
.nav-H4 {
.h5 {
margin-left: $nav-indent*3;
font-size: 0.85em
}
.nav-H5 {
.h6 {
margin-left: $nav-indent*4;
font-size: 0.80em
}
.nav-H6 {
margin-left: $nav-indent*5;
font-size: 0.75em
}
}
// Sidebar list
.book-tree {
padding: $-l 0 0 0;
padding: $-xs 0 0 0;
position: relative;
right: 0;
top: 0;
......@@ -306,10 +303,10 @@ ul.pagination {
}
.entity-list {
>div {
> div {
padding: $-m 0;
}
h3 {
h4 {
margin: 0;
}
p {
......@@ -327,9 +324,10 @@ ul.pagination {
color: $color-page-draft;
}
}
.entity-list.compact {
font-size: 0.6em;
h3, a {
h4, a {
line-height: 1.2;
}
p {
......
......@@ -71,6 +71,18 @@
max-width: 100%;
height: auto !important;
}
// diffs
ins,
del {
text-decoration: none;
}
ins {
background: #dbffdb;
}
del {
background: #FFECEC;
}
}
// Page content pointers
......@@ -138,7 +150,6 @@
background-color: #FFF;
border: 1px solid #DDD;
right: $-xl*2;
z-index: 99;
width: 48px;
overflow: hidden;
align-items: stretch;
......@@ -189,7 +200,7 @@
color: #444;
background-color: rgba(0, 0, 0, 0.1);
}
div[tab-content] {
div[toolbox-tab-content] {
padding-bottom: 45px;
display: flex;
flex: 1;
......@@ -197,7 +208,7 @@
min-height: 0px;
overflow-y: scroll;
}
div[tab-content] .padded {
div[toolbox-tab-content] .padded {
flex: 1;
padding-top: 0;
}
......@@ -216,21 +227,6 @@
padding-top: $-s;
position: relative;
}
button.pos {
position: absolute;
bottom: 0;
display: block;
width: 100%;
padding: $-s;
height: 45px;
border: 0;
margin: 0;
box-shadow: none;
border-radius: 0;
&:hover{
box-shadow: none;
}
}
.handle {
user-select: none;
cursor: move;
......@@ -242,9 +238,12 @@
flex-direction: column;
overflow-y: scroll;
}
table td, table th {
overflow: visible;
}
}
[tab-content] {
[toolbox-tab-content] {
display: none;
}
......
......@@ -51,4 +51,14 @@ table.list-table {
vertical-align: middle;
padding: $-xs;
}
}
table.file-table {
@extend .no-style;
td {
padding: $-xs;
}
.ui-sortable-helper {
display: table;
}
}
\ No newline at end of file
......
......@@ -15,31 +15,41 @@ h2 {
margin-bottom: 0.43137255em;
}
h3 {
font-size: 1.75em;
font-size: 2.333em;
line-height: 1.571428572em;
margin-top: 0.78571429em;
margin-bottom: 0.43137255em;
}
h4 {
font-size: 1em;
font-size: 1.666em;
line-height: 1.375em;
margin-top: 0.78571429em;
margin-bottom: 0.43137255em;
}
h1, h2, h3, h4 {
h1, h2, h3, h4, h5, h6 {
font-weight: 400;
position: relative;
display: block;
color: #555;
.subheader {
//display: block;
font-size: 0.5em;
line-height: 1em;
color: lighten($text-dark, 32%);
}
}
h5 {
font-size: 1.4em;
}
h5, h6 {
font-weight: 500;
line-height: 1.2em;
margin-top: 0.78571429em;
margin-bottom: 0.66em;
}
/*
* Link styling
*/
......@@ -183,7 +193,7 @@ p.neg, p .neg, span.neg, .text-neg {
p.muted, p .muted, span.muted, .text-muted {
color: lighten($text-dark, 26%);
&.small, .small {
color: lighten($text-dark, 42%);
color: lighten($text-dark, 32%);
}
}
......
<?php
return [
/**
* Activity text strings.
* Is used for all the text within activity logs & notifications.
*/
// Pages
'page_create' => 'Seite erstellt',
'page_create_notification' => 'Seite erfolgreich erstellt',
'page_update' => 'Seite aktualisiert',
'page_update_notification' => 'Seite erfolgreich aktualisiert',
'page_delete' => 'Seite gel&ouml;scht',
'page_delete_notification' => 'Seite erfolgreich gel&ouml;scht',
'page_restore' => 'Seite wiederhergstellt',
'page_restore_notification' => 'Seite erfolgreich wiederhergstellt',
'page_move' => 'Seite verschoben',
// Chapters
'chapter_create' => 'Kapitel erstellt',
'chapter_create_notification' => 'Kapitel erfolgreich erstellt',
'chapter_update' => 'Kapitel aktualisiert',
'chapter_update_notification' => 'Kapitel erfolgreich aktualisiert',
'chapter_delete' => 'Kapitel gel&ouml;scht',
'chapter_delete_notification' => 'Kapitel erfolgreich gel&ouml;scht',
'chapter_move' => 'Kapitel verschoben',
// Books
'book_create' => 'Buch erstellt',
'book_create_notification' => 'Buch erfolgreich erstellt',
'book_update' => 'Buch aktualisiert',
'book_update_notification' => 'Buch erfolgreich aktualisiert',
'book_delete' => 'Buch gel&ouml;scht',
'book_delete_notification' => 'Buch erfolgreich gel&ouml;scht',
'book_sort' => 'Buch sortiert',
'book_sort_notification' => 'Buch erfolgreich neu sortiert',
];
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'Dies sind keine g&uuml;ltigen Anmeldedaten.',
'throttle' => 'Zu viele Anmeldeversuche. Bitte versuchen sie es in :seconds Sekunden erneut.',
/**
* Email Confirmation Text
*/
'email_confirm_subject' => 'Best&auml;tigen sie ihre E-Mail Adresse bei :appName',
'email_confirm_greeting' => 'Danke, dass sie :appName beigetreten sind!',
'email_confirm_text' => 'Bitte best&auml;tigen sie ihre E-Mail Adresse, indem sie auf den Button klicken:',
'email_confirm_action' => 'E-Mail Adresse best&auml;tigen',
'email_confirm_send_error' => 'Best&auml;tigungs-E-Mail ben&ouml;tigt, aber das System konnte die E-Mail nicht versenden. Kontaktieren sie den Administrator, um sicherzustellen, dass das Sytsem korrekt eingerichtet ist.',
'email_confirm_success' => 'Ihre E-Mail Adresse wurde best&auml;tigt!',
'email_confirm_resent' => 'Best&auml;tigungs-E-Mail wurde erneut versendet, bitte &uuml;berpr&uuml;fen sie ihren Posteingang.',
];
<?php
return [
/**
* Error text strings.
*/
// Pages
'permission' => 'Sie haben keine Berechtigung auf diese Seite zuzugreifen.',
'permissionJson' => 'Sie haben keine Berechtigung die angeforderte Aktion auszuf&uuml;hren.'
];
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; Vorherige',
'next' => 'N&auml;chste &raquo;',
];
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reminder Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| has failed, such as for an invalid token or invalid new password.
|
*/
'password' => 'Pass&ouml;rter m&uuml;ssen mindestens sechs Zeichen enthalten und die Wiederholung muss identisch sein.',
'user' => "Wir k&ouml;nnen keinen Benutzer mit dieser E-Mail Adresse finden.",
'token' => 'Dieser Passwort-Reset-Token ist ung&uuml;ltig.',
'sent' => 'Wir haben ihnen eine E-Mail mit einem Link zum Zurücksetzen des Passworts zugesendet!',
'reset' => 'Ihr Passwort wurde zur&uuml;ckgesetzt!',
];
<?php
return [
/**
* Settings text strings
* Contains all text strings used in the general settings sections of BookStack
* including users and roles.
*/
'settings' => 'Einstellungen',
'settings_save' => 'Einstellungen speichern',
'app_settings' => 'Anwendungseinstellungen',
'app_name' => 'Anwendungsname',
'app_name_desc' => 'Dieser Name wird im Header und E-Mails angezeigt.',
'app_name_header' => 'Anwendungsname im Header anzeigen?',
'app_public_viewing' => '&Ouml;ffentliche Ansicht erlauben?',
'app_secure_images' => 'Erh&oml;hte Sicherheit f&uuml;r Bilduploads aktivieren?',
'app_secure_images_desc' => 'Aus Leistungsgr&uuml;nden sind alle Bilder &ouml;ffentlich sichtbar. Diese Option f&uuml;gt zuf&auml;llige, schwer zu eratene, Zeichenketten vor die Bild-URLs hinzu. Stellen sie sicher, dass Verzeichnindexes deaktiviert sind, um einen einfachen Zugrif zu verhindern.',
'app_editor' => 'Seiteneditor',
'app_editor_desc' => 'W&auml;hlen sie den Editor aus, der von allen Benutzern genutzt werden soll, um Seiten zu editieren.',
'app_custom_html' => 'Benutzerdefinierter HTML <head> Inhalt',
'app_custom_html_desc' => 'Jeder Inhalt, der hier hinzugef&uuml;gt wird, wird am Ende der <head> Sektion jeder Seite eingef&uuml;gt. Diese kann praktisch sein, um CSS Styles anzupassen oder Analytics Code hinzuzuf&uuml;gen.',
'app_logo' => 'Anwendungslogo',
'app_logo_desc' => 'Dieses Bild sollte 43px hoch sein. <br>Gr&ouml;&szlig;ere Bilder werden verkleinert.',
'app_primary_color' => 'Prim&auml;re Anwendungsfarbe',
'app_primary_color_desc' => 'Dies sollte ein HEX Wert sein. <br>Leer lassen des Feldes setzt auf die Standard-Anwendungsfarbe zur&uuml;ck.',
'reg_settings' => 'Registrierungseinstellungen',
'reg_allow' => 'Registrierung erlauben?',
'reg_default_role' => 'Standard-Benutzerrolle nach Registrierung',
'reg_confirm_email' => 'Best&auml;tigung per E-Mail erforderlich?',
'reg_confirm_email_desc' => 'Falls die Einschr&auml;nkung f&uumlr; Domains genutzt wird, ist die Best&auml;tigung per E-Mail zwingend erforderlich und der untenstehende Wert wird ignoriert.',
'reg_confirm_restrict_domain' => 'Registrierung auf bestimmte Domains einschr&auml;nken',
'reg_confirm_restrict_domain_desc' => 'F&uuml;gen sie eine, durch Komma getrennte, Liste von E-Mail Domains hinzu, auf die die Registrierung eingeschr&auml;nkt werden soll. Benutzern wird eine E-Mail gesendet, um ihre E-Mail Adresse zu best&auml;tigen, bevor sie diese Anwendung nutzen k&ouml;nnen. <br> Hinweis: Benutzer k&ouml;nnen ihre E-Mail Adresse nach erfolgreicher Registrierung &auml;ndern.',
'reg_confirm_restrict_domain_placeholder' => 'Keine Einschr&auml;nkung gesetzt',
];
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| following language lines contain default error messages used by
| validator class. Some of these rules have multiple versions such
| as size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => ':attribute muss akzeptiert werden.',
'active_url' => ':attribute ist keine valide URL.',
'after' => ':attribute muss ein Datum nach :date sein.',
'alpha' => ':attribute kann nur Buchstaben enthalten.',
'alpha_dash' => ':attribute kann nur Buchstaben, Zahlen und Bindestriche enthalten.',
'alpha_num' => ':attribute kann nur Buchstaben und Zahlen enthalten.',
'array' => ':attribute muss eine Array sein.',
'before' => ':attribute muss ein Datum vor :date sein.',
'between' => [
'numeric' => ':attribute muss zwischen :min und :max liegen.',
'file' => ':attribute muss zwischen :min und :max Kilobytes gro&szlig; sein.',
'string' => ':attribute muss zwischen :min und :max Zeichen lang sein.',
'array' => ':attribute muss zwischen :min und :max Elemente enthalten.',
],
'boolean' => ':attribute Feld muss wahr oder falsch sein.',
'confirmed' => ':attribute Best&auml;tigung stimmt nicht &uuml;berein.',
'date' => ':attribute ist kein valides Datum.',
'date_format' => ':attribute entspricht nicht dem Format :format.',
'different' => ':attribute und :other m&uuml;ssen unterschiedlich sein.',
'digits' => ':attribute muss :digits Stellen haben.',
'digits_between' => ':attribute muss zwischen :min und :max Stellen haben.',
'email' => ':attribute muss eine valide E-Mail Adresse sein.',
'filled' => ':attribute Feld ist erforderlich.',
'exists' => 'Markiertes :attribute ist ung&uuml;ltig.',
'image' => ':attribute muss ein Bild sein.',
'in' => 'Markiertes :attribute ist ung&uuml;ltig.',
'integer' => ':attribute muss eine Zahl sein.',
'ip' => ':attribute muss eine valide IP-Adresse sein.',
'max' => [
'numeric' => ':attribute darf nicht gr&ouml;&szlig;er als :max sein.',
'file' => ':attribute darf nicht gr&ouml;&szlig;er als :max Kilobyte sein.',
'string' => ':attribute darf nicht l&auml;nger als :max Zeichen sein.',
'array' => ':attribute darf nicht mehr als :max Elemente enthalten.',
],
'mimes' => ':attribute muss eine Datei vom Typ: :values sein.',
'min' => [
'numeric' => ':attribute muss mindestens :min. sein',
'file' => ':attribute muss mindestens :min Kilobyte gro&szlig; sein.',
'string' => ':attribute muss mindestens :min Zeichen lang sein.',
'array' => ':attribute muss mindesten :min Elemente enthalten.',
],
'not_in' => 'Markiertes :attribute ist ung&uuml;ltig.',
'numeric' => ':attribute muss eine Zahl sein.',
'regex' => ':attribute Format ist ung&uuml;ltig.',
'required' => ':attribute Feld ist erforderlich.',
'required_if' => ':attribute Feld ist erforderlich, wenn :other :value ist.',
'required_with' => ':attribute Feld ist erforderlich, wenn :values vorhanden ist.',
'required_with_all' => ':attribute Feld ist erforderlich, wenn :values vorhanden sind.',
'required_without' => ':attribute Feld ist erforderlich, wenn :values nicht vorhanden ist.',
'required_without_all' => ':attribute Feld ist erforderlich, wenn :values nicht vorhanden sind.',
'same' => ':attribute und :other muss &uuml;bereinstimmen.',
'size' => [
'numeric' => ':attribute muss :size sein.',
'file' => ':attribute muss :size Kilobytes gro&szlig; sein.',
'string' => ':attribute muss :size Zeichen lang sein.',
'array' => ':attribute muss :size Elemente enthalten.',
],
'string' => ':attribute muss eine Zeichenkette sein.',
'timezone' => ':attribute muss eine valide zeitzone sein.',
'unique' => ':attribute wird bereits verwendet.',
'url' => ':attribute ist kein valides Format.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| following language lines are used to swap attribute place-holders
| with something more reader friendly such as E-Mail Address instead
| of "email". This simply helps us make messages a little cleaner.
|
*/
'attributes' => [],
];
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'These credentials do not match our records.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
/**
* Email Confirmation Text
*/
'email_confirm_subject' => 'Confirm your email on :appName',
'email_confirm_greeting' => 'Thanks for joining :appName!',
'email_confirm_text' => 'Please confirm your email address by clicking the button below:',
'email_confirm_action' => 'Confirm Email',
'email_confirm_send_error' => 'Email confirmation required but the system could not send the email. Contact the admin to ensure email is set up correctly.',
'email_confirm_success' => 'Your email has been confirmed!',
'email_confirm_resent' => 'Confirmation email resent, Please check your inbox.',
];
\ No newline at end of file
<?php
return [
/**
* Settings text strings
* Contains all text strings used in the general settings sections of BookStack
* including users and roles.
*/
'settings' => 'Settings',
'settings_save' => 'Save Settings',
'app_settings' => 'App Settings',
'app_name' => 'Application name',
'app_name_desc' => 'This name is shown in the header and any emails.',
'app_name_header' => 'Show Application name in header?',
'app_public_viewing' => 'Allow public viewing?',
'app_secure_images' => 'Enable higher security image uploads?',
'app_secure_images_desc' => 'For performance reasons, all images are public. This option adds a random, hard-to-guess string in front of image urls. Ensure directory indexes are not enabled to prevent easy access.',
'app_editor' => 'Page editor',
'app_editor_desc' => 'Select which editor will be used by all users to edit pages.',
'app_custom_html' => 'Custom HTML head content',
'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the <head> section of every page. This is handy for overriding styles or adding analytics code.',
'app_logo' => 'Application logo',
'app_logo_desc' => 'This image should be 43px in height. <br>Large images will be scaled down.',
'app_primary_color' => 'Application primary color',
'app_primary_color_desc' => 'This should be a hex value. <br>Leave empty to reset to the default color.',
'reg_settings' => 'Registration Settings',
'reg_allow' => 'Allow registration?',
'reg_default_role' => 'Default user role after registration',
'reg_confirm_email' => 'Require email confirmation?',
'reg_confirm_email_desc' => 'If domain restriction is used then email confirmation will be required and the below value will be ignored.',
'reg_confirm_restrict_domain' => 'Restrict registration to domain',
'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application. <br> Note that users will be able to change their email addresses after successful registration.',
'reg_confirm_restrict_domain_placeholder' => 'No restriction set',
];
\ No newline at end of file
......@@ -39,7 +39,9 @@
@if(setting('app-logo', '') !== 'none')
<img class="logo-image" src="{{ setting('app-logo', '') === '' ? baseUrl('/logo.png') : baseUrl(setting('app-logo', '')) }}" alt="Logo">
@endif
<span class="logo-text">{{ setting('app-name') }}</span>
@if (setting('app-name-header'))
<span class="logo-text">{{ setting('app-name') }}</span>
@endif
</a>
</div>
<div class="col-lg-4 col-sm-3 text-center">
......
<div class="book entity-list-item" data-entity-type="book" data-entity-id="{{$book->id}}">
<h3 class="text-book"><a class="text-book entity-list-item-link" href="{{$book->getUrl()}}"><i class="zmdi zmdi-book"></i><span class="entity-list-item-name">{{$book->name}}</span></a></h3>
<h4 class="text-book"><a class="text-book entity-list-item-link" href="{{$book->getUrl()}}"><i class="zmdi zmdi-book"></i><span class="entity-list-item-name">{{$book->name}}</span></a></h4>
@if(isset($book->searchSnippet))
<p class="text-muted">{!! $book->searchSnippet !!}</p>
@else
......
<div class="chapter entity-list-item" data-entity-type="chapter" data-entity-id="{{$chapter->id}}">
<h3>
<h4>
@if (isset($showPath) && $showPath)
<a href="{{ $chapter->book->getUrl() }}" class="text-book">
<i class="zmdi zmdi-book"></i>{{ $chapter->book->name }}
......@@ -9,7 +9,7 @@
<a href="{{ $chapter->getUrl() }}" class="text-chapter entity-list-item-link">
<i class="zmdi zmdi-collection-bookmark"></i><span class="entity-list-item-name">{{ $chapter->name }}</span>
</a>
</h3>
</h4>
@if(isset($chapter->searchSnippet))
<p class="text-muted">{!! $chapter->searchSnippet !!}</p>
@else
......@@ -20,7 +20,7 @@
<p class="text-muted chapter-toggle"><i class="zmdi zmdi-caret-right"></i> <i class="zmdi zmdi-file-text"></i> <span>{{ count($chapter->pages) }} Pages</span></p>
<div class="inset-list">
@foreach($chapter->pages as $page)
<h4 class="@if($page->draft) draft @endif"><a href="{{ $page->getUrl() }}" class="text-page @if($page->draft) draft @endif"><i class="zmdi zmdi-file-text"></i>{{$page->name}}</a></h4>
<h5 class="@if($page->draft) draft @endif"><a href="{{ $page->getUrl() }}" class="text-page @if($page->draft) draft @endif"><i class="zmdi zmdi-file-text"></i>{{$page->name}}</a></h5>
@endforeach
</div>
@endif
......
......@@ -25,14 +25,14 @@
<div class="col-sm-4">
<div id="recent-drafts">
@if(count($draftPages) > 0)
<h3>My Recent Drafts</h3>
<h4>My Recent Drafts</h4>
@include('partials/entity-list', ['entities' => $draftPages, 'style' => 'compact'])
@endif
</div>
@if($signedIn)
<h3>My Recently Viewed</h3>
<h4>My Recently Viewed</h4>
@else
<h3>Recent Books</h3>
<h4>Recent Books</h4>
@endif
@include('partials/entity-list', [
'entities' => $recents,
......@@ -42,7 +42,7 @@
</div>
<div class="col-sm-4">
<h3><a class="no-color" href="{{ baseUrl("/pages/recently-created") }}">Recently Created Pages</a></h3>
<h4><a class="no-color" href="{{ baseUrl("/pages/recently-created") }}">Recently Created Pages</a></h4>
<div id="recently-created-pages">
@include('partials/entity-list', [
'entities' => $recentlyCreatedPages,
......@@ -51,7 +51,7 @@
])
</div>
<h3><a class="no-color" href="{{ baseUrl("/pages/recently-updated") }}">Recently Updated Pages</a></h3>
<h4><a class="no-color" href="{{ baseUrl("/pages/recently-updated") }}">Recently Updated Pages</a></h4>
<div id="recently-updated-pages">
@include('partials/entity-list', [
'entities' => $recentlyUpdatedPages,
......@@ -62,7 +62,7 @@
</div>
<div class="col-sm-4" id="recent-activity">
<h3>Recent Activity</h3>
<h4>Recent Activity</h4>
@include('partials/activity-list', ['activity' => $activity])
</div>
......
......@@ -23,10 +23,4 @@
@include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id])
@include('partials/entity-selector-popup')
<script>
(function() {
})();
</script>
@stop
\ No newline at end of file
......
......@@ -3,10 +3,13 @@
<div class="tabs primary-background-light">
<span toolbox-toggle><i class="zmdi zmdi-caret-left-circle"></i></span>
<span tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span>
<span toolbox-tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span>
@if(userCan('attachment-create-all'))
<span toolbox-tab-button="files" title="Attachments"><i class="zmdi zmdi-attachment"></i></span>
@endif
</div>
<div tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
<div toolbox-tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
<h4>Page Tags</h4>
<div class="padded tags">
<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 @@
</div>
</div>
@if(userCan('attachment-create-all'))
<div toolbox-tab-content="files" ng-controller="PageAttachmentController" page-id="{{ $page->id or 0 }}">
<h4>Attachments</h4>
<div class="padded files">
<div id="file-list" ng-show="!editFile">
<p class="muted small">Upload some files or attach some link to display on your page. These are visible in the page sidebar. <span class="secondary">Changes here are saved instantly.</span></p>
<div tab-container>
<div class="nav-tabs">
<div tab-button="list" class="tab-item">Attached Items</div>
<div tab-button="file" class="tab-item">Upload File</div>
<div tab-button="link" class="tab-item">Attach Link</div>
</div>
<div tab-content="list">
<table class="file-table" style="width: 100%;">
<tbody ui-sortable="sortOptions" ng-model="files" >
<tr ng-repeat="file in files track by $index">
<td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
<td>
<a ng-href="@{{getFileUrl(file)}}" target="_blank" ng-bind="file.name"></a>
<div ng-if="file.deleting">
<span class="neg small">Click delete again to confirm you want to delete this attachment.</span>
<br>
<span class="text-primary small" ng-click="file.deleting=false;">Cancel</span>
</div>
</td>
<td width="10" ng-click="startEdit(file)" class="text-center text-primary" style="padding: 0;"><i class="zmdi zmdi-edit"></i></td>
<td width="5"></td>
<td width="10" ng-click="deleteFile(file)" class="text-center text-neg" style="padding: 0;"><i class="zmdi zmdi-close"></i></td>
</tr>
</tbody>
</table>
<p class="small muted" ng-if="files.length == 0">
No files have been uploaded.
</p>
</div>
<div tab-content="file">
<drop-zone upload-url="@{{getUploadUrl()}}" uploaded-to="@{{uploadedTo}}" event-success="uploadSuccess"></drop-zone>
</div>
<div tab-content="link" sub-form="attachLinkSubmit(file)">
<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>
<div class="form-group">
<label for="attachment-via-link">Link Name</label>
<input type="text" placeholder="Link name" ng-model="file.name">
<p class="small neg" ng-repeat="error in errors.link.name" ng-bind="error"></p>
</div>
<div class="form-group">
<label for="attachment-via-link">Link to file</label>
<input type="text" placeholder="Url of site or file" ng-model="file.link">
<p class="small neg" ng-repeat="error in errors.link.link" ng-bind="error"></p>
</div>
<button type="submit" class="button pos">Attach</button>
</div>
</div>
</div>
<div id="file-edit" ng-if="editFile" sub-form="updateFile(editFile)">
<h5>Edit File</h5>
<div class="form-group">
<label for="attachment-name-edit">File Name</label>
<input type="text" id="attachment-name-edit" placeholder="File name" ng-model="editFile.name">
<p class="small neg" ng-repeat="error in errors.edit.name" ng-bind="error"></p>
</div>
<div tab-container="@{{ editFile.external ? 'link' : 'file' }}">
<div class="nav-tabs">
<div tab-button="file" class="tab-item">Upload File</div>
<div tab-button="link" class="tab-item">Set Link</div>
</div>
<div tab-content="file">
<drop-zone upload-url="@{{getUploadUrl(editFile)}}" uploaded-to="@{{uploadedTo}}" placeholder="Drop files or click here to upload and overwrite" event-success="uploadSuccessUpdate"></drop-zone>
<br>
</div>
<div tab-content="link">
<div class="form-group">
<label for="attachment-link-edit">Link to file</label>
<input type="text" id="attachment-link-edit" placeholder="Attachment link" ng-model="editFile.link">
<p class="small neg" ng-repeat="error in errors.edit.link" ng-bind="error"></p>
</div>
</div>
</div>
<button type="button" class="button" ng-click="cancelEdit()">Back</button>
<button type="submit" class="button pos">Save</button>
</div>
</div>
</div>
@endif
</div>
\ No newline at end of file
......
<div class="page-editor flex-fill flex" ng-controller="PageEditController" editor-type="{{ setting('app-editor') }}" page-id="{{ $model->id or 0 }}" page-new-draft="{{ $model->draft or 0 }}" page-update-draft="{{ $model->isDraft or 0 }}">
<div class="page-editor flex-fill flex" ng-controller="PageEditController" drafts-enabled="{{ $draftsEnabled ? 'true' : 'false' }}" editor-type="{{ setting('app-editor') }}" page-id="{{ $model->id or 0 }}" page-new-draft="{{ $model->draft or 0 }}" page-update-draft="{{ $model->isDraft or 0 }}">
{{ csrf_field() }}
{{--Header Bar--}}
<div class="faded-small toolbar">
<div class="container">
<div class="row">
......@@ -13,7 +15,7 @@
</div>
<div class="col-sm-4 faded text-center">
<div dropdown class="dropdown-container draft-display">
<div ng-show="draftsEnabled" dropdown class="dropdown-container draft-display">
<a dropdown-toggle class="text-primary text-button"><span class="faded-text" ng-bind="draftText"></span>&nbsp; <i class="zmdi zmdi-more-vert"></i></a>
<i class="zmdi zmdi-check-circle text-pos draft-notification" ng-class="{visible: draftUpdated}"></i>
<ul>
......@@ -48,13 +50,17 @@
</div>
</div>
{{--Title input--}}
<div class="title-input page-title clearfix" ng-non-bindable>
<div class="input">
@include('form/text', ['name' => 'name', 'placeholder' => 'Page Title'])
</div>
</div>
{{--Editors--}}
<div class="edit-area flex-fill flex">
{{--WYSIWYG Editor--}}
@if(setting('app-editor') === 'wysiwyg')
<div tinymce="editorOptions" mce-change="editorChange" mce-model="editContent" class="flex-fill flex">
<textarea id="html-editor" name="html" rows="5" ng-non-bindable
......@@ -66,6 +72,7 @@
@endif
@endif
{{--Markdown Editor--}}
@if(setting('app-editor') === 'markdown')
<div id="markdown-editor" markdown-editor class="flex-fill flex">
......@@ -102,7 +109,7 @@
@if($errors->has('markdown'))
<div class="text-neg text-small">{{ $errors->first('markdown') }}</div>
@endif
@endif
</div>
</div>
\ No newline at end of file
......
@extends('base')
@section('content')
<div class="container small" ng-non-bindable>
<h1>Create Page</h1>
<form action="{{ $parent->getUrl('/page/create/guest') }}" method="POST">
{!! csrf_field() !!}
<div class="form-group title-input">
<label for="name">Page Name</label>
@include('form/text', ['name' => 'name'])
</div>
<div class="form-group">
<a href="{{ $parent->getUrl() }}" class="button muted">Cancel</a>
<button type="submit" class="button pos">Continue</button>
</div>
</form>
</div>
@stop
\ No newline at end of file
<div class="page {{$page->draft ? 'draft' : ''}} entity-list-item" data-entity-type="page" data-entity-id="{{$page->id}}">
<h3>
<h4>
<a href="{{ $page->getUrl() }}" class="text-page entity-list-item-link"><i class="zmdi zmdi-file-text"></i><span class="entity-list-item-name">{{ $page->name }}</span></a>
</h3>
</h4>
@if(isset($page->searchSnippet))
<p class="text-muted">{!! $page->searchSnippet !!}</p>
......
......@@ -24,5 +24,9 @@
<div style="clear:left;"></div>
{!! $page->html !!}
@if (isset($diff) && $diff)
{!! $diff !!}
@else
{!! $page->html !!}
@endif
</div>
\ No newline at end of file
......
......@@ -32,11 +32,11 @@
<table class="table">
<tr>
<th width="25%">Name</th>
<th colspan="2" width="10%">Created By</th>
<th width="23%">Name</th>
<th colspan="2" width="8%">Created By</th>
<th width="15%">Revision Date</th>
<th width="25%">Changelog</th>
<th width="15%">Actions</th>
<th width="20%">Actions</th>
</tr>
@foreach($page->revisions as $index => $revision)
<tr>
......@@ -49,15 +49,18 @@
<td> @if($revision->createdBy) {{ $revision->createdBy->name }} @else Deleted User @endif</td>
<td><small>{{ $revision->created_at->format('jS F, Y H:i:s') }} <br> ({{ $revision->created_at->diffForHumans() }})</small></td>
<td>{{ $revision->summary }}</td>
@if ($index !== 0)
<td>
<td>
<a href="{{ $revision->getUrl('changes') }}" target="_blank">Changes</a>
<span class="text-muted">&nbsp;|&nbsp;</span>
@if ($index === 0)
<a target="_blank" href="{{ $page->getUrl() }}"><i>Current Version</i></a>
@else
<a href="{{ $revision->getUrl() }}" target="_blank">Preview</a>
<span class="text-muted">&nbsp;|&nbsp;</span>
<a href="{{ $revision->getUrl() }}/restore">Restore</a>
</td>
@else
<td><a target="_blank" href="{{ $page->getUrl() }}"><i>Current Version</i></a></td>
@endif
<a href="{{ $revision->getUrl('restore') }}" target="_blank">Restore</a>
@endif
</td>
</tr>
@endforeach
</table>
......
......@@ -114,7 +114,8 @@
@endif
</div>
@endif
@include('pages/sidebar-tree-list', ['book' => $book, 'sidebarTree' => $sidebarTree])
@include('pages/sidebar-tree-list', ['book' => $book, 'sidebarTree' => $sidebarTree, 'pageNav' => $pageNav])
</div>
</div>
......
This diff is collapsed. Click to expand it.