$userCn = $this->getUserResponseProperty($user, 'cn', null);
return [
'uid' => $this->getUserResponseProperty($user, 'uid', $user['dn']),
- 'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
+ 'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'dn' => $user['dn'],
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
];
/**
* @param Authenticatable $user
- * @param string $username
- * @param string $password
+ * @param string $username
+ * @param string $password
* @return bool
* @throws LdapException
*/
throw new LdapException(trans('errors.ldap_extension_not_installed'));
}
- // Get port from server string and protocol if specified.
- $ldapServer = explode(':', $this->config['server']);
- $hasProtocol = preg_match('/^ldaps{0,1}\:\/\//', $this->config['server']) === 1;
- if (!$hasProtocol) {
- array_unshift($ldapServer, '');
- }
- $hostName = $ldapServer[0] . ($hasProtocol?':':'') . $ldapServer[1];
- $defaultPort = $ldapServer[0] === 'ldaps' ? 636 : 389;
-
- /*
- * Check if TLS_INSECURE is set. The handle is set to NULL due to the nature of
- * the LDAP_OPT_X_TLS_REQUIRE_CERT option. It can only be set globally and not
- * per handle.
- */
+ // Check if TLS_INSECURE is set. The handle is set to NULL due to the nature of
+ // the LDAP_OPT_X_TLS_REQUIRE_CERT option. It can only be set globally and not per handle.
if ($this->config['tls_insecure']) {
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
}
- $ldapConnection = $this->ldap->connect($hostName, count($ldapServer) > 2 ? intval($ldapServer[2]) : $defaultPort);
+ $serverDetails = $this->parseServerString($this->config['server']);
+ $ldapConnection = $this->ldap->connect($serverDetails['host'], $serverDetails['port']);
if ($ldapConnection === false) {
throw new LdapException(trans('errors.ldap_cannot_connect'));
return $this->ldapConnection;
}
+ /**
+ * Parse a LDAP server string and return the host and port for
+ * a connection. Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'
+ * @param $serverString
+ * @return array
+ */
+ protected function parseServerString($serverString)
+ {
+ $serverNameParts = explode(':', $serverString);
+
+ // If we have a protocol just return the full string since PHP will ignore a separate port.
+ if ($serverNameParts[0] === 'ldaps' || $serverNameParts[0] === 'ldap') {
+ return ['host' => $serverString, 'port' => 389];
+ }
+
+ // Otherwise, extract the port out
+ $hostName = $serverNameParts[0];
+ $ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
+ return ['host' => $hostName, 'port' => $ldapPort];
+ }
+
/**
* Build a filter string by injecting common variables.
* @param string $filterString
$count = 0;
if (isset($userGroupSearchResponse[$groupsAttr]['count'])) {
- $count = (int) $userGroupSearchResponse[$groupsAttr]['count'];
+ $count = (int)$userGroupSearchResponse[$groupsAttr]['count'];
}
- for ($i=0; $i<$count; $i++) {
+ for ($i = 0; $i < $count; $i++) {
$dnComponents = $this->ldap->explodeDn($userGroupSearchResponse[$groupsAttr][$i], 1);
if (!in_array($dnComponents[0], $ldapGroups)) {
$ldapGroups[] = $dnComponents[0];
use BookStack\Entities\PageRevision;
use Carbon\Carbon;
use DOMDocument;
+use DOMElement;
use DOMXPath;
class PageRepo extends EntityRepo
}
/**
- * Formats a page's html to be tagged correctly
- * within the system.
+ * Formats a page's html to be tagged correctly within the system.
* @param string $htmlText
* @return string
*/
if ($htmlText == '') {
return $htmlText;
}
+
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
$body = $container->childNodes->item(0);
$childNodes = $body->childNodes;
- // Ensure no duplicate ids are used
- $idArray = [];
-
+ // Set ids on top-level nodes
+ $idMap = [];
foreach ($childNodes as $index => $childNode) {
- /** @var \DOMElement $childNode */
- if (get_class($childNode) !== 'DOMElement') {
- continue;
- }
-
- // Overwrite id if not a BookStack custom id
- if ($childNode->hasAttribute('id')) {
- $id = $childNode->getAttribute('id');
- if (strpos($id, 'bkmrk') === 0 && array_search($id, $idArray) === false) {
- $idArray[] = $id;
- continue;
- };
- }
-
- // Create an unique id for the element
- // Uses the content as a basis to ensure output is the same every time
- // the same content is passed through.
- $contentId = 'bkmrk-' . substr(strtolower(preg_replace('/\s+/', '-', trim($childNode->nodeValue))), 0, 20);
- $newId = urlencode($contentId);
- $loopIndex = 0;
- while (in_array($newId, $idArray)) {
- $newId = urlencode($contentId . '-' . $loopIndex);
- $loopIndex++;
- }
+ $this->setUniqueId($childNode, $idMap);
+ }
- $childNode->setAttribute('id', $newId);
- $idArray[] = $newId;
+ // Ensure no duplicate ids within child items
+ $xPath = new DOMXPath($doc);
+ $idElems = $xPath->query('//body//*//*[@id]');
+ foreach ($idElems as $domElem) {
+ $this->setUniqueId($domElem, $idMap);
}
// Generate inner html as a string
return $html;
}
+ /**
+ * Set a unique id on the given DOMElement.
+ * A map for existing ID's should be passed in to check for current existence.
+ * @param DOMElement $element
+ * @param array $idMap
+ */
+ protected function setUniqueId($element, array &$idMap)
+ {
+ if (get_class($element) !== 'DOMElement') {
+ return;
+ }
+
+ // Overwrite id if not a BookStack custom id
+ $existingId = $element->getAttribute('id');
+ if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
+ $idMap[$existingId] = true;
+ return;
+ }
+
+ // Create an unique id for the element
+ // Uses the content as a basis to ensure output is the same every time
+ // the same content is passed through.
+ $contentId = 'bkmrk-' . substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
+ $newId = urlencode($contentId);
+ $loopIndex = 0;
+
+ while (isset($idMap[$newId])) {
+ $newId = urlencode($contentId . '-' . $loopIndex);
+ $loopIndex++;
+ }
+
+ $element->setAttribute('id', $newId);
+ $idMap[$newId] = true;
+ }
+
/**
* Get the plain text version of a page's content.
* @param \BookStack\Entities\Page $page
let action = button.getAttribute('data-action');
if (action === 'insertImage') this.actionInsertImage();
if (action === 'insertLink') this.actionShowLinkSelector();
- if (action === 'insertDrawing' && event.ctrlKey) {
+ if (action === 'insertDrawing' && (event.ctrlKey || event.metaKey)) {
this.actionShowImageManager();
return;
}
import 'codemirror/mode/go/go';
import 'codemirror/mode/htmlmixed/htmlmixed';
import 'codemirror/mode/javascript/javascript';
+import 'codemirror/mode/julia/julia';
import 'codemirror/mode/lua/lua';
+import 'codemirror/mode/haskell/haskell';
import 'codemirror/mode/markdown/markdown';
+import 'codemirror/mode/mllike/mllike';
import 'codemirror/mode/nginx/nginx';
import 'codemirror/mode/php/php';
import 'codemirror/mode/powershell/powershell';
import 'codemirror/mode/python/python';
import 'codemirror/mode/ruby/ruby';
+import 'codemirror/mode/rust/rust';
import 'codemirror/mode/shell/shell';
import 'codemirror/mode/sql/sql';
import 'codemirror/mode/toml/toml';
csharp: 'text/x-csharp',
diff: 'diff',
go: 'go',
+ haskell: 'haskell',
+ hs: 'haskell',
html: 'htmlmixed',
javascript: 'javascript',
json: {name: 'javascript', json: true},
js: 'javascript',
+ jl: 'julia',
+ julia: 'julia',
lua: 'lua',
md: 'markdown',
mdown: 'markdown',
markdown: 'markdown',
+ ml: 'mllike',
nginx: 'nginx',
powershell: 'powershell',
+ ocaml: 'mllike',
php: 'php',
py: 'python',
python: 'python',
ruby: 'ruby',
+ rust: 'rust',
rb: 'ruby',
+ rs: 'rust',
shell: 'shell',
sh: 'shell',
bash: 'shell',
setContent: setContent,
markdownEditor: markdownEditor,
getMetaKey: getMetaKey,
-};
\ No newline at end of file
+};
// Revision
'revision_delete_confirm' => 'Are you sure you want to delete this revision?',
+ 'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
'revision_delete_success' => 'Revision deleted',
'revision_cannot_delete_latest' => 'Cannot delete the latest revision.'
];
\ No newline at end of file
@foreach($roles as $role)
<div>
@include('components.custom-checkbox', [
- 'name' => $name . '[' . $role->name . ']',
+ 'name' => $name . '[' . str_replace('.', 'DOT', $role->name) . ']',
'label' => $role->display_name,
'value' => $role->id,
- 'checked' => old($name . '.' . $role->name) || (!old('name') && isset($model) && $model->hasRole($role->name))
+ 'checked' => old($name . '.' . str_replace('.', 'DOT', $role->name)) || (!old('name') && isset($model) && $model->hasRole($role->name))
])
</div>
@endforeach
@else
<a href="{{ $revision->getUrl() }}" target="_blank">{{ trans('entities.pages_revisions_preview') }}</a>
<span class="text-muted"> | </span>
- <a href="{{ $revision->getUrl('restore') }}">{{ trans('entities.pages_revisions_restore') }}</a>
+ <a href="{{ $revision->getUrl('restore') }}"></a>
+ <div dropdown class="dropdown-container">
+ <a dropdown-toggle>{{ trans('entities.pages_revisions_restore') }}</a>
+ <ul>
+ <li class="px-m py-s"><small class="text-muted">{{trans('entities.revision_restore_confirm')}}</small></li>
+ <li>
+ <form action="{{ $revision->getUrl('/restore') }}" method="POST">
+ {!! csrf_field() !!}
+ <input type="hidden" name="_method" value="PUT">
+ <button type="submit" class="text-button text-primary">@icon('history'){{ trans('entities.pages_revisions_restore') }}</button>
+ </form>
+ </li>
+ </ul>
+ </div>
<span class="text-muted"> | </span>
<div dropdown class="dropdown-container">
<a dropdown-toggle>{{ trans('common.delete') }}</a>
Route::get('/{bookSlug}/page/{pageSlug}/revisions', 'PageController@showRevisions');
Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}', 'PageController@showRevision');
Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', 'PageController@showRevisionChanges');
- Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', 'PageController@restoreRevision');
+ Route::put('/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', 'PageController@restoreRevision');
Route::delete('/{bookSlug}/page/{pageSlug}/revisions/{revId}/delete', 'PageController@destroyRevision');
// Chapters
->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => $this->mockUser->name]);
}
+ protected function checkLdapReceivesCorrectDetails($serverString, $expectedHost, $expectedPort)
+ {
+ app('config')->set([
+ 'services.ldap.server' => $serverString
+ ]);
+
+ // Standard mocks
+ $this->mockLdap->shouldReceive('setVersion')->once();
+ $this->mockLdap->shouldReceive('setOption')->times(2);
+ $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)->andReturn(['count' => 1, 0 => [
+ 'uid' => [$this->mockUser->name],
+ 'cn' => [$this->mockUser->name],
+ 'dn' => ['dc=test' . config('services.ldap.base_dn')]
+ ]]);
+ $this->mockLdap->shouldReceive('bind')->times(3)->andReturn(true);
+ $this->mockEscapes(2);
+
+ $this->mockLdap->shouldReceive('connect')->once()
+ ->with($expectedHost, $expectedPort)->andReturn($this->resourceId);
+ $this->mockUserLogin();
+ }
+
+ public function test_ldap_port_provided_on_host_if_host_is_full_uri()
+ {
+ $hostName = 'ldaps://bookstack:8080';
+ $this->checkLdapReceivesCorrectDetails($hostName, $hostName, 389);
+ }
+
+ public function test_ldap_port_parsed_from_server_if_host_is_not_full_uri()
+ {
+ $this->checkLdapReceivesCorrectDetails('ldap.bookstack.com:8080', 'ldap.bookstack.com', 8080);
+ }
+
+ public function test_default_ldap_port_used_if_not_in_server_string_and_not_uri()
+ {
+ $this->checkLdapReceivesCorrectDetails('ldap.bookstack.com', 'ldap.bookstack.com', 389);
+ }
}
$pageResp->assertSee($content);
}
- public function test_page_revision_views_viewable()
- {
- $this->asEditor();
-
- $pageRepo = app(PageRepo::class);
- $page = Page::first();
- $pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
- $pageRevision = $page->revisions->last();
-
- $revisionView = $this->get($page->getUrl() . '/revisions/' . $pageRevision->id);
- $revisionView->assertStatus(200);
- $revisionView->assertSee('new content');
-
- $revisionView = $this->get($page->getUrl() . '/revisions/' . $pageRevision->id . '/changes');
- $revisionView->assertStatus(200);
- $revisionView->assertSee('new content');
- }
-
- public function test_page_revision_restore_updates_content()
- {
- $this->asEditor();
-
- $pageRepo = app(PageRepo::class);
- $page = Page::first();
- $pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page abc123', 'html' => '<p>new contente def456</p>', 'summary' => 'initial page revision testing']);
- $pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page again', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
- $page = Page::find($page->id);
-
-
- $pageView = $this->get($page->getUrl());
- $pageView->assertDontSee('abc123');
- $pageView->assertDontSee('def456');
-
- $revToRestore = $page->revisions()->where('name', 'like', '%abc123')->first();
- $restoreReq = $this->get($page->getUrl() . '/revisions/' . $revToRestore->id . '/restore');
- $page = Page::find($page->id);
-
- $restoreReq->assertStatus(302);
- $restoreReq->assertRedirect($page->getUrl());
-
- $pageView = $this->get($page->getUrl());
- $pageView->assertSee('abc123');
- $pageView->assertSee('def456');
- }
-
public function test_page_content_scripts_escaped_by_default()
{
$this->asEditor();
$pageView = $this->get($pageB->getUrl());
$pageView->assertSuccessful();
}
+
+ public function test_duplicate_ids_fixed_on_page_save()
+ {
+ $this->asEditor();
+ $page = Page::first();
+
+ $content = '<ul id="bkmrk-test"><li>test a</li><li><ul id="bkmrk-test"><li>test b</li></ul></li></ul>';
+ $pageSave = $this->put($page->getUrl(), [
+ 'name' => $page->name,
+ 'html' => $content,
+ 'summary' => ''
+ ]);
+ $pageSave->assertRedirect();
+
+ $updatedPage = Page::where('id', '=', $page->id)->first();
+ $this->assertEquals(substr_count($updatedPage->html, "bkmrk-test\""), 1);
+ }
}
<?php namespace Entity;
-
use BookStack\Entities\Page;
+use BookStack\Entities\Repos\PageRepo;
use Tests\TestCase;
class PageRevisionTest extends TestCase
{
+ public function test_page_revision_views_viewable()
+ {
+ $this->asEditor();
+
+ $pageRepo = app(PageRepo::class);
+ $page = Page::first();
+ $pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
+ $pageRevision = $page->revisions->last();
+
+ $revisionView = $this->get($page->getUrl() . '/revisions/' . $pageRevision->id);
+ $revisionView->assertStatus(200);
+ $revisionView->assertSee('new content');
+
+ $revisionView = $this->get($page->getUrl() . '/revisions/' . $pageRevision->id . '/changes');
+ $revisionView->assertStatus(200);
+ $revisionView->assertSee('new content');
+ }
+
+ public function test_page_revision_restore_updates_content()
+ {
+ $this->asEditor();
+
+ $pageRepo = app(PageRepo::class);
+ $page = Page::first();
+ $pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page abc123', 'html' => '<p>new contente def456</p>', 'summary' => 'initial page revision testing']);
+ $pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page again', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
+ $page = Page::find($page->id);
+
+
+ $pageView = $this->get($page->getUrl());
+ $pageView->assertDontSee('abc123');
+ $pageView->assertDontSee('def456');
+
+ $revToRestore = $page->revisions()->where('name', 'like', '%abc123')->first();
+ $restoreReq = $this->put($page->getUrl() . '/revisions/' . $revToRestore->id . '/restore');
+ $page = Page::find($page->id);
+
+ $restoreReq->assertStatus(302);
+ $restoreReq->assertRedirect($page->getUrl());
+
+ $pageView = $this->get($page->getUrl());
+ $pageView->assertSee('abc123');
+ $pageView->assertSee('def456');
+ }
public function test_page_revision_count_increments_on_update()
{