mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-05-04 18:08:46 +03:00
Compare commits
29 Commits
sort_rule_
...
lexical_ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47ca6b9c33 | ||
|
|
74aa897626 | ||
|
|
4b624596c8 | ||
|
|
00239bb6c8 | ||
|
|
241563e8fc | ||
|
|
e91747785b | ||
|
|
4f370ccddb | ||
|
|
743a21a02f | ||
|
|
0c9fabb6de | ||
|
|
426f9ac493 | ||
|
|
ec0b0384a2 | ||
|
|
e7e019d3d4 | ||
|
|
1339f668eb | ||
|
|
befa3a8fbb | ||
|
|
083fb1a600 | ||
|
|
a2bb5bdf10 | ||
|
|
e274a5fa4e | ||
|
|
18364d1e6e | ||
|
|
0760e677b2 | ||
|
|
208629ee1f | ||
|
|
346dc27979 | ||
|
|
1c1ad1d1b7 | ||
|
|
f14fc68b66 | ||
|
|
93f84a81b2 | ||
|
|
4feb50e7ee | ||
|
|
c7e2b487c1 | ||
|
|
abed4eae0c | ||
|
|
c7d3775bb9 | ||
|
|
0b659671fe |
86
.github/CODE_OF_CONDUCT.md
vendored
86
.github/CODE_OF_CONDUCT.md
vendored
@@ -1,84 +1,2 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, gender identity and expression, level of experience,
|
||||
education, socio-economic status, nationality, personal appearance, race,
|
||||
religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
### Project Maintainer Standards
|
||||
|
||||
Project maintainers should generally follow these additional standards:
|
||||
|
||||
* Avoid using a negative or harsh tone in communication, Even if the other party
|
||||
is being negative themselves.
|
||||
* When providing criticism, try to make it constructive to lead the other person
|
||||
down the correct path.
|
||||
* Keep the [project definition](https://github.com/BookStackApp/BookStack#project-definition)
|
||||
in mind when deciding what's in scope of the Project.
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior. In addition, Project
|
||||
maintainers are responsible for following the standards themselves.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at the email address shown on [the profile here](https://github.com/ssddanbrown). All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
Please find our community rules on our website here:
|
||||
https://www.bookstackapp.com/about/community-rules/
|
||||
10
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
10
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -56,3 +56,13 @@ body:
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: ai-thoughts
|
||||
attributes:
|
||||
label: Have you used generative AI/LLMs to create any thoughts in this request?
|
||||
description: |
|
||||
We ask that no machine generated thoughts or ideas are provided, to avoid us spending time considering the ideas
|
||||
of a machine instead of a human. Further guidance on this can be found [in the BookStack community rules](https://www.bookstackapp.com/about/community-rules/#use-of-llmsai).
|
||||
options:
|
||||
- label: This request only contains the thoughts & ideas of a human
|
||||
required: true
|
||||
|
||||
11
.github/pull_request_template.md
vendored
Normal file
11
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
## Details
|
||||
|
||||
<!-- Write details of your pull request in here -->
|
||||
<!-- Include references to any relevant issues/discussions -->
|
||||
|
||||
## Checklist
|
||||
|
||||
<!-- Put an 'x' in between the brackets below to confirm these elements -->
|
||||
|
||||
- [ ] I have read the [BookStack community rules](https://www.bookstackapp.com/about/community-rules/).
|
||||
- [ ] This PR does not feature significant use of LLM/AI generation as per the community rules above.
|
||||
@@ -45,11 +45,11 @@ class ForgotPasswordController extends Controller
|
||||
);
|
||||
|
||||
if ($response === Password::RESET_LINK_SENT) {
|
||||
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email'));
|
||||
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->input('email'));
|
||||
}
|
||||
|
||||
if (in_array($response, [Password::RESET_LINK_SENT, Password::INVALID_USER, Password::RESET_THROTTLED])) {
|
||||
$message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
|
||||
$message = trans('auth.reset_password_sent', ['email' => $request->input('email')]);
|
||||
$this->showSuccessNotification($message);
|
||||
|
||||
return redirect('/password/email')->with('status', trans($response));
|
||||
|
||||
@@ -32,12 +32,12 @@ class LoginController extends Controller
|
||||
{
|
||||
$socialDrivers = $this->socialDriverManager->getActive();
|
||||
$authMethod = config('auth.method');
|
||||
$preventInitiation = $request->get('prevent_auto_init') === 'true';
|
||||
$preventInitiation = $request->input('prevent_auto_init') === 'true';
|
||||
|
||||
if ($request->has('email')) {
|
||||
session()->flashInput([
|
||||
'email' => $request->get('email'),
|
||||
'password' => (config('app.env') === 'demo') ? $request->get('password', '') : '',
|
||||
'email' => $request->input('email'),
|
||||
'password' => (config('app.env') === 'demo') ? $request->input('password', '') : '',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ class LoginController extends Controller
|
||||
public function login(Request $request)
|
||||
{
|
||||
$this->validateLogin($request);
|
||||
$username = $request->get($this->username());
|
||||
$username = $request->input($this->username());
|
||||
|
||||
// Check login throttling attempts to see if they've gone over the limit
|
||||
if ($this->hasTooManyLoginAttempts($request)) {
|
||||
|
||||
@@ -84,7 +84,7 @@ class MfaBackupCodesController extends Controller
|
||||
],
|
||||
]);
|
||||
|
||||
$updatedCodes = $codeService->removeInputCodeFromSet($request->get('code'), $codes);
|
||||
$updatedCodes = $codeService->removeInputCodeFromSet($request->input('code'), $codes);
|
||||
MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, $updatedCodes);
|
||||
|
||||
$mfaSession->markVerifiedForUser($user);
|
||||
|
||||
@@ -51,14 +51,14 @@ class MfaController extends Controller
|
||||
*/
|
||||
public function verify(Request $request)
|
||||
{
|
||||
$desiredMethod = $request->get('method');
|
||||
$desiredMethod = $request->input('method');
|
||||
$userMethods = $this->currentOrLastAttemptedUser()
|
||||
->mfaValues()
|
||||
->get(['id', 'method'])
|
||||
->groupBy('method');
|
||||
|
||||
// Basic search for the default option for a user.
|
||||
// (Prioritises totp over backup codes)
|
||||
// (Prioritises TOTP over backup codes)
|
||||
$method = $userMethods->has($desiredMethod) ? $desiredMethod : $userMethods->keys()->sort()->reverse()->first();
|
||||
$otherMethods = $userMethods->keys()->filter(function ($userMethod) use ($method) {
|
||||
return $method !== $userMethod;
|
||||
|
||||
@@ -48,7 +48,7 @@ class ResetPasswordController extends Controller
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
// database. Otherwise, we will parse the error and return the response.
|
||||
$credentials = $request->only('email', 'password', 'password_confirmation', 'token');
|
||||
$response = Password::broker()->reset($credentials, function (User $user, string $password) {
|
||||
$user->password = Hash::make($password);
|
||||
@@ -63,7 +63,7 @@ class ResetPasswordController extends Controller
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $response === Password::PASSWORD_RESET
|
||||
? $this->sendResetResponse()
|
||||
: $this->sendResetFailedResponse($request, $response, $request->get('token'));
|
||||
: $this->sendResetFailedResponse($request, $response, $request->input('token'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,7 +78,7 @@ class Saml2Controller extends Controller
|
||||
*/
|
||||
public function startAcs(Request $request)
|
||||
{
|
||||
$samlResponse = $request->get('SAMLResponse', null);
|
||||
$samlResponse = $request->input('SAMLResponse', null);
|
||||
|
||||
if (empty($samlResponse)) {
|
||||
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
|
||||
@@ -100,7 +100,7 @@ class Saml2Controller extends Controller
|
||||
*/
|
||||
public function processAcs(Request $request)
|
||||
{
|
||||
$acsId = $request->get('id', null);
|
||||
$acsId = $request->input('id', null);
|
||||
$cacheKey = 'saml2_acs:' . $acsId;
|
||||
$samlResponse = null;
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class SocialController extends Controller
|
||||
if ($request->has('error') && $request->has('error_description')) {
|
||||
throw new SocialSignInException(trans('errors.social_login_bad_response', [
|
||||
'socialAccount' => $socialDriver,
|
||||
'error' => $request->get('error_description'),
|
||||
'error' => $request->input('error_description'),
|
||||
]), '/login');
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class UserInviteController extends Controller
|
||||
}
|
||||
|
||||
$user = $this->userRepo->getById($userId);
|
||||
$user->password = Hash::make($request->get('password'));
|
||||
$user->password = Hash::make($request->input('password'));
|
||||
$user->email_confirmed = true;
|
||||
$user->save();
|
||||
|
||||
|
||||
@@ -17,19 +17,19 @@ class AuditLogController extends Controller
|
||||
$this->checkPermission(Permission::SettingsManage);
|
||||
$this->checkPermission(Permission::UsersManage);
|
||||
|
||||
$sort = $request->get('sort', 'activity_date');
|
||||
$order = $request->get('order', 'desc');
|
||||
$sort = $request->input('sort', 'activity_date');
|
||||
$order = $request->input('order', 'desc');
|
||||
$listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([
|
||||
'created_at' => trans('settings.audit_table_date'),
|
||||
'type' => trans('settings.audit_table_event'),
|
||||
]);
|
||||
|
||||
$filters = [
|
||||
'event' => $request->get('event', ''),
|
||||
'date_from' => $request->get('date_from', ''),
|
||||
'date_to' => $request->get('date_to', ''),
|
||||
'user' => $request->get('user', ''),
|
||||
'ip' => $request->get('ip', ''),
|
||||
'event' => $request->input('event', ''),
|
||||
'date_from' => $request->input('date_from', ''),
|
||||
'date_to' => $request->input('date_to', ''),
|
||||
'user' => $request->input('user', ''),
|
||||
'ip' => $request->input('ip', ''),
|
||||
];
|
||||
|
||||
$query = Activity::query()
|
||||
|
||||
@@ -20,7 +20,7 @@ class FavouriteController extends Controller
|
||||
public function index(Request $request, QueryTopFavourites $topFavourites)
|
||||
{
|
||||
$viewCount = 20;
|
||||
$page = intval($request->get('page', 1));
|
||||
$page = intval($request->input('page', 1));
|
||||
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
|
||||
|
||||
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
|
||||
|
||||
68
app/Activity/Controllers/TagApiController.php
Normal file
68
app/Activity/Controllers/TagApiController.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\TagRepo;
|
||||
use BookStack\Http\ApiController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Endpoints to query data about tags in the system.
|
||||
* You'll only see results based on tags applied to content you have access to.
|
||||
* There are no general create/update/delete endpoints here since tags do not exist
|
||||
* by themselves, they are managed via the items they are assigned to.
|
||||
*/
|
||||
class TagApiController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
protected TagRepo $tagRepo,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'listValues' => [
|
||||
'name' => ['required', 'string'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of tag names used in the system.
|
||||
* Only the name field can be used in filters.
|
||||
*/
|
||||
public function listNames(): JsonResponse
|
||||
{
|
||||
$tagQuery = $this->tagRepo
|
||||
->queryWithTotalsForApi('');
|
||||
|
||||
return $this->apiListingResponse($tagQuery, [
|
||||
'name', 'values', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count',
|
||||
], [], [
|
||||
'name'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of tag values, which have been set for the given tag name,
|
||||
* which must be provided as a query parameter on the request.
|
||||
* Only the value field can be used in filters.
|
||||
*/
|
||||
public function listValues(Request $request): JsonResponse
|
||||
{
|
||||
$data = $this->validate($request, $this->rules()['listValues']);
|
||||
$name = $data['name'];
|
||||
|
||||
$tagQuery = $this->tagRepo->queryWithTotalsForApi($name);
|
||||
|
||||
return $this->apiListingResponse($tagQuery, [
|
||||
'name', 'value', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count',
|
||||
], [], [
|
||||
'value',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,9 @@ class TagController extends Controller
|
||||
'usages' => trans('entities.tags_usages'),
|
||||
]);
|
||||
|
||||
$nameFilter = $request->get('name', '');
|
||||
$nameFilter = $request->input('name', '');
|
||||
$tags = $this->tagRepo
|
||||
->queryWithTotals($listOptions, $nameFilter)
|
||||
->queryWithTotalsForList($listOptions, $nameFilter)
|
||||
->paginate(50)
|
||||
->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [
|
||||
'name' => $nameFilter,
|
||||
@@ -46,7 +46,7 @@ class TagController extends Controller
|
||||
*/
|
||||
public function getNameSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search', '');
|
||||
$searchTerm = $request->input('search', '');
|
||||
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
||||
|
||||
return response()->json($suggestions);
|
||||
@@ -57,8 +57,8 @@ class TagController extends Controller
|
||||
*/
|
||||
public function getValueSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search', '');
|
||||
$tagName = $request->get('name', '');
|
||||
$searchTerm = $request->input('search', '');
|
||||
$tagName = $request->input('name', '');
|
||||
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
||||
|
||||
return response()->json($suggestions);
|
||||
|
||||
@@ -9,6 +9,7 @@ use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
use BookStack\Users\Models\OwnableInterface;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use BookStack\Util\HtmlToPlainText;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -87,6 +88,12 @@ class Comment extends Model implements Loggable, OwnableInterface
|
||||
return $filter->filterString($this->html ?? '');
|
||||
}
|
||||
|
||||
public function getPlainText(): string
|
||||
{
|
||||
$converter = new HtmlToPlainText();
|
||||
return $converter->convert($this->html ?? '');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id')
|
||||
|
||||
@@ -24,7 +24,7 @@ class CommentCreationNotification extends BaseActivityNotification
|
||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
||||
$locale->trans('notifications.detail_commenter') => $this->user->name,
|
||||
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
|
||||
$locale->trans('notifications.detail_comment') => $comment->getPlainText(),
|
||||
]);
|
||||
|
||||
return $this->newMailMessage($locale)
|
||||
|
||||
@@ -24,7 +24,7 @@ class CommentMentionNotification extends BaseActivityNotification
|
||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
||||
$locale->trans('notifications.detail_commenter') => $this->user->name,
|
||||
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
|
||||
$locale->trans('notifications.detail_comment') => $comment->getPlainText(),
|
||||
]);
|
||||
|
||||
return $this->newMailMessage($locale)
|
||||
|
||||
@@ -18,9 +18,10 @@ class TagRepo
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query against all tags in the system.
|
||||
* Start a query against all tags in the system, with total counts for their usage,
|
||||
* suitable for a system interface list with listing options.
|
||||
*/
|
||||
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
|
||||
public function queryWithTotalsForList(SimpleListOptions $listOptions, string $nameFilter): Builder
|
||||
{
|
||||
$searchTerm = $listOptions->getSearch();
|
||||
$sort = $listOptions->getSort();
|
||||
@@ -28,17 +29,34 @@ class TagRepo
|
||||
$sort = 'value';
|
||||
}
|
||||
|
||||
$query = $this->baseQueryWithTotals($nameFilter, $searchTerm)
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query against all tags in the system, with total counts for their usage,
|
||||
* which can be used via the API.
|
||||
*/
|
||||
public function queryWithTotalsForApi(string $nameFilter): Builder
|
||||
{
|
||||
$query = $this->baseQueryWithTotals($nameFilter, '');
|
||||
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
}
|
||||
|
||||
protected function baseQueryWithTotals(string $nameFilter, string $searchTerm): Builder
|
||||
{
|
||||
$query = Tag::query()
|
||||
->select([
|
||||
'name',
|
||||
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
|
||||
DB::raw('COUNT(id) as usages'),
|
||||
DB::raw('SUM(IF(entity_type = \'page\', 1, 0)) as page_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'chapter\', 1, 0)) as chapter_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
|
||||
DB::raw('CAST(SUM(IF(entity_type = \'page\', 1, 0)) as UNSIGNED) as page_count'),
|
||||
DB::raw('CAST(SUM(IF(entity_type = \'chapter\', 1, 0)) as UNSIGNED) as chapter_count'),
|
||||
DB::raw('CAST(SUM(IF(entity_type = \'book\', 1, 0)) as UNSIGNED) as book_count'),
|
||||
DB::raw('CAST(SUM(IF(entity_type = \'bookshelf\', 1, 0)) as UNSIGNED) as shelf_count'),
|
||||
])
|
||||
->orderBy($sort, $listOptions->getOrder())
|
||||
->whereHas('entity');
|
||||
|
||||
if ($nameFilter) {
|
||||
@@ -57,7 +75,7 @@ class TagRepo
|
||||
});
|
||||
}
|
||||
|
||||
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -195,11 +195,12 @@ class ApiDocsGenerator
|
||||
protected function getFlatApiRoutes(): Collection
|
||||
{
|
||||
return collect(Route::getRoutes()->getRoutes())->filter(function ($route) {
|
||||
return strpos($route->uri, 'api/') === 0;
|
||||
return str_starts_with($route->uri, 'api/');
|
||||
})->map(function ($route) {
|
||||
[$controller, $controllerMethod] = explode('@', $route->action['uses']);
|
||||
$baseModelName = explode('.', explode('/', $route->uri)[1])[0];
|
||||
$shortName = $baseModelName . '-' . $controllerMethod;
|
||||
$controllerMethodKebab = Str::kebab($controllerMethod);
|
||||
$shortName = $baseModelName . '-' . $controllerMethodKebab;
|
||||
|
||||
return [
|
||||
'name' => $shortName,
|
||||
@@ -207,7 +208,7 @@ class ApiDocsGenerator
|
||||
'method' => $route->methods[0],
|
||||
'controller' => $controller,
|
||||
'controller_method' => $controllerMethod,
|
||||
'controller_method_kebab' => Str::kebab($controllerMethod),
|
||||
'controller_method_kebab' => $controllerMethodKebab,
|
||||
'base_model' => $baseModelName,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -18,6 +18,13 @@ class ListingResponseBuilder
|
||||
*/
|
||||
protected array $fields;
|
||||
|
||||
/**
|
||||
* Which fields are filterable.
|
||||
* When null, the $fields above are used instead (Allow all fields).
|
||||
* @var string[]|null
|
||||
*/
|
||||
protected array|null $filterableFields = null;
|
||||
|
||||
/**
|
||||
* @var array<callable>
|
||||
*/
|
||||
@@ -54,7 +61,7 @@ class ListingResponseBuilder
|
||||
{
|
||||
$filteredQuery = $this->filterQuery($this->query);
|
||||
|
||||
$total = $filteredQuery->count();
|
||||
$total = $filteredQuery->getCountForPagination();
|
||||
$data = $this->fetchData($filteredQuery)->each(function ($model) {
|
||||
foreach ($this->resultModifiers as $modifier) {
|
||||
$modifier($model);
|
||||
@@ -77,6 +84,14 @@ class ListingResponseBuilder
|
||||
$this->resultModifiers[] = $modifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit filtering to just the given set of fields.
|
||||
*/
|
||||
public function setFilterableFields(array $fields): void
|
||||
{
|
||||
$this->filterableFields = $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the data to return within the response.
|
||||
*/
|
||||
@@ -94,7 +109,7 @@ class ListingResponseBuilder
|
||||
protected function filterQuery(Builder $query): Builder
|
||||
{
|
||||
$query = clone $query;
|
||||
$requestFilters = $this->request->get('filter', []);
|
||||
$requestFilters = $this->request->input('filter', []);
|
||||
if (!is_array($requestFilters)) {
|
||||
return $query;
|
||||
}
|
||||
@@ -114,10 +129,11 @@ class ListingResponseBuilder
|
||||
protected function requestFilterToQueryFilter($fieldKey, $value): ?array
|
||||
{
|
||||
$splitKey = explode(':', $fieldKey);
|
||||
$field = $splitKey[0];
|
||||
$field = strtolower($splitKey[0]);
|
||||
$filterOperator = $splitKey[1] ?? 'eq';
|
||||
|
||||
if (!in_array($field, $this->fields)) {
|
||||
$filterFields = $this->filterableFields ?? $this->fields;
|
||||
if (!in_array($field, $filterFields)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -140,8 +156,8 @@ class ListingResponseBuilder
|
||||
$defaultSortName = $this->fields[0];
|
||||
$direction = 'asc';
|
||||
|
||||
$sort = $this->request->get('sort', '');
|
||||
if (strpos($sort, '-') === 0) {
|
||||
$sort = $this->request->input('sort', '');
|
||||
if (str_starts_with($sort, '-')) {
|
||||
$direction = 'desc';
|
||||
}
|
||||
|
||||
@@ -160,9 +176,9 @@ class ListingResponseBuilder
|
||||
protected function countAndOffsetQuery(Builder $query): Builder
|
||||
{
|
||||
$query = clone $query;
|
||||
$offset = max(0, $this->request->get('offset', 0));
|
||||
$offset = max(0, $this->request->input('offset', 0));
|
||||
$maxCount = config('api.max_item_count');
|
||||
$count = $this->request->get('count', config('api.default_item_count'));
|
||||
$count = $this->request->input('count', config('api.default_item_count'));
|
||||
$count = max(min($maxCount, $count), 1);
|
||||
|
||||
return $query->skip($offset)->take($count);
|
||||
|
||||
@@ -48,11 +48,11 @@ class UserApiTokenController extends Controller
|
||||
$secret = Str::random(32);
|
||||
|
||||
$token = (new ApiToken())->forceFill([
|
||||
'name' => $request->get('name'),
|
||||
'name' => $request->input('name'),
|
||||
'token_id' => Str::random(32),
|
||||
'secret' => Hash::make($secret),
|
||||
'user_id' => $user->id,
|
||||
'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
|
||||
'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(),
|
||||
]);
|
||||
|
||||
while (ApiToken::query()->where('token_id', '=', $token->token_id)->exists()) {
|
||||
@@ -100,8 +100,8 @@ class UserApiTokenController extends Controller
|
||||
|
||||
[$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
|
||||
$token->fill([
|
||||
'name' => $request->get('name'),
|
||||
'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
|
||||
'name' => $request->input('name'),
|
||||
'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(),
|
||||
])->save();
|
||||
|
||||
$this->logActivity(ActivityType::API_TOKEN_UPDATE, $token);
|
||||
|
||||
@@ -68,7 +68,7 @@ return [
|
||||
* Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
|
||||
* Symbol, ZapfDingbats.
|
||||
*/
|
||||
'font_dir' => storage_path('fonts/'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
|
||||
'font_dir' => storage_path('fonts/dompdf'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
|
||||
|
||||
/**
|
||||
* The location of the DOMPDF font cache directory.
|
||||
@@ -78,7 +78,7 @@ return [
|
||||
*
|
||||
* Note: This directory must exist and be writable by the webserver process.
|
||||
*/
|
||||
'font_cache' => storage_path('fonts/'),
|
||||
'font_cache' => storage_path('fonts/dompdf/cache'),
|
||||
|
||||
/**
|
||||
* The location of a temporary directory.
|
||||
|
||||
@@ -144,7 +144,7 @@ class BookController extends Controller
|
||||
|
||||
View::incrementFor($book);
|
||||
if ($request->has('shelf')) {
|
||||
$this->shelfContext->setShelfContext(intval($request->get('shelf')));
|
||||
$this->shelfContext->setShelfContext(intval($request->input('shelf')));
|
||||
}
|
||||
|
||||
$this->setPageTitle($book->getShortName());
|
||||
@@ -263,7 +263,7 @@ class BookController extends Controller
|
||||
$this->checkOwnablePermission(Permission::BookView, $book);
|
||||
$this->checkPermission(Permission::BookCreateAll);
|
||||
|
||||
$newName = $request->get('name') ?: $book->name;
|
||||
$newName = $request->input('name') ?: $book->name;
|
||||
$bookCopy = $cloner->cloneBook($book, $newName);
|
||||
$this->showSuccessNotification(trans('entities.books_copy_success'));
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ class BookshelfApiController extends ApiController
|
||||
$this->checkPermission(Permission::BookshelfCreateAll);
|
||||
$requestData = $this->validate($request, $this->rules()['create']);
|
||||
|
||||
$bookIds = $request->get('books', []);
|
||||
$bookIds = $request->input('books', []);
|
||||
$shelf = $this->bookshelfRepo->create($requestData, $bookIds);
|
||||
|
||||
return response()->json($this->forJsonDisplay($shelf));
|
||||
@@ -88,7 +88,7 @@ class BookshelfApiController extends ApiController
|
||||
$this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf);
|
||||
|
||||
$requestData = $this->validate($request, $this->rules()['update']);
|
||||
$bookIds = $request->get('books', null);
|
||||
$bookIds = $request->input('books', null);
|
||||
|
||||
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ class BookshelfController extends Controller
|
||||
'tags' => ['array'],
|
||||
]);
|
||||
|
||||
$bookIds = explode(',', $request->get('books', ''));
|
||||
$bookIds = explode(',', $request->input('books', ''));
|
||||
$shelf = $this->shelfRepo->create($validated, $bookIds);
|
||||
|
||||
return redirect($shelf->getUrl());
|
||||
@@ -196,7 +196,7 @@ class BookshelfController extends Controller
|
||||
unset($validated['image']);
|
||||
}
|
||||
|
||||
$bookIds = explode(',', $request->get('books', ''));
|
||||
$bookIds = explode(',', $request->input('books', ''));
|
||||
$shelf = $this->shelfRepo->update($shelf, $validated, $bookIds);
|
||||
|
||||
return redirect($shelf->getUrl());
|
||||
|
||||
@@ -64,7 +64,7 @@ class ChapterApiController extends ApiController
|
||||
{
|
||||
$requestData = $this->validate($request, $this->rules['create']);
|
||||
|
||||
$bookId = $request->get('book_id');
|
||||
$bookId = $request->input('book_id');
|
||||
$book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId));
|
||||
$this->checkOwnablePermission(Permission::ChapterCreate, $book);
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ class ChapterController extends Controller
|
||||
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
|
||||
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
|
||||
|
||||
$entitySelection = $request->get('entity_selection', null);
|
||||
$entitySelection = $request->input('entity_selection', null);
|
||||
if ($entitySelection === null || $entitySelection === '') {
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
@@ -248,7 +248,7 @@ class ChapterController extends Controller
|
||||
{
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
|
||||
$entitySelection = $request->get('entity_selection') ?: null;
|
||||
$entitySelection = $request->input('entity_selection') ?: null;
|
||||
$newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent();
|
||||
|
||||
if (!$newParentBook instanceof Book) {
|
||||
@@ -259,7 +259,7 @@ class ChapterController extends Controller
|
||||
|
||||
$this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook);
|
||||
|
||||
$newName = $request->get('name') ?: $chapter->name;
|
||||
$newName = $request->input('name') ?: $chapter->name;
|
||||
$chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName);
|
||||
$this->showSuccessNotification(trans('entities.chapters_copy_success'));
|
||||
|
||||
|
||||
@@ -74,9 +74,9 @@ class PageApiController extends ApiController
|
||||
$this->validate($request, $this->rules['create']);
|
||||
|
||||
if ($request->has('chapter_id')) {
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id')));
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id')));
|
||||
} else {
|
||||
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
|
||||
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id')));
|
||||
}
|
||||
$this->checkOwnablePermission(Permission::PageCreate, $parent);
|
||||
|
||||
@@ -133,9 +133,9 @@ class PageApiController extends ApiController
|
||||
|
||||
$parent = null;
|
||||
if ($request->has('chapter_id')) {
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id')));
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id')));
|
||||
} elseif ($request->has('book_id')) {
|
||||
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
|
||||
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id')));
|
||||
}
|
||||
|
||||
if ($parent && !$parent->matches($page->getParent())) {
|
||||
|
||||
@@ -88,7 +88,7 @@ class PageController extends Controller
|
||||
|
||||
$page = $this->pageRepo->getNewDraftPage($parent);
|
||||
$this->pageRepo->publishDraft($page, [
|
||||
'name' => $request->get('name'),
|
||||
'name' => $request->input('name'),
|
||||
]);
|
||||
|
||||
return redirect($page->getUrl('/edit'));
|
||||
@@ -408,7 +408,7 @@ class PageController extends Controller
|
||||
$this->checkOwnablePermission(Permission::PageUpdate, $page);
|
||||
$this->checkOwnablePermission(Permission::PageDelete, $page);
|
||||
|
||||
$entitySelection = $request->get('entity_selection', null);
|
||||
$entitySelection = $request->input('entity_selection', null);
|
||||
if ($entitySelection === null || $entitySelection === '') {
|
||||
return redirect($page->getUrl());
|
||||
}
|
||||
@@ -453,7 +453,7 @@ class PageController extends Controller
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission(Permission::PageView, $page);
|
||||
|
||||
$entitySelection = $request->get('entity_selection') ?: null;
|
||||
$entitySelection = $request->input('entity_selection') ?: null;
|
||||
$newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent();
|
||||
|
||||
if (!$newParent instanceof Book && !$newParent instanceof Chapter) {
|
||||
@@ -464,7 +464,7 @@ class PageController extends Controller
|
||||
|
||||
$this->checkOwnablePermission(Permission::PageCreate, $newParent);
|
||||
|
||||
$newName = $request->get('name') ?: $page->name;
|
||||
$newName = $request->input('name') ?: $page->name;
|
||||
$pageCopy = $cloner->clonePage($page, $newParent, $newName);
|
||||
$this->showSuccessNotification(trans('entities.pages_copy_success'));
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function index(Request $request, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([
|
||||
'id' => trans('entities.pages_revisions_sort_number')
|
||||
@@ -65,6 +66,8 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function show(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
@@ -94,6 +97,8 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function changes(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
@@ -129,6 +134,7 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function restore(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission(Permission::PageUpdate, $page);
|
||||
|
||||
@@ -144,6 +150,7 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function destroy(string $bookSlug, string $pageSlug, int $revId)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission(Permission::PageDelete, $page);
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ class PageTemplateController extends Controller
|
||||
*/
|
||||
public function list(Request $request)
|
||||
{
|
||||
$page = $request->get('page', 1);
|
||||
$search = $request->get('search', '');
|
||||
$page = $request->input('page', 1);
|
||||
$search = $request->input('search', '');
|
||||
$count = 10;
|
||||
|
||||
$query = $this->pageQueries->visibleTemplates()
|
||||
|
||||
@@ -16,6 +16,7 @@ use BookStack\References\ReferenceUpdater;
|
||||
use BookStack\Sorting\BookSorter;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Util\HtmlDescriptionFilter;
|
||||
use BookStack\Util\HtmlToPlainText;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class BaseRepo
|
||||
@@ -151,9 +152,10 @@ class BaseRepo
|
||||
}
|
||||
|
||||
if (isset($input['description_html'])) {
|
||||
$plainTextConverter = new HtmlToPlainText();
|
||||
$entity->descriptionInfo()->set(
|
||||
HtmlDescriptionFilter::filterFromString($input['description_html']),
|
||||
html_entity_decode(strip_tags($input['description_html']))
|
||||
$plainTextConverter->convert($input['description_html']),
|
||||
);
|
||||
} else if (isset($input['description'])) {
|
||||
$entity->descriptionInfo()->set('', $input['description']);
|
||||
|
||||
@@ -16,6 +16,7 @@ use BookStack\Users\Models\User;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
use BookStack\Util\HtmlToPlainText;
|
||||
use BookStack\Util\WebSafeMimeSniffer;
|
||||
use Closure;
|
||||
use DOMElement;
|
||||
@@ -303,8 +304,8 @@ class PageContent
|
||||
public function toPlainText(): string
|
||||
{
|
||||
$html = $this->render(true);
|
||||
|
||||
return html_entity_decode(strip_tags($html));
|
||||
$converter = new HtmlToPlainText();
|
||||
return $converter->convert($html);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,8 +20,8 @@ class PermissionsUpdater
|
||||
*/
|
||||
public function updateFromPermissionsForm(Entity $entity, Request $request): void
|
||||
{
|
||||
$permissions = $request->get('permissions', null);
|
||||
$ownerId = $request->get('owned_by', null);
|
||||
$permissions = $request->input('permissions', null);
|
||||
$ownerId = $request->input('owned_by', null);
|
||||
|
||||
$entity->permissions()->delete();
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Util\CspService;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
use BookStack\Util\HtmlToPlainText;
|
||||
use DOMElement;
|
||||
use Exception;
|
||||
use Throwable;
|
||||
@@ -242,24 +243,13 @@ class ExportFormatter
|
||||
|
||||
/**
|
||||
* Converts the page contents into simple plain text.
|
||||
* This method filters any bad looking content to provide a nice final output.
|
||||
* We re-generate the plain text from HTML at this point, post-page-content rendering.
|
||||
*/
|
||||
public function pageToPlainText(Page $page, bool $pageRendered = false, bool $fromParent = false): string
|
||||
{
|
||||
$html = $pageRendered ? $page->html : (new PageContent($page))->render();
|
||||
// Add proceeding spaces before tags so spaces remain between
|
||||
// text within elements after stripping tags.
|
||||
$html = str_replace('<', " <", $html);
|
||||
$text = trim(strip_tags($html));
|
||||
// Replace multiple spaces with single spaces
|
||||
$text = preg_replace('/ {2,}/', ' ', $text);
|
||||
// Reduce multiple horrid whitespace characters.
|
||||
$text = preg_replace('/(\x0A|\xA0|\x0A|\r|\n){2,}/su', "\n\n", $text);
|
||||
$text = html_entity_decode($text);
|
||||
// Add title
|
||||
$text = $page->name . ($fromParent ? "\n" : "\n\n") . $text;
|
||||
|
||||
return $text;
|
||||
$contentText = (new HtmlToPlainText())->convert($html);
|
||||
return $page->name . ($fromParent ? "\n" : "\n\n") . $contentText;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,7 +257,7 @@ class ExportFormatter
|
||||
*/
|
||||
public function chapterToPlainText(Chapter $chapter): string
|
||||
{
|
||||
$text = $chapter->name . "\n" . $chapter->description;
|
||||
$text = $chapter->name . "\n" . $chapter->descriptionInfo()->getPlain();
|
||||
$text = trim($text) . "\n\n";
|
||||
|
||||
$parts = [];
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace BookStack\Exports;
|
||||
|
||||
use BookStack\Exceptions\PdfExportException;
|
||||
use Dompdf\Dompdf;
|
||||
use FontLib\Font;
|
||||
use Illuminate\Support\Str;
|
||||
use Knp\Snappy\Pdf as SnappyPdf;
|
||||
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
||||
use Symfony\Component\Process\Process;
|
||||
@@ -60,12 +62,65 @@ class PdfGenerator
|
||||
$domPdf = new Dompdf($options);
|
||||
$domPdf->setBasePath(base_path('public'));
|
||||
|
||||
$fontMetrics = $domPdf->getFontMetrics();
|
||||
$userFontfamilies = $this->getUserDomPdfFontFamilies();
|
||||
foreach ($userFontfamilies as $fontFamily => $fonts) {
|
||||
try {
|
||||
$fontMetrics->setFontFamily($fontFamily, $fonts);
|
||||
} catch (\Exception $exception) {
|
||||
$expectedPath = storage_path('fonts/dompdf');
|
||||
throw new PdfExportException("Failed to create required font data in {$expectedPath}, Ensure all content in this location is writable by the web server");
|
||||
}
|
||||
}
|
||||
|
||||
$domPdf->loadHTML($this->convertEntities($html));
|
||||
$domPdf->render();
|
||||
|
||||
return (string) $domPdf->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
protected function getUserDomPdfFontFamilies(): array
|
||||
{
|
||||
$fontStore = storage_path('fonts/dompdf');
|
||||
if (!is_dir($fontStore)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$fontFamilies = [];
|
||||
$fontFiles = glob($fontStore . DIRECTORY_SEPARATOR . '*.ttf');
|
||||
foreach ($fontFiles as $fontFile) {
|
||||
$fontFileName = basename($fontFile, '.ttf');
|
||||
$expectedUfm = $fontStore . DIRECTORY_SEPARATOR . $fontFileName . '.ufm';
|
||||
if (!file_exists($expectedUfm)) {
|
||||
$font = Font::load($fontFile);
|
||||
$font->parse();
|
||||
try {
|
||||
$font->saveAdobeFontMetrics($expectedUfm);
|
||||
} catch (\Exception $exception) {
|
||||
throw new PdfExportException("Failed to create required font data at $expectedUfm, Ensure this location is writable by the web server");
|
||||
}
|
||||
}
|
||||
|
||||
$nameParts = explode('-', $fontFileName);
|
||||
if (count($nameParts) === 1 || $nameParts[1] === 'Regular') {
|
||||
$nameParts[1] = 'Normal';
|
||||
}
|
||||
|
||||
$family = trim(strtolower(preg_replace('/([A-Z])/', ' $1', $nameParts[0])));
|
||||
$variation = Str::snake($nameParts[1]);
|
||||
if (!isset($fontFamilies[$family])) {
|
||||
$fontFamilies[$family] = [];
|
||||
}
|
||||
|
||||
$fontFamilies[$family][$variation] = $fontStore . DIRECTORY_SEPARATOR . $fontFileName;
|
||||
}
|
||||
|
||||
return $fontFamilies;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws PdfExportException
|
||||
*/
|
||||
|
||||
@@ -45,7 +45,7 @@ final class ZipExportAttachment extends ZipExportModel
|
||||
$rules = [
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'link' => ['required_without:file', 'nullable', 'string'],
|
||||
'link' => ['required_without:file', 'nullable', 'string', 'max:2000', 'safe_url'],
|
||||
'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()],
|
||||
];
|
||||
|
||||
|
||||
@@ -20,10 +20,14 @@ abstract class ApiController extends Controller
|
||||
* Provide a paginated listing JSON response in a standard format
|
||||
* taking into account any pagination parameters passed by the user.
|
||||
*/
|
||||
protected function apiListingResponse(Builder $query, array $fields, array $modifiers = []): JsonResponse
|
||||
protected function apiListingResponse(Builder $query, array $fields, array $modifiers = [], array $filterableFields = []): JsonResponse
|
||||
{
|
||||
$listing = new ListingResponseBuilder($query, request(), $fields);
|
||||
|
||||
if (count($filterableFields) > 0) {
|
||||
$listing->setFilterableFields($filterableFields);
|
||||
}
|
||||
|
||||
foreach ($modifiers as $modifier) {
|
||||
$listing->modifyResults($modifier);
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@ enum Permission: string
|
||||
case PageViewAll = 'page-view-all';
|
||||
case PageViewOwn = 'page-view-own';
|
||||
|
||||
case RevisionViewAll = 'revision-view-all';
|
||||
|
||||
/**
|
||||
* Get the generic permissions which may be queried for entities.
|
||||
*/
|
||||
|
||||
@@ -40,9 +40,9 @@ class SearchApiController extends ApiController
|
||||
{
|
||||
$this->validate($request, $this->rules['all']);
|
||||
|
||||
$options = SearchOptions::fromString($request->get('query') ?? '');
|
||||
$page = intval($request->get('page', '0')) ?: 1;
|
||||
$count = min(intval($request->get('count', '0')) ?: 20, 100);
|
||||
$options = SearchOptions::fromString($request->input('query') ?? '');
|
||||
$page = intval($request->input('page', '0')) ?: 1;
|
||||
$count = min(intval($request->input('count', '0')) ?: 20, 100);
|
||||
|
||||
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
|
||||
$this->resultsFormatter->format($results['results']->all(), $options);
|
||||
|
||||
@@ -24,7 +24,7 @@ class SearchController extends Controller
|
||||
{
|
||||
$searchOpts = SearchOptions::fromRequest($request);
|
||||
$fullSearchString = $searchOpts->toString();
|
||||
$page = intval($request->get('page', '0')) ?: 1;
|
||||
$page = intval($request->input('page', '0')) ?: 1;
|
||||
$count = setting()->getInteger('lists-page-count-search', 18, 1, 1000);
|
||||
|
||||
$results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, $count);
|
||||
@@ -49,7 +49,7 @@ class SearchController extends Controller
|
||||
*/
|
||||
public function searchBook(Request $request, int $bookId)
|
||||
{
|
||||
$term = $request->get('term', '');
|
||||
$term = $request->input('term', '');
|
||||
$results = $this->searchRunner->searchBook($bookId, $term);
|
||||
|
||||
return view('entities.list', ['entities' => $results]);
|
||||
@@ -60,7 +60,7 @@ class SearchController extends Controller
|
||||
*/
|
||||
public function searchChapter(Request $request, int $chapterId)
|
||||
{
|
||||
$term = $request->get('term', '');
|
||||
$term = $request->input('term', '');
|
||||
$results = $this->searchRunner->searchChapter($chapterId, $term);
|
||||
|
||||
return view('entities.list', ['entities' => $results]);
|
||||
@@ -72,9 +72,9 @@ class SearchController extends Controller
|
||||
*/
|
||||
public function searchForSelector(Request $request, QueryPopular $queryPopular)
|
||||
{
|
||||
$entityTypes = $request->filled('types') ? explode(',', $request->get('types')) : ['page', 'chapter', 'book'];
|
||||
$searchTerm = $request->get('term', false);
|
||||
$permission = $request->get('permission', 'view');
|
||||
$entityTypes = $request->filled('types') ? explode(',', $request->input('types')) : ['page', 'chapter', 'book'];
|
||||
$searchTerm = $request->input('term', false);
|
||||
$permission = $request->input('permission', 'view');
|
||||
|
||||
// Search for entities otherwise show most popular
|
||||
if ($searchTerm !== false) {
|
||||
@@ -93,7 +93,7 @@ class SearchController extends Controller
|
||||
*/
|
||||
public function templatesForSelector(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('term', false);
|
||||
$searchTerm = $request->input('term', false);
|
||||
|
||||
if ($searchTerm !== false) {
|
||||
$searchOptions = SearchOptions::fromString($searchTerm);
|
||||
@@ -119,7 +119,7 @@ class SearchController extends Controller
|
||||
*/
|
||||
public function searchSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('term', '');
|
||||
$searchTerm = $request->input('term', '');
|
||||
$entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 5)['results'];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
@@ -136,8 +136,8 @@ class SearchController extends Controller
|
||||
*/
|
||||
public function searchSiblings(Request $request, SiblingFetcher $siblingFetcher)
|
||||
{
|
||||
$type = $request->get('entity_type', null);
|
||||
$id = $request->get('entity_id', null);
|
||||
$type = $request->input('entity_type', null);
|
||||
$id = $request->input('entity_id', null);
|
||||
|
||||
$entities = $siblingFetcher->fetch($type, $id);
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class SearchOptions
|
||||
}
|
||||
|
||||
if ($request->has('term')) {
|
||||
return static::fromString($request->get('term'));
|
||||
return static::fromString($request->input('term'));
|
||||
}
|
||||
|
||||
$instance = new SearchOptions();
|
||||
|
||||
@@ -44,7 +44,7 @@ class AppSettingsStore
|
||||
}
|
||||
|
||||
// Clear icon image if requested
|
||||
if ($request->get('app_icon_reset')) {
|
||||
if ($request->input('app_icon_reset')) {
|
||||
$this->destroyExistingSettingImage('app-icon');
|
||||
setting()->remove('app-icon');
|
||||
foreach ($sizes as $size) {
|
||||
@@ -67,7 +67,7 @@ class AppSettingsStore
|
||||
}
|
||||
|
||||
// Clear logo image if requested
|
||||
if ($request->get('app_logo_reset')) {
|
||||
if ($request->input('app_logo_reset')) {
|
||||
$this->destroyExistingSettingImage('app-logo');
|
||||
setting()->remove('app-logo');
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class MaintenanceController extends Controller
|
||||
$this->checkPermission(Permission::SettingsManage);
|
||||
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'cleanup-images');
|
||||
|
||||
$checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
|
||||
$checkRevisions = !($request->input('ignore_revisions', 'false') === 'true');
|
||||
$dryRun = !($request->has('confirm'));
|
||||
|
||||
$imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun);
|
||||
|
||||
@@ -58,7 +58,7 @@ class BookSortController extends Controller
|
||||
// Sort via map
|
||||
if ($request->filled('sort-tree')) {
|
||||
(new DatabaseTransaction(function () use ($book, $request, $sorter, &$loggedActivityForBook) {
|
||||
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
|
||||
$sortMap = BookSortMap::fromJson($request->input('sort-tree'));
|
||||
$booksInvolved = $sorter->sortUsingMap($sortMap);
|
||||
|
||||
// Add activity for involved books.
|
||||
@@ -72,7 +72,7 @@ class BookSortController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('auto-sort')) {
|
||||
$sortSetId = intval($request->get('auto-sort')) ?: null;
|
||||
$sortSetId = intval($request->input('auto-sort')) ?: null;
|
||||
if ($sortSetId && SortRule::query()->find($sortSetId) === null) {
|
||||
$sortSetId = null;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ class AttachmentApiController extends ApiController
|
||||
$this->checkPermission(Permission::AttachmentCreateAll);
|
||||
$requestData = $this->validate($request, $this->rules()['create']);
|
||||
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$pageId = $request->input('uploaded_to');
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
|
||||
$this->checkOwnablePermission(Permission::PageUpdate, $page);
|
||||
|
||||
@@ -134,7 +134,7 @@ class AttachmentApiController extends ApiController
|
||||
|
||||
$page = $attachment->page;
|
||||
if ($requestData['uploaded_to'] ?? false) {
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$pageId = $request->input('uploaded_to');
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
|
||||
$attachment->uploaded_to = $requestData['uploaded_to'];
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class AttachmentController extends Controller
|
||||
'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
|
||||
]);
|
||||
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$pageId = $request->input('uploaded_to');
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
|
||||
|
||||
$this->checkPermission(Permission::AttachmentCreateAll);
|
||||
@@ -125,8 +125,8 @@ class AttachmentController extends Controller
|
||||
$this->checkOwnablePermission(Permission::AttachmentUpdate, $attachment);
|
||||
|
||||
$attachment = $this->attachmentService->updateFile($attachment, [
|
||||
'name' => $request->get('attachment_edit_name'),
|
||||
'link' => $request->get('attachment_edit_url'),
|
||||
'name' => $request->input('attachment_edit_name'),
|
||||
'link' => $request->input('attachment_edit_url'),
|
||||
]);
|
||||
|
||||
return view('attachments.manager-edit-form', [
|
||||
@@ -141,7 +141,7 @@ class AttachmentController extends Controller
|
||||
*/
|
||||
public function attachLink(Request $request)
|
||||
{
|
||||
$pageId = $request->get('attachment_link_uploaded_to');
|
||||
$pageId = $request->input('attachment_link_uploaded_to');
|
||||
|
||||
try {
|
||||
$this->validate($request, [
|
||||
@@ -161,8 +161,8 @@ class AttachmentController extends Controller
|
||||
$this->checkPermission(Permission::AttachmentCreateAll);
|
||||
$this->checkOwnablePermission(Permission::PageUpdate, $page);
|
||||
|
||||
$attachmentName = $request->get('attachment_link_name');
|
||||
$link = $request->get('attachment_link_url');
|
||||
$attachmentName = $request->input('attachment_link_name');
|
||||
$link = $request->input('attachment_link_url');
|
||||
$this->attachmentService->saveNewFromLink($attachmentName, $link, intval($pageId));
|
||||
|
||||
return view('attachments.manager-link-form', [
|
||||
@@ -198,7 +198,7 @@ class AttachmentController extends Controller
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
|
||||
$this->checkOwnablePermission(Permission::PageUpdate, $page);
|
||||
|
||||
$attachmentOrder = $request->get('order');
|
||||
$attachmentOrder = $request->input('order');
|
||||
$this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId);
|
||||
|
||||
return response()->json(['message' => trans('entities.attachments_order_updated')]);
|
||||
@@ -231,7 +231,7 @@ class AttachmentController extends Controller
|
||||
$attachmentStream = $this->attachmentService->streamAttachmentFromStorage($attachment);
|
||||
$attachmentSize = $this->attachmentService->getAttachmentFileSize($attachment);
|
||||
|
||||
if ($request->get('open') === 'true') {
|
||||
if ($request->input('open') === 'true') {
|
||||
return $this->download()->streamedInline($attachmentStream, $fileName, $attachmentSize);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ class DrawioImageController extends Controller
|
||||
*/
|
||||
public function list(Request $request, ImageResizer $resizer)
|
||||
{
|
||||
$page = $request->get('page', 1);
|
||||
$searchTerm = $request->get('search', null);
|
||||
$uploadedToFilter = $request->get('uploaded_to', null);
|
||||
$parentTypeFilter = $request->get('filter_type', null);
|
||||
$page = $request->input('page', 1);
|
||||
$searchTerm = $request->input('search', null);
|
||||
$uploadedToFilter = $request->input('uploaded_to', null);
|
||||
$parentTypeFilter = $request->input('filter_type', null);
|
||||
|
||||
$imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
|
||||
$viewData = [
|
||||
@@ -59,10 +59,10 @@ class DrawioImageController extends Controller
|
||||
]);
|
||||
|
||||
$this->checkPermission(Permission::ImageCreateAll);
|
||||
$imageBase64Data = $request->get('image');
|
||||
$imageBase64Data = $request->input('image');
|
||||
|
||||
try {
|
||||
$uploadedTo = $request->get('uploaded_to', 0);
|
||||
$uploadedTo = $request->input('uploaded_to', 0);
|
||||
$image = $this->imageRepo->saveDrawing($imageBase64Data, $uploadedTo);
|
||||
} catch (ImageUploadException $e) {
|
||||
return response($e->getMessage(), 500);
|
||||
|
||||
@@ -24,10 +24,10 @@ class GalleryImageController extends Controller
|
||||
*/
|
||||
public function list(Request $request, ImageResizer $resizer)
|
||||
{
|
||||
$page = $request->get('page', 1);
|
||||
$searchTerm = $request->get('search', null);
|
||||
$uploadedToFilter = $request->get('uploaded_to', null);
|
||||
$parentTypeFilter = $request->get('filter_type', null);
|
||||
$page = $request->input('page', 1);
|
||||
$searchTerm = $request->input('search', null);
|
||||
$uploadedToFilter = $request->input('uploaded_to', null);
|
||||
$parentTypeFilter = $request->input('filter_type', null);
|
||||
|
||||
$imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 30, $uploadedToFilter, $searchTerm);
|
||||
$viewData = [
|
||||
@@ -69,7 +69,7 @@ class GalleryImageController extends Controller
|
||||
|
||||
try {
|
||||
$imageUpload = $request->file('file');
|
||||
$uploadedTo = $request->get('uploaded_to', 0);
|
||||
$uploadedTo = $request->input('uploaded_to', 0);
|
||||
$image = $this->imageRepo->saveNew($imageUpload, 'gallery', $uploadedTo);
|
||||
} catch (ImageUploadException $e) {
|
||||
return response($e->getMessage(), 500);
|
||||
|
||||
@@ -55,7 +55,7 @@ class RoleController extends Controller
|
||||
/** @var ?Role $role */
|
||||
$role = null;
|
||||
if ($request->has('copy_from')) {
|
||||
$role = Role::query()->find($request->get('copy_from'));
|
||||
$role = Role::query()->find($request->input('copy_from'));
|
||||
}
|
||||
|
||||
if ($role) {
|
||||
@@ -150,7 +150,7 @@ class RoleController extends Controller
|
||||
$this->checkPermission(Permission::UserRolesManage);
|
||||
|
||||
try {
|
||||
$migrateRoleId = intval($request->get('migrate_role_id') ?: "0");
|
||||
$migrateRoleId = intval($request->input('migrate_role_id') ?: "0");
|
||||
$this->permissionsRepo->deleteRole($id, $migrateRoleId);
|
||||
} catch (PermissionsException $e) {
|
||||
$this->showErrorNotification($e->getMessage());
|
||||
|
||||
@@ -106,8 +106,8 @@ class UserAccountController extends Controller
|
||||
*/
|
||||
public function updateShortcuts(Request $request)
|
||||
{
|
||||
$enabled = $request->get('enabled') === 'true';
|
||||
$providedShortcuts = $request->get('shortcut', []);
|
||||
$enabled = $request->input('enabled') === 'true';
|
||||
$providedShortcuts = $request->input('shortcut', []);
|
||||
$shortcuts = new UserShortcutMap($providedShortcuts);
|
||||
|
||||
setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson());
|
||||
@@ -218,7 +218,7 @@ class UserAccountController extends Controller
|
||||
{
|
||||
$this->preventAccessInDemoMode();
|
||||
|
||||
$requestNewOwnerId = intval($request->get('new_owner_id')) ?: null;
|
||||
$requestNewOwnerId = intval($request->input('new_owner_id')) ?: null;
|
||||
$newOwnerId = userCan(Permission::UsersManage) ? $requestNewOwnerId : null;
|
||||
|
||||
$this->userRepo->destroy(user(), $newOwnerId);
|
||||
|
||||
@@ -141,7 +141,7 @@ class UserApiController extends ApiController
|
||||
public function delete(Request $request, string $id)
|
||||
{
|
||||
$user = $this->userRepo->getById($id);
|
||||
$newOwnerId = $request->get('migrate_ownership_id', null);
|
||||
$newOwnerId = $request->input('migrate_ownership_id', null);
|
||||
|
||||
$this->userRepo->destroy($user, $newOwnerId);
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ class UserController extends Controller
|
||||
$this->checkPermission(Permission::UsersManage);
|
||||
|
||||
$authMethod = config('auth.method');
|
||||
$sendInvite = ($request->get('send_invite', 'false') === 'true');
|
||||
$sendInvite = ($request->input('send_invite', 'false') === 'true');
|
||||
$externalAuth = $authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'oidc';
|
||||
$passwordRequired = ($authMethod === 'standard' && !$sendInvite);
|
||||
|
||||
@@ -202,7 +202,7 @@ class UserController extends Controller
|
||||
$this->checkPermission(Permission::UsersManage);
|
||||
|
||||
$user = $this->userRepo->getById($id);
|
||||
$newOwnerId = intval($request->get('new_owner_id')) ?: null;
|
||||
$newOwnerId = intval($request->input('new_owner_id')) ?: null;
|
||||
|
||||
$this->userRepo->destroy($user, $newOwnerId);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class UserPreferencesController extends Controller
|
||||
return $this->redirectToRequest($request);
|
||||
}
|
||||
|
||||
$view = $request->get('view');
|
||||
$view = $request->input('view');
|
||||
if (!in_array($view, ['grid', 'list'])) {
|
||||
$view = 'list';
|
||||
}
|
||||
@@ -44,8 +44,8 @@ class UserPreferencesController extends Controller
|
||||
return $this->redirectToRequest($request);
|
||||
}
|
||||
|
||||
$sort = substr($request->get('sort') ?: 'name', 0, 50);
|
||||
$order = $request->get('order') === 'desc' ? 'desc' : 'asc';
|
||||
$sort = substr($request->input('sort') ?: 'name', 0, 50);
|
||||
$order = $request->input('order') === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$sortKey = $type . '_sort';
|
||||
$orderKey = $type . '_sort_order';
|
||||
@@ -76,7 +76,7 @@ class UserPreferencesController extends Controller
|
||||
return response('Invalid key', 500);
|
||||
}
|
||||
|
||||
$newState = $request->get('expand', 'false');
|
||||
$newState = $request->input('expand', 'false');
|
||||
setting()->putForCurrentUser('section_expansion#' . $type, $newState);
|
||||
|
||||
return response('', 204);
|
||||
|
||||
@@ -26,7 +26,7 @@ class UserSearchController extends Controller
|
||||
$this->showPermissionError();
|
||||
}
|
||||
|
||||
$search = $request->get('search', '');
|
||||
$search = $request->input('search', '');
|
||||
$query = User::query()
|
||||
->orderBy('name', 'asc')
|
||||
->take(20);
|
||||
@@ -58,7 +58,7 @@ class UserSearchController extends Controller
|
||||
$this->showPermissionError();
|
||||
}
|
||||
|
||||
$search = $request->get('search', '');
|
||||
$search = $request->input('search', '');
|
||||
$query = User::query()
|
||||
->orderBy('name', 'asc')
|
||||
->take(20);
|
||||
|
||||
@@ -27,6 +27,7 @@ class HtmlDescriptionFilter
|
||||
'span' => [],
|
||||
'em' => [],
|
||||
'br' => [],
|
||||
'code' => [],
|
||||
];
|
||||
|
||||
public static function filterFromString(string $html): string
|
||||
|
||||
47
app/Util/HtmlToPlainText.php
Normal file
47
app/Util/HtmlToPlainText.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Util;
|
||||
|
||||
class HtmlToPlainText
|
||||
{
|
||||
/**
|
||||
* Inline tags types where the content should not be put on a new line.
|
||||
*/
|
||||
protected array $inlineTags = [
|
||||
'a', 'b', 'i', 'u', 'strong', 'em', 'small', 'sup', 'sub', 'span', 'div',
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert the provided HTML to relatively clean plain text.
|
||||
*/
|
||||
public function convert(string $html): string
|
||||
{
|
||||
$doc = new HtmlDocument($html);
|
||||
$text = $this->nodeToText($doc->getBody());
|
||||
|
||||
// Remove repeated newlines
|
||||
$text = preg_replace('/\n+/', "\n", $text);
|
||||
// Remove leading/trailing whitespace
|
||||
$text = trim($text);
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
protected function nodeToText(\DOMNode $node): string
|
||||
{
|
||||
if ($node->nodeType === XML_TEXT_NODE) {
|
||||
return $node->textContent;
|
||||
}
|
||||
|
||||
$text = '';
|
||||
if (!in_array($node->nodeName, $this->inlineTags)) {
|
||||
$text .= "\n";
|
||||
}
|
||||
|
||||
foreach ($node->childNodes as $childNode) {
|
||||
$text .= $this->nodeToText($childNode);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ class SimpleListOptions
|
||||
*/
|
||||
public static function fromRequest(Request $request, string $typeKey, bool $sortDescDefault = false): self
|
||||
{
|
||||
$search = $request->get('search', '');
|
||||
$search = $request->input('search', '');
|
||||
$sort = setting()->getForCurrentUser($typeKey . '_sort', '');
|
||||
$order = setting()->getForCurrentUser($typeKey . '_sort_order', $sortDescDefault ? 'desc' : 'asc');
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Create new revision-view-all permission
|
||||
$permissionId = DB::table('role_permissions')->insertGetId([
|
||||
'name' => 'revision-view-all',
|
||||
'created_at' => Carbon::now()->toDateTimeString(),
|
||||
'updated_at' => Carbon::now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
// Get ids of page view permissions
|
||||
$pageViewPermissions = DB::table('role_permissions')
|
||||
->whereIn('name', [
|
||||
'page-view-own',
|
||||
'page-view-all',
|
||||
])->get();
|
||||
|
||||
if ($pageViewPermissions->count() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get role ids which have page view permission
|
||||
$applicableRoleIds = DB::table('permission_role')
|
||||
->whereIn('permission_id', $pageViewPermissions->pluck('id'))
|
||||
->pluck('role_id')
|
||||
->unique()
|
||||
->all();
|
||||
|
||||
// Assign the new permission to relevant roles
|
||||
$newPermissionRoles = array_values(array_map(function (int $roleId) use ($permissionId) {
|
||||
return [
|
||||
'role_id' => $roleId,
|
||||
'permission_id' => $permissionId,
|
||||
];
|
||||
}, $applicableRoleIds));
|
||||
|
||||
DB::table('permission_role')->insert($newPermissionRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Get the permission to remove
|
||||
$revisionViewPermission = DB::table('role_permissions')
|
||||
->where('name', '=', 'revision-view-all')
|
||||
->first();
|
||||
|
||||
if (!$revisionViewPermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the permission, and its use on roles, from the database
|
||||
DB::table('permission_role')->where('permission_id', '=', $revisionViewPermission->id)->delete();
|
||||
DB::table('role_permissions')->where('id', '=', $revisionViewPermission->id)->delete();
|
||||
}
|
||||
};
|
||||
1
dev/api/requests/tags-list-values.http
Normal file
1
dev/api/requests/tags-list-values.http
Normal file
@@ -0,0 +1 @@
|
||||
GET /api/tags/values-for-name?name=Category
|
||||
32
dev/api/responses/tags-list-names.json
Normal file
32
dev/api/responses/tags-list-names.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"name": "Category",
|
||||
"values": 8,
|
||||
"usages": 184,
|
||||
"page_count": 3,
|
||||
"chapter_count": 8,
|
||||
"book_count": 171,
|
||||
"shelf_count": 2
|
||||
},
|
||||
{
|
||||
"name": "Review Due",
|
||||
"values": 2,
|
||||
"usages": 2,
|
||||
"page_count": 1,
|
||||
"chapter_count": 0,
|
||||
"book_count": 1,
|
||||
"shelf_count": 0
|
||||
},
|
||||
{
|
||||
"name": "Type",
|
||||
"values": 2,
|
||||
"usages": 2,
|
||||
"page_count": 0,
|
||||
"chapter_count": 1,
|
||||
"book_count": 1,
|
||||
"shelf_count": 0
|
||||
}
|
||||
],
|
||||
"total": 3
|
||||
}
|
||||
32
dev/api/responses/tags-list-values.json
Normal file
32
dev/api/responses/tags-list-values.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"name": "Category",
|
||||
"value": "Cool Stuff",
|
||||
"usages": 3,
|
||||
"page_count": 1,
|
||||
"chapter_count": 0,
|
||||
"book_count": 2,
|
||||
"shelf_count": 0
|
||||
},
|
||||
{
|
||||
"name": "Category",
|
||||
"value": "Top Content",
|
||||
"usages": 168,
|
||||
"page_count": 0,
|
||||
"chapter_count": 3,
|
||||
"book_count": 165,
|
||||
"shelf_count": 0
|
||||
},
|
||||
{
|
||||
"name": "Category",
|
||||
"value": "Learning",
|
||||
"usages": 2,
|
||||
"page_count": 0,
|
||||
"chapter_count": 0,
|
||||
"book_count": 0,
|
||||
"shelf_count": 2
|
||||
}
|
||||
],
|
||||
"total": 3
|
||||
}
|
||||
@@ -207,6 +207,7 @@ return [
|
||||
'role_all' => 'All',
|
||||
'role_own' => 'Own',
|
||||
'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to',
|
||||
'role_controlled_by_page_delete' => 'Controlled by page delete permissions',
|
||||
'role_save' => 'Save Role',
|
||||
'role_users' => 'Users in this role',
|
||||
'role_users_none' => 'No users are currently assigned to this role',
|
||||
|
||||
11
readme.md
11
readme.md
@@ -9,8 +9,9 @@
|
||||
<br>
|
||||
[](https://source.bookstackapp.com/)
|
||||
[](https://gh-stats.bookstackapp.com/)
|
||||
[](https://www.bookstackapp.com/links/discord)
|
||||
[](https://community.bookstackapp.com/)
|
||||
[](https://www.bookstackapp.com/links/mastodon)
|
||||
[](https://www.bookstackapp.com/links/discord)
|
||||
<br>
|
||||
[](https://foss.video/c/bookstack)
|
||||
[](https://www.youtube.com/bookstackapp)
|
||||
@@ -20,11 +21,10 @@ A platform for storing and organising information and documentation. Details for
|
||||
* [Installation Instructions](https://www.bookstackapp.com/docs/admin/installation)
|
||||
* [Documentation](https://www.bookstackapp.com/docs)
|
||||
* [Demo Instance](https://demo.bookstackapp.com)
|
||||
* [Admin Login](https://demo.bookstackapp.com/login?email=admin@example.com&password=password)
|
||||
* [Screenshots](https://www.bookstackapp.com/#screenshots)
|
||||
* [BookStack Blog](https://www.bookstackapp.com/blog)
|
||||
* [Issue List](https://github.com/BookStackApp/BookStack/issues)
|
||||
* [Discord Chat](https://www.bookstackapp.com/links/discord)
|
||||
* [Community Discussions](https://community.bookstackapp.com/)
|
||||
* [Support Options](https://www.bookstackapp.com/support/)
|
||||
|
||||
## 📚 Project Definition
|
||||
@@ -72,7 +72,7 @@ Big thanks to these companies for supporting the project.
|
||||
<td align="center"><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
|
||||
<img width="240" src="https://www.bookstackapp.com/images/sponsors/stellarhosted.png" alt="Stellar Hosted">
|
||||
</a></td>
|
||||
<td align="center" style="text-align: center"><a href="https://nws.netways.de/apps/bookstack/" target="_blank">
|
||||
<td align="center" style="text-align: center"><a href="https://nws.netways.de" target="_blank">
|
||||
<img width="240" src="https://www.bookstackapp.com/images/sponsors/netways.png" alt="NETWAYS Web Services">
|
||||
</a></td>
|
||||
</tr>
|
||||
@@ -124,8 +124,9 @@ Feel free to [create issues](https://github.com/BookStackApp/BookStack/issues/ne
|
||||
Pull requests are welcome but, unless it's a small tweak, it may be best to open the pull request early or create an issue for your intended change to discuss how it will fit into the project and plan out the merge. Just because a feature request exists, or is tagged, does not mean that feature would be accepted into the core project.
|
||||
|
||||
Pull requests should be created from the `development` branch since they will be merged back into `development` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
|
||||
See the [Development & Testing](#-development--testing) section above for further development guidance.
|
||||
|
||||
The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/development/.github/CODE_OF_CONDUCT.md).
|
||||
The project's community rules, including those for raising issues and making code contributions, [can be found here](https://www.bookstackapp.com/about/community-rules/).
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
|
||||
mergeRegister(
|
||||
registerRichText(editor),
|
||||
registerHistory(editor, createEmptyHistoryState(), 300),
|
||||
registerShortcuts(context),
|
||||
registerShortcuts(context, true),
|
||||
registerKeyboardHandling(context),
|
||||
registerMouseHandling(context),
|
||||
registerSelectionHandling(context),
|
||||
@@ -123,7 +123,7 @@ export function createBasicEditorInstance(container: HTMLElement, htmlContent: s
|
||||
const editorTeardown = mergeRegister(
|
||||
registerRichText(editor),
|
||||
registerHistory(editor, createEmptyHistoryState(), 300),
|
||||
registerShortcuts(context),
|
||||
registerShortcuts(context, false),
|
||||
registerAutoLinks(editor),
|
||||
);
|
||||
|
||||
@@ -157,7 +157,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
|
||||
const editorTeardown = mergeRegister(
|
||||
registerRichText(editor),
|
||||
registerHistory(editor, createEmptyHistoryState(), 300),
|
||||
registerShortcuts(context),
|
||||
registerShortcuts(context, false),
|
||||
registerAutoLinks(editor),
|
||||
registerMentions(context),
|
||||
);
|
||||
|
||||
@@ -38,29 +38,9 @@ type ShortcutAction = (editor: LexicalEditor, context: EditorUiContext) => boole
|
||||
* List of action functions by their shortcut combo.
|
||||
* We use "meta" as an abstraction for ctrl/cmd depending on platform.
|
||||
*/
|
||||
const actionsByKeys: Record<string, ShortcutAction> = {
|
||||
'meta+s': () => {
|
||||
window.$events.emit('editor-save-draft');
|
||||
return true;
|
||||
},
|
||||
'meta+enter': () => {
|
||||
window.$events.emit('editor-save-page');
|
||||
return true;
|
||||
},
|
||||
'meta+1': (editor, context) => headerHandler(context, 'h2'),
|
||||
'meta+2': (editor, context) => headerHandler(context, 'h3'),
|
||||
'meta+3': (editor, context) => headerHandler(context, 'h4'),
|
||||
'meta+4': (editor, context) => headerHandler(context, 'h5'),
|
||||
'meta+5': wrapFormatAction(toggleSelectionAsParagraph),
|
||||
'meta+d': wrapFormatAction(toggleSelectionAsParagraph),
|
||||
'meta+6': wrapFormatAction(toggleSelectionAsBlockquote),
|
||||
'meta+q': wrapFormatAction(toggleSelectionAsBlockquote),
|
||||
'meta+7': wrapFormatAction(formatCodeBlock),
|
||||
'meta+e': wrapFormatAction(formatCodeBlock),
|
||||
const baseActionsByKeys: Record<string, ShortcutAction> = {
|
||||
'meta+8': toggleInlineCode,
|
||||
'meta+shift+e': toggleInlineCode,
|
||||
'meta+9': wrapFormatAction(cycleSelectionCalloutFormats),
|
||||
|
||||
'meta+o': wrapFormatAction((e) => toggleSelectionAsList(e, 'number')),
|
||||
'meta+p': wrapFormatAction((e) => toggleSelectionAsList(e, 'bullet')),
|
||||
'meta+k': (editor, context) => {
|
||||
@@ -87,30 +67,105 @@ const actionsByKeys: Record<string, ShortcutAction> = {
|
||||
},
|
||||
};
|
||||
|
||||
function createKeyDownListener(context: EditorUiContext): (e: KeyboardEvent) => void {
|
||||
/**
|
||||
* An extended set of the above, used for fuller-featured editors with heavier block-level formatting.
|
||||
*/
|
||||
const extendedActionsByKeys: Record<string, ShortcutAction> = {
|
||||
...baseActionsByKeys,
|
||||
'meta+s': () => {
|
||||
window.$events.emit('editor-save-draft');
|
||||
return true;
|
||||
},
|
||||
'meta+enter': () => {
|
||||
window.$events.emit('editor-save-page');
|
||||
return true;
|
||||
},
|
||||
'meta+1': (editor, context) => headerHandler(context, 'h2'),
|
||||
'meta+2': (editor, context) => headerHandler(context, 'h3'),
|
||||
'meta+3': (editor, context) => headerHandler(context, 'h4'),
|
||||
'meta+4': (editor, context) => headerHandler(context, 'h5'),
|
||||
'meta+5': wrapFormatAction(toggleSelectionAsParagraph),
|
||||
'meta+d': wrapFormatAction(toggleSelectionAsParagraph),
|
||||
'meta+6': wrapFormatAction(toggleSelectionAsBlockquote),
|
||||
'meta+7': wrapFormatAction(formatCodeBlock),
|
||||
'meta+e': wrapFormatAction(formatCodeBlock),
|
||||
'meta+q': wrapFormatAction(toggleSelectionAsBlockquote),
|
||||
'meta+9': wrapFormatAction(cycleSelectionCalloutFormats),
|
||||
};
|
||||
|
||||
function createKeyDownListener(context: EditorUiContext, useExtended: boolean): (e: KeyboardEvent) => void {
|
||||
const baseKeySetToUse = useExtended ? extendedActionsByKeys : baseActionsByKeys;
|
||||
const keySetToUse = extendKeySetWithKeyCodes(baseKeySetToUse);
|
||||
return (event: KeyboardEvent) => {
|
||||
const combo = keyboardEventToKeyComboString(event);
|
||||
// console.log(`pressed: ${combo}`);
|
||||
if (actionsByKeys[combo]) {
|
||||
const handled = actionsByKeys[combo](context.editor, context);
|
||||
if (handled) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const comboStrings = keyboardEventToKeyComboStrings(event);
|
||||
// console.log(comboStrings, event, keySetToUse);
|
||||
for (const combo of comboStrings) {
|
||||
if (keySetToUse[combo]) {
|
||||
const handled = keySetToUse[combo](context.editor, context);
|
||||
if (handled) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function keyboardEventToKeyComboString(event: KeyboardEvent): string {
|
||||
/**
|
||||
* Takes a shortcut key set and returns a new set with added variations of shortcts where
|
||||
* they can be sensibly represented as their key code instead of just key, which we can use
|
||||
* for matching in scenarios where the physical key may be represented of the letter used
|
||||
* in the shortcut, but produces a different 'key' value.
|
||||
* Useful for Cyrillic scenarios where the keyboard key would show a latin character
|
||||
* as an option, and therefore be expected for use for the relevant latin shortcut, but the main
|
||||
* key output is a Cyrillic character.
|
||||
*/
|
||||
function extendKeySetWithKeyCodes(keySet: Record<string, ShortcutAction>): Record<string, ShortcutAction> {
|
||||
const newKeys: Record<string, ShortcutAction> = {};
|
||||
|
||||
const setKeys = Object.keys(keySet);
|
||||
for (const keyCombo of setKeys) {
|
||||
const action = keySet[keyCombo];
|
||||
newKeys[keyCombo] = action;
|
||||
|
||||
const comboParts = keyCombo.split('+');
|
||||
const lastComboPart = comboParts.pop() || '';
|
||||
if (lastComboPart.match(/^[a-zA-Z]$/)) {
|
||||
const keyCode = lastComboPart.toUpperCase().charCodeAt(0);
|
||||
comboParts.push(String(keyCode));
|
||||
const newCombo = comboParts.join('+');
|
||||
newKeys[newCombo] = action;
|
||||
}
|
||||
}
|
||||
|
||||
return newKeys;
|
||||
}
|
||||
|
||||
function keyboardEventToKeyComboStrings(event: KeyboardEvent): string[] {
|
||||
const metaKeyPressed = isMac() ? event.metaKey : event.ctrlKey;
|
||||
|
||||
const parts = [
|
||||
const mainParts = [
|
||||
metaKeyPressed ? 'meta' : '',
|
||||
event.shiftKey ? 'shift' : '',
|
||||
event.key,
|
||||
];
|
||||
|
||||
return parts.filter(Boolean).join('+').toLowerCase();
|
||||
const toReturn = [
|
||||
mainParts.filter(Boolean).join('+').toLowerCase(),
|
||||
];
|
||||
|
||||
// If ending with a standard latin character, provide an alternative
|
||||
// keyCode based option for scenarios of dual-language keyboard use.
|
||||
const keyCode = event.keyCode || 0;
|
||||
if (keyCode >= 65 && keyCode <= 90) {
|
||||
const keyCodeParts = [...mainParts];
|
||||
keyCodeParts.pop();
|
||||
keyCodeParts.push(String(keyCode));
|
||||
toReturn.push(keyCodeParts.filter(Boolean).join('+').toLowerCase());
|
||||
}
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
function isMac(): boolean {
|
||||
@@ -127,8 +182,8 @@ function overrideDefaultCommands(editor: LexicalEditor) {
|
||||
}, COMMAND_PRIORITY_HIGH);
|
||||
}
|
||||
|
||||
export function registerShortcuts(context: EditorUiContext) {
|
||||
const listener = createKeyDownListener(context);
|
||||
export function registerShortcuts(context: EditorUiContext, useExtended: boolean) {
|
||||
const listener = createKeyDownListener(context, useExtended);
|
||||
overrideDefaultCommands(context.editor);
|
||||
|
||||
return context.editor.registerRootListener((rootElement: null | HTMLElement, prevRootElement: null | HTMLElement) => {
|
||||
|
||||
@@ -227,6 +227,7 @@ export function getBasicEditorToolbar(context: EditorUiContext): EditorContainer
|
||||
new EditorButton(bold),
|
||||
new EditorButton(italic),
|
||||
new EditorButton(link),
|
||||
new EditorButton(code),
|
||||
new EditorButton(bulletList),
|
||||
new EditorButton(numberList),
|
||||
])
|
||||
|
||||
@@ -12,12 +12,16 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'DejaVu Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Set fonts to common system fonts, starting with DejaVu Sans due to support in DOMPDF
|
||||
body, h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'DejaVu Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
table {
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
@@ -100,4 +104,4 @@ body.export-format-pdf.export-engine-dompdf {
|
||||
.page-content td a > img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="flex-container-row items-center gap-m">
|
||||
<span class="api-method text-mono" data-method="{{ $endpoint['method'] }}">{{ $endpoint['method'] }}</span>
|
||||
<h5 id="{{ $endpoint['name'] }}" class="text-mono pb-xs">
|
||||
@if($endpoint['controller_method_kebab'] === 'list')
|
||||
@if(str_starts_with($endpoint['controller_method_kebab'], 'list') && !str_contains($endpoint['uri'], '{'))
|
||||
<a style="color: inherit;" target="_blank" rel="noopener" href="{{ url($endpoint['uri']) }}">{{ url($endpoint['uri']) }}</a>
|
||||
@else
|
||||
<span>{{ url($endpoint['uri']) }}</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<p class="mb-none">
|
||||
This documentation covers use of the REST API. <br>
|
||||
Examples of API usage, in a variety of programming languages, can be found in the <a href="https://codeberg.org/bookstack/api-scripts" target="_blank" rel="noopener noreferrer">BookStack api-scripts repo on GitHub</a>.
|
||||
Examples of API usage, in a variety of programming languages, can be found in the <a href="https://codeberg.org/bookstack/api-scripts" target="_blank" rel="noopener noreferrer">BookStack api-scripts repo on Codeberg</a>.
|
||||
|
||||
<br> <br>
|
||||
Some alternative options for extension and customization can be found below:
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($entity->isA('page'))
|
||||
@if ($entity->isA('page') && userCan(\BookStack\Permissions\Permission::RevisionViewAll))
|
||||
<a href="{{ $entity->getUrl('/revisions') }}" class="entity-meta-item">
|
||||
@icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }}
|
||||
</a>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="entity-meta">
|
||||
@if ($entity->isA('page'))
|
||||
@if ($entity->isA('page') && userCan(\BookStack\Permissions\Permission::RevisionViewAll))
|
||||
@icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }} <br>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -24,10 +24,12 @@
|
||||
</a>
|
||||
@endif
|
||||
@endif
|
||||
<a href="{{ $page->getUrl('/revisions') }}" data-shortcut="revisions" class="icon-list-item">
|
||||
<span>@icon('history')</span>
|
||||
<span>{{ trans('entities.revisions') }}</span>
|
||||
</a>
|
||||
@if(userCan(\BookStack\Permissions\Permission::RevisionViewAll))
|
||||
<a href="{{ $page->getUrl('/revisions') }}" data-shortcut="revisions" class="icon-list-item">
|
||||
<span>@icon('history')</span>
|
||||
<span>{{ trans('entities.revisions') }}</span>
|
||||
</a>
|
||||
@endif
|
||||
@if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $page))
|
||||
<a href="{{ $page->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
|
||||
<span>@icon('lock')</span>
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
@include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.books'), 'permissionPrefix' => 'book'])
|
||||
@include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.chapters'), 'permissionPrefix' => 'chapter'])
|
||||
@include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.pages'), 'permissionPrefix' => 'page'])
|
||||
@include('settings.roles.parts.revisions-permissions-row', ['title' => trans('entities.revisions'), 'permissionPrefix' => 'revision'])
|
||||
@include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.images'), 'permissionPrefix' => 'image'])
|
||||
@include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.attachments'), 'permissionPrefix' => 'attachment'])
|
||||
@include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.comments'), 'permissionPrefix' => 'comment'])
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<div class="item-list-row flex-container-row items-center wrap">
|
||||
<div class="flex py-s px-m min-width-s">
|
||||
<strong>{{ $title }}</strong> <br>
|
||||
<a href="#" refs="permissions-table@toggle-row" class="text-small text-link">{{ trans('common.toggle_all') }}</a>
|
||||
</div>
|
||||
<div class="flex py-s px-m min-width-xxs">
|
||||
<small class="hide-over-m bold">{{ trans('common.create') }}<br></small>
|
||||
<strong class="text-muted opacity-70 text-large">-</strong>
|
||||
</div>
|
||||
<div class="flex py-s px-m min-width-xxs">
|
||||
<small class="hide-over-m bold">{{ trans('common.view') }}<br></small>
|
||||
@include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all')])
|
||||
</div>
|
||||
<div class="flex py-s px-m min-width-xxs">
|
||||
<small class="hide-over-m bold">{{ trans('common.edit') }}<br></small>
|
||||
<strong class="text-muted opacity-70 text-large">-</strong>
|
||||
</div>
|
||||
<div class="flex py-s px-m min-width-xxs">
|
||||
<small class="hide-over-m bold">{{ trans('common.delete') }}<br></small>
|
||||
<small>{{ trans('settings.role_controlled_by_page_delete') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
use BookStack\Activity\Controllers as ActivityControllers;
|
||||
use BookStack\Activity\Controllers\TagApiController;
|
||||
use BookStack\Api\ApiDocsController;
|
||||
use BookStack\App\SystemApiController;
|
||||
use BookStack\Entities\Controllers as EntityControllers;
|
||||
@@ -109,6 +110,9 @@ Route::get('search', [SearchApiController::class, 'all']);
|
||||
|
||||
Route::get('system', [SystemApiController::class, 'read']);
|
||||
|
||||
Route::get('tags/names', [TagApiController::class, 'listNames']);
|
||||
Route::get('tags/values-for-name', [TagApiController::class, 'listValues']);
|
||||
|
||||
Route::get('users', [UserApiController::class, 'list']);
|
||||
Route::post('users', [UserApiController::class, 'create']);
|
||||
Route::get('users/{id}', [UserApiController::class, 'read']);
|
||||
|
||||
6
storage/fonts/.gitignore
vendored
6
storage/fonts/.gitignore
vendored
@@ -1,2 +1,6 @@
|
||||
# Font cache files have once been stored directly in this folder
|
||||
# therefore its important the contents non-ignored by git
|
||||
# are chosen selectively
|
||||
*
|
||||
!.gitignore
|
||||
!.gitignore
|
||||
!dompdf/
|
||||
3
storage/fonts/dompdf/.gitignore
vendored
Normal file
3
storage/fonts/dompdf/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!cache/
|
||||
2
storage/fonts/dompdf/cache/.gitignore
vendored
Normal file
2
storage/fonts/dompdf/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
109
tests/Api/TagsApiTest.php
Normal file
109
tests/Api/TagsApiTest.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Api;
|
||||
|
||||
use BookStack\Activity\Models\Tag;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Tests\Api\TestsApi;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TagsApiTest extends TestCase
|
||||
{
|
||||
use TestsApi;
|
||||
|
||||
public function test_list_names_provides_rolled_up_tag_info(): void
|
||||
{
|
||||
$tagInfo = ['name' => 'MyGreatApiTag', 'value' => 'cat'];
|
||||
$pagesToTag = Page::query()->take(10)->get();
|
||||
$booksToTag = Book::query()->take(3)->get();
|
||||
$chaptersToTag = Chapter::query()->take(5)->get();
|
||||
$pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag($tagInfo)));
|
||||
$booksToTag->each(fn (Book $book) => $book->tags()->save(new Tag($tagInfo)));
|
||||
$chaptersToTag->each(fn (Chapter $chapter) => $chapter->tags()->save(new Tag($tagInfo)));
|
||||
|
||||
$resp = $this->actingAsApiEditor()->getJson('api/tags/names?filter[name]=MyGreatApiTag');
|
||||
$resp->assertStatus(200);
|
||||
$resp->assertJson([
|
||||
'data' => [
|
||||
[
|
||||
'name' => 'MyGreatApiTag',
|
||||
'values' => 1,
|
||||
'usages' => 18,
|
||||
'page_count' => 10,
|
||||
'book_count' => 3,
|
||||
'chapter_count' => 5,
|
||||
'shelf_count' => 0,
|
||||
]
|
||||
],
|
||||
'total' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_list_names_is_limited_by_permission_visibility(): void
|
||||
{
|
||||
$pagesToTag = Page::query()->take(10)->get();
|
||||
$pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyGreatApiTag', 'value' => 'cat' . $page->id])));
|
||||
|
||||
$this->permissions->disableEntityInheritedPermissions($pagesToTag[3]);
|
||||
$this->permissions->disableEntityInheritedPermissions($pagesToTag[6]);
|
||||
|
||||
$resp = $this->actingAsApiEditor()->getJson('api/tags/names?filter[name]=MyGreatApiTag');
|
||||
$resp->assertStatus(200);
|
||||
$resp->assertJson([
|
||||
'data' => [
|
||||
[
|
||||
'name' => 'MyGreatApiTag',
|
||||
'values' => 8,
|
||||
'usages' => 8,
|
||||
'page_count' => 8,
|
||||
'book_count' => 0,
|
||||
'chapter_count' => 0,
|
||||
'shelf_count' => 0,
|
||||
]
|
||||
],
|
||||
'total' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_list_values_returns_values_for_set_tag()
|
||||
{
|
||||
$pagesToTag = Page::query()->take(10)->get();
|
||||
$booksToTag = Book::query()->take(3)->get();
|
||||
$chaptersToTag = Chapter::query()->take(5)->get();
|
||||
$pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-page' . $page->id])));
|
||||
$booksToTag->each(fn (Book $book) => $book->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-book' . $book->id])));
|
||||
$chaptersToTag->each(fn (Chapter $chapter) => $chapter->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-chapter' . $chapter->id])));
|
||||
|
||||
$resp = $this->actingAsApiEditor()->getJson('api/tags/values-for-name?name=MyValueApiTag');
|
||||
|
||||
$resp->assertStatus(200);
|
||||
$resp->assertJson(['total' => 18]);
|
||||
$resp->assertJsonFragment([
|
||||
[
|
||||
'name' => 'MyValueApiTag',
|
||||
'value' => 'tag-page' . $pagesToTag[0]->id,
|
||||
'usages' => 1,
|
||||
'page_count' => 1,
|
||||
'book_count' => 0,
|
||||
'chapter_count' => 0,
|
||||
'shelf_count' => 0,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_list_values_is_limited_by_permission_visibility(): void
|
||||
{
|
||||
$pagesToTag = Page::query()->take(10)->get();
|
||||
$pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyGreatApiTag', 'value' => 'cat' . $page->id])));
|
||||
|
||||
$this->permissions->disableEntityInheritedPermissions($pagesToTag[3]);
|
||||
$this->permissions->disableEntityInheritedPermissions($pagesToTag[6]);
|
||||
|
||||
$resp = $this->actingAsApiEditor()->getJson('api/tags/values-for-name?name=MyGreatApiTag');
|
||||
$resp->assertStatus(200);
|
||||
$resp->assertJson(['total' => 8]);
|
||||
$resp->assertJsonMissing(['value' => 'cat' . $pagesToTag[3]->id]);
|
||||
}
|
||||
}
|
||||
@@ -256,8 +256,8 @@ class BookTest extends TestCase
|
||||
{
|
||||
$book = $this->entities->book();
|
||||
|
||||
$input = '<h1>Test</h1><p id="abc" href="beans">Content<a href="#cat" target="_blank" data-a="b">a</a><section>Hello</section></p>';
|
||||
$expected = '<p>Content<a href="#cat" target="_blank">a</a></p>';
|
||||
$input = '<h1>Test</h1><p id="abc" href="beans">Content<a href="#cat" target="_blank" data-a="b">a</a><section>Hello</section><code id="abc">code</code></p>';
|
||||
$expected = '<p>Content<a href="#cat" target="_blank">a</a><code>code</code></p>';
|
||||
|
||||
$this->asEditor()->put($book->getUrl(), [
|
||||
'name' => $book->name,
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace Tests\Entity;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Models\PageRevision;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PageRevisionTest extends TestCase
|
||||
@@ -257,6 +259,33 @@ class PageRevisionTest extends TestCase
|
||||
$revisionView->assertDontSee('dontwantthishere');
|
||||
}
|
||||
|
||||
public function test_access_to_revision_operation_requires_revision_view_all_permission()
|
||||
{
|
||||
$editor = $this->users->editor();
|
||||
$this->actingAs($editor);
|
||||
|
||||
$page = $this->entities->page();
|
||||
$this->createRevisions($page, 3);
|
||||
/** @var PageRevision $revision */
|
||||
$revision = $page->revisions()->orderBy('id', 'desc')->first();
|
||||
|
||||
$this->get($page->getUrl())->assertSee($page->getUrl('/revisions'), false);
|
||||
$this->get($page->getUrl('/revisions'))->assertOk();
|
||||
$this->get($revision->getUrl())->assertOk();
|
||||
$this->get($revision->getUrl('/changes'))->assertOk();
|
||||
$this->put($revision->getUrl('/restore'))->assertRedirect($page->getUrl());
|
||||
$this->delete($revision->getUrl('/delete'))->assertRedirect($page->getUrl('/revisions'));
|
||||
|
||||
$this->permissions->removeUserRolePermissions($editor, [Permission::RevisionViewAll]);
|
||||
|
||||
$this->get($page->getUrl())->assertDontSee($page->getUrl('/revisions'), false);
|
||||
$this->assertPermissionError($this->get($page->getUrl('/revisions')));
|
||||
$this->assertPermissionError($this->get($revision->getUrl()));
|
||||
$this->assertPermissionError($this->get($revision->getUrl('/changes')));
|
||||
$this->assertPermissionError($this->put($revision->getUrl('/restore')));
|
||||
$this->assertPermissionError($this->delete($revision->getUrl('/delete')));
|
||||
}
|
||||
|
||||
public function test_revision_restore_action_only_visible_with_permission()
|
||||
{
|
||||
$page = $this->entities->page();
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Exports;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -229,6 +230,20 @@ class HtmlExportTest extends TestCase
|
||||
$resp->assertDontSee('ExportWizardTheFifth');
|
||||
}
|
||||
|
||||
public function test_page_export_only_includes_revision_count_if_user_has_revision_view_permissions()
|
||||
{
|
||||
$editor = $this->users->editor();
|
||||
$page = $this->entities->page();
|
||||
|
||||
$resp = $this->actingAs($editor)->get($page->getUrl('/export/html'));
|
||||
$resp->assertSee('Revision #');
|
||||
|
||||
$this->permissions->removeUserRolePermissions($editor, [Permission::RevisionViewAll]);
|
||||
|
||||
$resp = $this->actingAs($editor)->get($page->getUrl('/export/html'));
|
||||
$resp->assertDontSee('Revision #');
|
||||
}
|
||||
|
||||
public function test_html_exports_contain_csp_meta_tag()
|
||||
{
|
||||
$entities = [
|
||||
|
||||
@@ -79,6 +79,39 @@ class PdfExportTest extends TestCase
|
||||
$this->assertStringContainsString('<details open="open"', $pdfHtml);
|
||||
}
|
||||
|
||||
public function test_custom_fonts_loaded_for_dom_pdf_when_used()
|
||||
{
|
||||
// Set up custom font usage
|
||||
$page = $this->entities->page()->forceFill([
|
||||
'html' => '<p><strong>Bold</strong>text</p>',
|
||||
]);
|
||||
$page->save();
|
||||
$this->setSettings([
|
||||
'app-custom-head' => '<style>* { font-family: "meow words"}</style>'
|
||||
]);
|
||||
$normalFont = $this->files->testFilePath('fonts/Cardiff.ttf');
|
||||
$normalFontTarget = storage_path('fonts/dompdf/MeowWords.ttf');
|
||||
$boldFont = $this->files->testFilePath('fonts/Cardiff-Bold.ttf');
|
||||
$boldFontTarget = storage_path('fonts/dompdf/MeowWords-Bold.ttf');
|
||||
copy($normalFont, $normalFontTarget);
|
||||
copy($boldFont, $boldFontTarget);
|
||||
|
||||
$resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
|
||||
$resp->assertStatus(200);
|
||||
|
||||
// Existance of UFM files indicates the metrics have been generated
|
||||
$this->assertFileExists(storage_path('fonts/dompdf/MeowWords.ufm'));
|
||||
$this->assertFileExists(storage_path('fonts/dompdf/MeowWords-Bold.ufm'));
|
||||
// Existence of cache json files indicates the fonts have been used
|
||||
$this->assertFileExists(storage_path('fonts/dompdf/cache/MeowWords.ufm.json'));
|
||||
$this->assertFileExists(storage_path('fonts/dompdf/cache/MeowWords-Bold.ufm.json'));
|
||||
|
||||
$filesToCleanUp = [...glob(storage_path('fonts/dompdf/Meow*')), ...glob(storage_path('fonts/dompdf/cache/Meow*'))];
|
||||
foreach ($filesToCleanUp as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true()
|
||||
{
|
||||
$page = $this->entities->page();
|
||||
|
||||
@@ -52,7 +52,7 @@ class TextExportTest extends TestCase
|
||||
$resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext'));
|
||||
|
||||
$expected = "Export Book\nThis is a book with stuff to export\n\nExport chapter\nA test chapter to be exported\nIt has loads of info within\n\n";
|
||||
$expected .= "My wonderful page!\nMy great page Full of great stuff";
|
||||
$expected .= "My wonderful page!\nMy great page\nFull of great stuff";
|
||||
$resp->assertSee($expected);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ class TextExportTest extends TestCase
|
||||
$resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext'));
|
||||
|
||||
$expected = "Export chapter\nA test chapter to be exported\nIt has loads of info within\n\n";
|
||||
$expected .= "My wonderful page!\nMy great page Full of great stuff";
|
||||
$expected .= "My wonderful page!\nMy great page\nFull of great stuff";
|
||||
$resp->assertSee($expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,4 +90,29 @@ class ZipExportValidatorTest extends TestCase
|
||||
|
||||
$this->assertEquals('The file needs to reference a file of type image/png,image/jpeg,image/gif,image/webp, found text/plain.', $results['page.images.0.file']);
|
||||
}
|
||||
|
||||
public function test_page_link_attachments_cant_be_data_or_js()
|
||||
{
|
||||
$validateResultCountByLink = [
|
||||
'data:text/html,<p>hi</p>' => 1,
|
||||
'javascript:alert(\'hi\')' => 1,
|
||||
'mailto:email@example.com' => 0,
|
||||
];
|
||||
|
||||
foreach ($validateResultCountByLink as $link => $count) {
|
||||
$validator = $this->getValidatorForData([
|
||||
'page' => [
|
||||
'id' => 4,
|
||||
'name' => 'My page',
|
||||
'markdown' => 'hello',
|
||||
'attachments' => [
|
||||
['id' => 4, 'name' => 'Attachment A', 'link' => $link],
|
||||
],
|
||||
]
|
||||
]);
|
||||
|
||||
$results = $validator->validate();
|
||||
$this->assertCount($count, $results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
63
tests/Util/HtmlToPlainTextTest.php
Normal file
63
tests/Util/HtmlToPlainTextTest.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Util;
|
||||
|
||||
use BookStack\Util\HtmlToPlainText;
|
||||
use Tests\TestCase;
|
||||
|
||||
class HtmlToPlainTextTest extends TestCase
|
||||
{
|
||||
public function test_it_converts_html_to_plain_text()
|
||||
{
|
||||
$html = <<<HTML
|
||||
<p>This is a test</p>
|
||||
<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
</ul>
|
||||
<h2>A Header</h2>
|
||||
<p>more <©> text <strong>with bold</strong></p>
|
||||
HTML;
|
||||
$expected = <<<TEXT
|
||||
This is a test
|
||||
Item 1
|
||||
Item 2
|
||||
A Header
|
||||
more <©> text with bold
|
||||
TEXT;
|
||||
|
||||
$this->runTest($html, $expected);
|
||||
}
|
||||
|
||||
public function test_adjacent_list_items_are_separated_by_newline()
|
||||
{
|
||||
$html = <<<HTML
|
||||
<ul><li>Item A</li><li>Item B</li></ul>
|
||||
HTML;
|
||||
$expected = <<<TEXT
|
||||
Item A
|
||||
Item B
|
||||
TEXT;
|
||||
|
||||
$this->runTest($html, $expected);
|
||||
}
|
||||
|
||||
public function test_inline_formats_dont_cause_newlines()
|
||||
{
|
||||
$html = <<<HTML
|
||||
<p><strong>H</strong><a>e</a><sup>l</sup><span>l</span><em>o</em></p>
|
||||
HTML;
|
||||
$expected = <<<TEXT
|
||||
Hello
|
||||
TEXT;
|
||||
|
||||
$this->runTest($html, $expected);
|
||||
}
|
||||
|
||||
protected function runTest(string $html, string $expected): void
|
||||
{
|
||||
$converter = new HtmlToPlainText();
|
||||
$result = $converter->convert(trim($html));
|
||||
$this->assertEquals(trim($expected), $result);
|
||||
}
|
||||
}
|
||||
BIN
tests/test-data/fonts/Cardiff-Bold.ttf
Normal file
BIN
tests/test-data/fonts/Cardiff-Bold.ttf
Normal file
Binary file not shown.
BIN
tests/test-data/fonts/Cardiff.ttf
Normal file
BIN
tests/test-data/fonts/Cardiff.ttf
Normal file
Binary file not shown.
2
tests/test-data/fonts/attribution.txt
Normal file
2
tests/test-data/fonts/attribution.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Font files by Roger White, in public domain.
|
||||
https://web.archive.org/web/20110609213636/http://www.rogersfonts.org.uk/
|
||||
Reference in New Issue
Block a user