Repository: BookStackApp/BookStack Branch: development Commit: 0120b475eb18 Files: 2270 Total size: 11.4 MB Directory structure: gitextract_hlplnxq_/ ├── .gitattributes ├── .github/ │ ├── CODE_OF_CONDUCT.md │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── api_request.yml │ │ ├── bug_report.yml │ │ ├── config.yml │ │ ├── feature_request.yml │ │ ├── language_request.yml │ │ ├── support_request.yml │ │ └── z_blank_request.yml │ ├── SECURITY.md │ ├── translators.txt │ └── workflows/ │ ├── analyse-php.yml │ ├── lint-js.yml │ ├── lint-php.yml │ ├── test-js.yml │ ├── test-migrations.yml │ └── test-php.yml ├── .gitignore ├── LICENSE ├── app/ │ ├── Access/ │ │ ├── Controllers/ │ │ │ ├── ConfirmEmailController.php │ │ │ ├── ForgotPasswordController.php │ │ │ ├── HandlesPartialLogins.php │ │ │ ├── LoginController.php │ │ │ ├── MfaBackupCodesController.php │ │ │ ├── MfaController.php │ │ │ ├── MfaTotpController.php │ │ │ ├── OidcController.php │ │ │ ├── RegisterController.php │ │ │ ├── ResetPasswordController.php │ │ │ ├── Saml2Controller.php │ │ │ ├── SocialController.php │ │ │ ├── ThrottlesLogins.php │ │ │ └── UserInviteController.php │ │ ├── EmailConfirmationService.php │ │ ├── ExternalBaseUserProvider.php │ │ ├── GroupSyncService.php │ │ ├── Guards/ │ │ │ ├── AsyncExternalBaseSessionGuard.php │ │ │ ├── ExternalBaseSessionGuard.php │ │ │ └── LdapSessionGuard.php │ │ ├── Ldap.php │ │ ├── LdapService.php │ │ ├── LoginService.php │ │ ├── Mfa/ │ │ │ ├── BackupCodeService.php │ │ │ ├── MfaSession.php │ │ │ ├── MfaValue.php │ │ │ ├── TotpService.php │ │ │ └── TotpValidationRule.php │ │ ├── Notifications/ │ │ │ ├── ConfirmEmailNotification.php │ │ │ ├── ResetPasswordNotification.php │ │ │ └── UserInviteNotification.php │ │ ├── Oidc/ │ │ │ ├── OidcAccessToken.php │ │ │ ├── OidcException.php │ │ │ ├── OidcIdToken.php │ │ │ ├── OidcInvalidKeyException.php │ │ │ ├── OidcInvalidTokenException.php │ │ │ ├── OidcIssuerDiscoveryException.php │ │ │ ├── OidcJwtSigningKey.php │ │ │ ├── OidcJwtWithClaims.php │ │ │ ├── OidcOAuthProvider.php │ │ │ ├── OidcProviderSettings.php │ │ │ ├── OidcService.php │ │ │ ├── OidcUserDetails.php │ │ │ ├── OidcUserinfoResponse.php │ │ │ └── ProvidesClaims.php │ │ ├── RegistrationService.php │ │ ├── Saml2Service.php │ │ ├── SocialAccount.php │ │ ├── SocialAuthService.php │ │ ├── SocialDriverManager.php │ │ ├── UserInviteException.php │ │ ├── UserInviteService.php │ │ └── UserTokenService.php │ ├── Activity/ │ │ ├── ActivityQueries.php │ │ ├── ActivityType.php │ │ ├── CommentRepo.php │ │ ├── Controllers/ │ │ │ ├── AuditLogApiController.php │ │ │ ├── AuditLogController.php │ │ │ ├── CommentApiController.php │ │ │ ├── CommentController.php │ │ │ ├── FavouriteController.php │ │ │ ├── TagController.php │ │ │ ├── WatchController.php │ │ │ └── WebhookController.php │ │ ├── DispatchWebhookJob.php │ │ ├── Models/ │ │ │ ├── Activity.php │ │ │ ├── Comment.php │ │ │ ├── Favouritable.php │ │ │ ├── Favourite.php │ │ │ ├── Loggable.php │ │ │ ├── MentionHistory.php │ │ │ ├── Tag.php │ │ │ ├── View.php │ │ │ ├── Viewable.php │ │ │ ├── Watch.php │ │ │ ├── Webhook.php │ │ │ └── WebhookTrackedEvent.php │ │ ├── Notifications/ │ │ │ ├── Handlers/ │ │ │ │ ├── BaseNotificationHandler.php │ │ │ │ ├── CommentCreationNotificationHandler.php │ │ │ │ ├── CommentMentionNotificationHandler.php │ │ │ │ ├── NotificationHandler.php │ │ │ │ ├── PageCreationNotificationHandler.php │ │ │ │ └── PageUpdateNotificationHandler.php │ │ │ ├── MessageParts/ │ │ │ │ ├── EntityLinkMessageLine.php │ │ │ │ ├── EntityPathMessageLine.php │ │ │ │ ├── LinkedMailMessageLine.php │ │ │ │ └── ListMessageLine.php │ │ │ ├── Messages/ │ │ │ │ ├── BaseActivityNotification.php │ │ │ │ ├── CommentCreationNotification.php │ │ │ │ ├── CommentMentionNotification.php │ │ │ │ ├── PageCreationNotification.php │ │ │ │ └── PageUpdateNotification.php │ │ │ └── NotificationManager.php │ │ ├── Queries/ │ │ │ └── WebhooksAllPaginatedAndSorted.php │ │ ├── TagRepo.php │ │ ├── Tools/ │ │ │ ├── ActivityLogger.php │ │ │ ├── CommentTree.php │ │ │ ├── CommentTreeNode.php │ │ │ ├── EntityWatchers.php │ │ │ ├── IpFormatter.php │ │ │ ├── MentionParser.php │ │ │ ├── TagClassGenerator.php │ │ │ ├── UserEntityWatchOptions.php │ │ │ ├── WatchedParentDetails.php │ │ │ └── WebhookFormatter.php │ │ └── WatchLevels.php │ ├── Api/ │ │ ├── ApiDocsController.php │ │ ├── ApiDocsGenerator.php │ │ ├── ApiEntityListFormatter.php │ │ ├── ApiToken.php │ │ ├── ApiTokenGuard.php │ │ ├── ListingResponseBuilder.php │ │ └── UserApiTokenController.php │ ├── App/ │ │ ├── AppVersion.php │ │ ├── Application.php │ │ ├── HomeController.php │ │ ├── MailNotification.php │ │ ├── MetaController.php │ │ ├── Model.php │ │ ├── Providers/ │ │ │ ├── AppServiceProvider.php │ │ │ ├── AuthServiceProvider.php │ │ │ ├── EventServiceProvider.php │ │ │ ├── RouteServiceProvider.php │ │ │ ├── ThemeServiceProvider.php │ │ │ ├── TranslationServiceProvider.php │ │ │ ├── ValidationRuleServiceProvider.php │ │ │ └── ViewTweaksServiceProvider.php │ │ ├── PwaManifestBuilder.php │ │ ├── SluggableInterface.php │ │ ├── SystemApiController.php │ │ └── helpers.php │ ├── Config/ │ │ ├── api.php │ │ ├── app.php │ │ ├── auth.php │ │ ├── cache.php │ │ ├── clockwork.php │ │ ├── database.php │ │ ├── debugbar.php │ │ ├── exports.php │ │ ├── filesystems.php │ │ ├── hashing.php │ │ ├── logging.php │ │ ├── mail.php │ │ ├── oidc.php │ │ ├── queue.php │ │ ├── saml2.php │ │ ├── services.php │ │ ├── session.php │ │ ├── setting-defaults.php │ │ └── view.php │ ├── Console/ │ │ ├── Commands/ │ │ │ ├── AssignSortRuleCommand.php │ │ │ ├── CleanupImagesCommand.php │ │ │ ├── ClearActivityCommand.php │ │ │ ├── ClearRevisionsCommand.php │ │ │ ├── ClearViewsCommand.php │ │ │ ├── CopyShelfPermissionsCommand.php │ │ │ ├── CreateAdminCommand.php │ │ │ ├── DeleteUsersCommand.php │ │ │ ├── HandlesSingleUser.php │ │ │ ├── InstallModuleCommand.php │ │ │ ├── RefreshAvatarCommand.php │ │ │ ├── RegeneratePermissionsCommand.php │ │ │ ├── RegenerateReferencesCommand.php │ │ │ ├── RegenerateSearchCommand.php │ │ │ ├── ResetMfaCommand.php │ │ │ ├── UpdateUrlCommand.php │ │ │ └── UpgradeDatabaseEncodingCommand.php │ │ └── Kernel.php │ ├── Entities/ │ │ ├── BreadcrumbsViewComposer.php │ │ ├── Controllers/ │ │ │ ├── BookApiController.php │ │ │ ├── BookController.php │ │ │ ├── BookshelfApiController.php │ │ │ ├── BookshelfController.php │ │ │ ├── ChapterApiController.php │ │ │ ├── ChapterController.php │ │ │ ├── PageApiController.php │ │ │ ├── PageController.php │ │ │ ├── PageRevisionController.php │ │ │ ├── PageTemplateController.php │ │ │ ├── RecycleBinApiController.php │ │ │ └── RecycleBinController.php │ │ ├── EntityExistsRule.php │ │ ├── EntityProvider.php │ │ ├── Models/ │ │ │ ├── Book.php │ │ │ ├── BookChild.php │ │ │ ├── Bookshelf.php │ │ │ ├── Chapter.php │ │ │ ├── ContainerTrait.php │ │ │ ├── DeletableInterface.php │ │ │ ├── Deletion.php │ │ │ ├── Entity.php │ │ │ ├── EntityContainerData.php │ │ │ ├── EntityPageData.php │ │ │ ├── EntityQueryBuilder.php │ │ │ ├── EntityScope.php │ │ │ ├── EntityTable.php │ │ │ ├── HasCoverInterface.php │ │ │ ├── HasDefaultTemplateInterface.php │ │ │ ├── HasDescriptionInterface.php │ │ │ ├── Page.php │ │ │ ├── PageRevision.php │ │ │ └── SlugHistory.php │ │ ├── Queries/ │ │ │ ├── BookQueries.php │ │ │ ├── BookshelfQueries.php │ │ │ ├── ChapterQueries.php │ │ │ ├── EntityQueries.php │ │ │ ├── PageQueries.php │ │ │ ├── PageRevisionQueries.php │ │ │ ├── ProvidesEntityQueries.php │ │ │ ├── QueryPopular.php │ │ │ ├── QueryRecentlyViewed.php │ │ │ └── QueryTopFavourites.php │ │ ├── Repos/ │ │ │ ├── BaseRepo.php │ │ │ ├── BookRepo.php │ │ │ ├── BookshelfRepo.php │ │ │ ├── ChapterRepo.php │ │ │ ├── DeletionRepo.php │ │ │ ├── PageRepo.php │ │ │ └── RevisionRepo.php │ │ └── Tools/ │ │ ├── BookContents.php │ │ ├── Cloner.php │ │ ├── EntityCover.php │ │ ├── EntityDefaultTemplate.php │ │ ├── EntityHtmlDescription.php │ │ ├── EntityHydrator.php │ │ ├── HierarchyTransformer.php │ │ ├── Markdown/ │ │ │ ├── CheckboxConverter.php │ │ │ ├── CustomDivConverter.php │ │ │ ├── CustomImageConverter.php │ │ │ ├── CustomListItemRenderer.php │ │ │ ├── CustomParagraphConverter.php │ │ │ ├── CustomStrikeThroughExtension.php │ │ │ ├── CustomStrikethroughRenderer.php │ │ │ ├── HtmlToMarkdown.php │ │ │ ├── MarkdownToHtml.php │ │ │ └── SpacedTagFallbackConverter.php │ │ ├── MixedEntityListLoader.php │ │ ├── MixedEntityRequestHelper.php │ │ ├── NextPreviousContentLocator.php │ │ ├── PageContent.php │ │ ├── PageEditActivity.php │ │ ├── PageEditorData.php │ │ ├── PageEditorType.php │ │ ├── PageIncludeContent.php │ │ ├── PageIncludeParser.php │ │ ├── PageIncludeTag.php │ │ ├── ParentChanger.php │ │ ├── PermissionsUpdater.php │ │ ├── ShelfContext.php │ │ ├── SiblingFetcher.php │ │ ├── SlugGenerator.php │ │ ├── SlugHistory.php │ │ └── TrashCan.php │ ├── Exceptions/ │ │ ├── ApiAuthException.php │ │ ├── BookStackExceptionHandlerPage.php │ │ ├── ConfirmationEmailException.php │ │ ├── FileUploadException.php │ │ ├── Handler.php │ │ ├── HttpFetchException.php │ │ ├── ImageUploadException.php │ │ ├── JsonDebugException.php │ │ ├── LdapException.php │ │ ├── LoginAttemptEmailNeededException.php │ │ ├── LoginAttemptException.php │ │ ├── LoginAttemptInvalidUserException.php │ │ ├── MoveOperationException.php │ │ ├── NotFoundException.php │ │ ├── NotifyException.php │ │ ├── PdfExportException.php │ │ ├── PermissionsException.php │ │ ├── PrettyException.php │ │ ├── SamlException.php │ │ ├── SocialDriverNotConfigured.php │ │ ├── SocialSignInAccountNotUsed.php │ │ ├── SocialSignInException.php │ │ ├── StoppedAuthenticationException.php │ │ ├── ThemeException.php │ │ ├── UserRegistrationException.php │ │ ├── UserTokenExpiredException.php │ │ ├── UserTokenNotFoundException.php │ │ ├── UserUpdateException.php │ │ ├── ZipExportException.php │ │ ├── ZipImportException.php │ │ └── ZipValidationException.php │ ├── Exports/ │ │ ├── Controllers/ │ │ │ ├── BookExportApiController.php │ │ │ ├── BookExportController.php │ │ │ ├── ChapterExportApiController.php │ │ │ ├── ChapterExportController.php │ │ │ ├── ImportApiController.php │ │ │ ├── ImportController.php │ │ │ ├── PageExportApiController.php │ │ │ └── PageExportController.php │ │ ├── ExportFormatter.php │ │ ├── Import.php │ │ ├── ImportRepo.php │ │ ├── PdfGenerator.php │ │ └── ZipExports/ │ │ ├── Models/ │ │ │ ├── ZipExportAttachment.php │ │ │ ├── ZipExportBook.php │ │ │ ├── ZipExportChapter.php │ │ │ ├── ZipExportImage.php │ │ │ ├── ZipExportModel.php │ │ │ ├── ZipExportPage.php │ │ │ └── ZipExportTag.php │ │ ├── ZipExportBuilder.php │ │ ├── ZipExportFiles.php │ │ ├── ZipExportReader.php │ │ ├── ZipExportReferences.php │ │ ├── ZipExportValidator.php │ │ ├── ZipFileReferenceRule.php │ │ ├── ZipImportReferences.php │ │ ├── ZipImportRunner.php │ │ ├── ZipReferenceParser.php │ │ ├── ZipUniqueIdRule.php │ │ └── ZipValidationHelper.php │ ├── Facades/ │ │ ├── Activity.php │ │ └── Theme.php │ ├── Http/ │ │ ├── ApiController.php │ │ ├── Controller.php │ │ ├── DownloadResponseFactory.php │ │ ├── HttpClientHistory.php │ │ ├── HttpRequestService.php │ │ ├── Kernel.php │ │ ├── Middleware/ │ │ │ ├── ApiAuthenticate.php │ │ │ ├── ApplyCspRules.php │ │ │ ├── Authenticate.php │ │ │ ├── AuthenticatedOrPendingMfa.php │ │ │ ├── CheckEmailConfirmed.php │ │ │ ├── CheckGuard.php │ │ │ ├── CheckUserHasPermission.php │ │ │ ├── EncryptCookies.php │ │ │ ├── Localization.php │ │ │ ├── PreventRequestsDuringMaintenance.php │ │ │ ├── PreventResponseCaching.php │ │ │ ├── RedirectIfAuthenticated.php │ │ │ ├── RunThemeActions.php │ │ │ ├── StartSessionExtended.php │ │ │ ├── StartSessionIfCookieExists.php │ │ │ ├── ThrottleApiRequests.php │ │ │ ├── TrimStrings.php │ │ │ ├── TrustHosts.php │ │ │ ├── TrustProxies.php │ │ │ └── VerifyCsrfToken.php │ │ ├── RangeSupportedStream.php │ │ └── Request.php │ ├── Permissions/ │ │ ├── ContentPermissionApiController.php │ │ ├── EntityPermissionEvaluator.php │ │ ├── JointPermissionBuilder.php │ │ ├── MassEntityPermissionEvaluator.php │ │ ├── Models/ │ │ │ ├── EntityPermission.php │ │ │ ├── JointPermission.php │ │ │ └── RolePermission.php │ │ ├── Permission.php │ │ ├── PermissionApplicator.php │ │ ├── PermissionFormData.php │ │ ├── PermissionStatus.php │ │ ├── PermissionsController.php │ │ ├── PermissionsRepo.php │ │ └── SimpleEntityData.php │ ├── References/ │ │ ├── CrossLinkParser.php │ │ ├── ModelResolvers/ │ │ │ ├── AttachmentModelResolver.php │ │ │ ├── BookLinkModelResolver.php │ │ │ ├── BookshelfLinkModelResolver.php │ │ │ ├── ChapterLinkModelResolver.php │ │ │ ├── CrossLinkModelResolver.php │ │ │ ├── ImageModelResolver.php │ │ │ ├── PageLinkModelResolver.php │ │ │ └── PagePermalinkModelResolver.php │ │ ├── Reference.php │ │ ├── ReferenceChangeContext.php │ │ ├── ReferenceController.php │ │ ├── ReferenceFetcher.php │ │ ├── ReferenceStore.php │ │ └── ReferenceUpdater.php │ ├── Search/ │ │ ├── Options/ │ │ │ ├── ExactSearchOption.php │ │ │ ├── FilterSearchOption.php │ │ │ ├── SearchOption.php │ │ │ ├── TagSearchOption.php │ │ │ └── TermSearchOption.php │ │ ├── SearchApiController.php │ │ ├── SearchController.php │ │ ├── SearchIndex.php │ │ ├── SearchOptionSet.php │ │ ├── SearchOptions.php │ │ ├── SearchResultsFormatter.php │ │ ├── SearchRunner.php │ │ ├── SearchTerm.php │ │ └── SearchTextTokenizer.php │ ├── Settings/ │ │ ├── AppSettingsStore.php │ │ ├── MaintenanceController.php │ │ ├── Setting.php │ │ ├── SettingController.php │ │ ├── SettingService.php │ │ ├── StatusController.php │ │ ├── TestEmailNotification.php │ │ ├── UserNotificationPreferences.php │ │ └── UserShortcutMap.php │ ├── Sorting/ │ │ ├── BookSortController.php │ │ ├── BookSortMap.php │ │ ├── BookSortMapItem.php │ │ ├── BookSorter.php │ │ ├── SortRule.php │ │ ├── SortRuleController.php │ │ ├── SortRuleOperation.php │ │ ├── SortSetOperationComparisons.php │ │ └── SortUrl.php │ ├── Theming/ │ │ ├── CustomHtmlHeadContentProvider.php │ │ ├── ThemeController.php │ │ ├── ThemeEvents.php │ │ ├── ThemeModule.php │ │ ├── ThemeModuleException.php │ │ ├── ThemeModuleManager.php │ │ ├── ThemeModuleZip.php │ │ ├── ThemeService.php │ │ └── ThemeViews.php │ ├── Translation/ │ │ ├── FileLoader.php │ │ ├── LocaleDefinition.php │ │ ├── LocaleManager.php │ │ └── MessageSelector.php │ ├── Uploads/ │ │ ├── Attachment.php │ │ ├── AttachmentService.php │ │ ├── Controllers/ │ │ │ ├── AttachmentApiController.php │ │ │ ├── AttachmentController.php │ │ │ ├── DrawioImageController.php │ │ │ ├── GalleryImageController.php │ │ │ ├── ImageController.php │ │ │ └── ImageGalleryApiController.php │ │ ├── FaviconHandler.php │ │ ├── FileStorage.php │ │ ├── Image.php │ │ ├── ImageRepo.php │ │ ├── ImageResizer.php │ │ ├── ImageService.php │ │ ├── ImageStorage.php │ │ ├── ImageStorageDisk.php │ │ └── UserAvatars.php │ ├── Users/ │ │ ├── Controllers/ │ │ │ ├── RoleApiController.php │ │ │ ├── RoleController.php │ │ │ ├── UserAccountController.php │ │ │ ├── UserApiController.php │ │ │ ├── UserController.php │ │ │ ├── UserPreferencesController.php │ │ │ ├── UserProfileController.php │ │ │ └── UserSearchController.php │ │ ├── Models/ │ │ │ ├── HasCreatorAndUpdater.php │ │ │ ├── OwnableInterface.php │ │ │ ├── Role.php │ │ │ └── User.php │ │ ├── Queries/ │ │ │ ├── RolesAllPaginatedAndSorted.php │ │ │ ├── UserContentCounts.php │ │ │ ├── UserRecentlyCreatedContent.php │ │ │ └── UsersAllPaginatedAndSorted.php │ │ └── UserRepo.php │ └── Util/ │ ├── ConfiguredHtmlPurifier.php │ ├── CspService.php │ ├── DatabaseTransaction.php │ ├── DateFormatter.php │ ├── FilePathNormalizer.php │ ├── HtmlContentFilter.php │ ├── HtmlContentFilterConfig.php │ ├── HtmlDescriptionFilter.php │ ├── HtmlDocument.php │ ├── HtmlNonceApplicator.php │ ├── OutOfMemoryHandler.php │ ├── SimpleListOptions.php │ ├── SsrUrlValidator.php │ ├── SvgIcon.php │ └── WebSafeMimeSniffer.php ├── artisan ├── bookstack-system-cli ├── bootstrap/ │ ├── app.php │ ├── cache/ │ │ └── .gitignore │ └── phpstan.php ├── composer.json ├── crowdin.yml ├── database/ │ ├── .gitignore │ ├── factories/ │ │ ├── Access/ │ │ │ ├── Mfa/ │ │ │ │ └── MfaValueFactory.php │ │ │ └── SocialAccountFactory.php │ │ ├── Activity/ │ │ │ └── Models/ │ │ │ ├── ActivityFactory.php │ │ │ ├── CommentFactory.php │ │ │ ├── FavouriteFactory.php │ │ │ ├── TagFactory.php │ │ │ ├── WatchFactory.php │ │ │ ├── WebhookFactory.php │ │ │ └── WebhookTrackedEventFactory.php │ │ ├── Api/ │ │ │ └── ApiTokenFactory.php │ │ ├── Entities/ │ │ │ └── Models/ │ │ │ ├── BookFactory.php │ │ │ ├── BookshelfFactory.php │ │ │ ├── ChapterFactory.php │ │ │ ├── DeletionFactory.php │ │ │ ├── PageFactory.php │ │ │ ├── PageRevisionFactory.php │ │ │ └── SlugHistoryFactory.php │ │ ├── Exports/ │ │ │ └── ImportFactory.php │ │ ├── Sorting/ │ │ │ └── SortRuleFactory.php │ │ ├── Uploads/ │ │ │ ├── AttachmentFactory.php │ │ │ └── ImageFactory.php │ │ └── Users/ │ │ └── Models/ │ │ ├── RoleFactory.php │ │ └── UserFactory.php │ ├── migrations/ │ │ ├── .gitkeep │ │ ├── 2014_10_12_000000_create_users_table.php │ │ ├── 2014_10_12_100000_create_password_resets_table.php │ │ ├── 2015_07_12_114933_create_books_table.php │ │ ├── 2015_07_12_190027_create_pages_table.php │ │ ├── 2015_07_13_172121_create_images_table.php │ │ ├── 2015_07_27_172342_create_chapters_table.php │ │ ├── 2015_08_08_200447_add_users_to_entities.php │ │ ├── 2015_08_09_093534_create_page_revisions_table.php │ │ ├── 2015_08_16_142133_create_activities_table.php │ │ ├── 2015_08_29_105422_add_roles_and_permissions.php │ │ ├── 2015_08_30_125859_create_settings_table.php │ │ ├── 2015_08_31_175240_add_search_indexes.php │ │ ├── 2015_09_04_165821_create_social_accounts_table.php │ │ ├── 2015_09_05_164707_add_email_confirmation_table.php │ │ ├── 2015_11_21_145609_create_views_table.php │ │ ├── 2015_11_26_221857_add_entity_indexes.php │ │ ├── 2015_12_05_145049_fulltext_weighting.php │ │ ├── 2015_12_07_195238_add_image_upload_types.php │ │ ├── 2015_12_09_195748_add_user_avatars.php │ │ ├── 2016_01_11_210908_add_external_auth_to_users.php │ │ ├── 2016_02_25_184030_add_slug_to_revisions.php │ │ ├── 2016_02_27_120329_update_permissions_and_roles.php │ │ ├── 2016_02_28_084200_add_entity_access_controls.php │ │ ├── 2016_03_09_203143_add_page_revision_types.php │ │ ├── 2016_03_13_082138_add_page_drafts.php │ │ ├── 2016_03_25_123157_add_markdown_support.php │ │ ├── 2016_04_09_100730_add_view_permissions_to_roles.php │ │ ├── 2016_04_20_192649_create_joint_permissions_table.php │ │ ├── 2016_05_06_185215_create_tags_table.php │ │ ├── 2016_07_07_181521_add_summary_to_page_revisions.php │ │ ├── 2016_09_29_101449_remove_hidden_roles.php │ │ ├── 2016_10_09_142037_create_attachments_table.php │ │ ├── 2017_01_21_163556_create_cache_table.php │ │ ├── 2017_01_21_163602_create_sessions_table.php │ │ ├── 2017_03_19_091553_create_search_index_table.php │ │ ├── 2017_04_20_185112_add_revision_counts.php │ │ ├── 2017_07_02_152834_update_db_encoding_to_ut8mb4.php │ │ ├── 2017_08_01_130541_create_comments_table.php │ │ ├── 2017_08_29_102650_add_cover_image_display.php │ │ ├── 2018_07_15_173514_add_role_external_auth_id.php │ │ ├── 2018_08_04_115700_create_bookshelves_table.php │ │ ├── 2019_07_07_112515_add_template_support.php │ │ ├── 2019_08_17_140214_add_user_invites_table.php │ │ ├── 2019_12_29_120917_add_api_auth.php │ │ ├── 2020_08_04_111754_drop_joint_permissions_id.php │ │ ├── 2020_08_04_131052_remove_role_name_field.php │ │ ├── 2020_09_19_094251_add_activity_indexes.php │ │ ├── 2020_09_27_210059_add_entity_soft_deletes.php │ │ ├── 2020_09_27_210528_create_deletions_table.php │ │ ├── 2020_11_07_232321_simplify_activities_table.php │ │ ├── 2020_12_30_173528_add_owned_by_field_to_entities.php │ │ ├── 2021_01_30_225441_add_settings_type_column.php │ │ ├── 2021_03_08_215138_add_user_slug.php │ │ ├── 2021_05_15_173110_create_favourites_table.php │ │ ├── 2021_06_30_173111_create_mfa_values_table.php │ │ ├── 2021_07_03_085038_add_mfa_enforced_to_roles_table.php │ │ ├── 2021_08_28_161743_add_export_role_permission.php │ │ ├── 2021_09_26_044614_add_activities_ip_column.php │ │ ├── 2021_11_26_070438_add_index_for_user_ip.php │ │ ├── 2021_12_07_111343_create_webhooks_table.php │ │ ├── 2021_12_13_152024_create_jobs_table.php │ │ ├── 2021_12_13_152120_create_failed_jobs_table.php │ │ ├── 2022_01_03_154041_add_webhooks_timeout_error_columns.php │ │ ├── 2022_04_17_101741_add_editor_change_field_and_permission.php │ │ ├── 2022_04_25_140741_update_polymorphic_types.php │ │ ├── 2022_07_16_170051_drop_joint_permission_type.php │ │ ├── 2022_08_17_092941_create_references_table.php │ │ ├── 2022_09_02_082910_fix_shelf_cover_image_types.php │ │ ├── 2022_10_07_091406_flatten_entity_permissions_table.php │ │ ├── 2022_10_08_104202_drop_entity_restricted_field.php │ │ ├── 2023_01_24_104625_refactor_joint_permissions_storage.php │ │ ├── 2023_01_28_141230_copy_color_settings_for_dark_mode.php │ │ ├── 2023_02_20_093655_increase_attachments_path_length.php │ │ ├── 2023_02_23_200227_add_updated_at_index_to_pages.php │ │ ├── 2023_06_10_071823_remove_guest_user_secondary_roles.php │ │ ├── 2023_06_25_181952_remove_bookshelf_create_entity_permissions.php │ │ ├── 2023_07_25_124945_add_receive_notifications_role_permissions.php │ │ ├── 2023_07_31_104430_create_watches_table.php │ │ ├── 2023_08_21_174248_increase_cache_size.php │ │ ├── 2023_12_02_104541_add_default_template_to_books.php │ │ ├── 2023_12_17_140913_add_description_html_to_entities.php │ │ ├── 2024_01_01_104542_add_default_template_to_chapters.php │ │ ├── 2024_02_04_141358_add_views_updated_index.php │ │ ├── 2024_05_04_154409_rename_activity_relation_columns.php │ │ ├── 2024_09_29_140340_ensure_editor_value_set.php │ │ ├── 2024_10_29_114420_add_import_role_permission.php │ │ ├── 2024_11_02_160700_create_imports_table.php │ │ ├── 2024_11_27_171039_add_instance_id_setting.php │ │ ├── 2025_01_29_180933_create_sort_rules_table.php │ │ ├── 2025_02_05_150842_add_sort_rule_id_to_books.php │ │ ├── 2025_04_18_215145_add_content_refs_and_archived_to_comments.php │ │ ├── 2025_09_02_111542_remove_unused_columns.php │ │ ├── 2025_09_15_132850_create_entities_table.php │ │ ├── 2025_09_15_134701_migrate_entity_data.php │ │ ├── 2025_09_15_134751_update_entity_relation_columns.php │ │ ├── 2025_09_15_134813_drop_old_entity_tables.php │ │ ├── 2025_10_18_163331_clean_user_id_references.php │ │ ├── 2025_10_22_134507_update_comments_relation_field_names.php │ │ ├── 2025_11_23_161812_create_slug_history_table.php │ │ ├── 2025_12_15_140219_create_mention_history_table.php │ │ └── 2025_12_19_103417_add_views_viewable_type_index.php │ └── seeders/ │ ├── .gitkeep │ ├── DatabaseSeeder.php │ ├── DummyContentSeeder.php │ └── LargeContentSeeder.php ├── dev/ │ ├── api/ │ │ ├── requests/ │ │ │ ├── attachments-create.json │ │ │ ├── attachments-update.json │ │ │ ├── books-create.json │ │ │ ├── books-update.json │ │ │ ├── chapters-create.json │ │ │ ├── chapters-update.json │ │ │ ├── comments-create.json │ │ │ ├── comments-update.json │ │ │ ├── content-permissions-update.json │ │ │ ├── image-gallery-readDataForUrl.http │ │ │ ├── image-gallery-update.json │ │ │ ├── imports-run.json │ │ │ ├── pages-create.json │ │ │ ├── pages-update.json │ │ │ ├── roles-create.json │ │ │ ├── roles-update.json │ │ │ ├── search-all.http │ │ │ ├── shelves-create.json │ │ │ ├── shelves-update.json │ │ │ ├── users-create.json │ │ │ ├── users-delete.json │ │ │ └── users-update.json │ │ └── responses/ │ │ ├── attachments-create.json │ │ ├── attachments-list.json │ │ ├── attachments-read.json │ │ ├── attachments-update.json │ │ ├── audit-log-list.json │ │ ├── books-create.json │ │ ├── books-list.json │ │ ├── books-read.json │ │ ├── books-update.json │ │ ├── chapters-create.json │ │ ├── chapters-list.json │ │ ├── chapters-read.json │ │ ├── chapters-update.json │ │ ├── comments-create.json │ │ ├── comments-list.json │ │ ├── comments-read.json │ │ ├── comments-update.json │ │ ├── content-permissions-read.json │ │ ├── content-permissions-update.json │ │ ├── image-gallery-create.json │ │ ├── image-gallery-list.json │ │ ├── image-gallery-read.json │ │ ├── image-gallery-update.json │ │ ├── imports-create.json │ │ ├── imports-list.json │ │ ├── imports-read.json │ │ ├── imports-run.json │ │ ├── pages-create.json │ │ ├── pages-list.json │ │ ├── pages-read.json │ │ ├── pages-update.json │ │ ├── recycle-bin-destroy.json │ │ ├── recycle-bin-list.json │ │ ├── recycle-bin-restore.json │ │ ├── roles-create.json │ │ ├── roles-list.json │ │ ├── roles-read.json │ │ ├── roles-update.json │ │ ├── search-all.json │ │ ├── shelves-create.json │ │ ├── shelves-list.json │ │ ├── shelves-read.json │ │ ├── shelves-update.json │ │ ├── system-read.json │ │ ├── users-create.json │ │ ├── users-list.json │ │ ├── users-read.json │ │ └── users-update.json │ ├── build/ │ │ ├── esbuild.mjs │ │ ├── livereload.js │ │ └── svg-blank-transform.js │ ├── checksums/ │ │ ├── .gitignore │ │ └── vendor │ ├── docker/ │ │ ├── Dockerfile │ │ ├── db-testing/ │ │ │ ├── Dockerfile │ │ │ ├── readme.md │ │ │ └── run.sh │ │ ├── entrypoint.app.sh │ │ ├── entrypoint.node.sh │ │ └── php/ │ │ └── conf.d/ │ │ └── xdebug.ini │ ├── docs/ │ │ ├── development.md │ │ ├── javascript-code.md │ │ ├── javascript-public-events.md │ │ ├── logical-theme-system.md │ │ ├── permission-scenario-testing.md │ │ ├── php-testing.md │ │ ├── portable-zip-file-format.md │ │ ├── release-process.md │ │ ├── theme-system-modules.md │ │ ├── visual-theme-system.md │ │ └── wysiwyg-js-api.md │ └── licensing/ │ ├── gen-js-licenses │ ├── gen-licenses-shared.php │ ├── gen-php-licenses │ ├── js-library-licenses.txt │ └── php-library-licenses.txt ├── docker-compose.yml ├── eslint.config.mjs ├── jest.config.ts ├── lang/ │ ├── ar/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── bg/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── bn/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── bs/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── ca/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── cs/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── cy/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── da/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── de/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── de_informal/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── el/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── en/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── es/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── es_AR/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── et/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── eu/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── fa/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── fi/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── fr/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── he/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── hr/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── hu/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── id/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── is/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── it/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── ja/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── ka/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── ko/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── ku/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── lt/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── lv/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── nb/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── ne/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── nl/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── nn/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── pl/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── pt/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── pt_BR/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── ro/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── ru/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── sk/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── sl/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── sq/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── sr/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── sv/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── tk/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── tr/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── uk/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── uz/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── vi/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ ├── zh_CN/ │ │ ├── activities.php │ │ ├── auth.php │ │ ├── common.php │ │ ├── components.php │ │ ├── editor.php │ │ ├── entities.php │ │ ├── errors.php │ │ ├── notifications.php │ │ ├── pagination.php │ │ ├── passwords.php │ │ ├── preferences.php │ │ ├── settings.php │ │ └── validation.php │ └── zh_TW/ │ ├── activities.php │ ├── auth.php │ ├── common.php │ ├── components.php │ ├── editor.php │ ├── entities.php │ ├── errors.php │ ├── notifications.php │ ├── pagination.php │ ├── passwords.php │ ├── preferences.php │ ├── settings.php │ └── validation.php ├── package.json ├── phpcs.xml ├── phpstan.neon.dist ├── phpunit.xml ├── public/ │ ├── .htaccess │ ├── index.php │ ├── libs/ │ │ └── tinymce/ │ │ ├── langs/ │ │ │ └── README.md │ │ ├── license.txt │ │ ├── skins/ │ │ │ ├── content/ │ │ │ │ ├── dark/ │ │ │ │ │ └── content.js │ │ │ │ ├── default/ │ │ │ │ │ └── content.js │ │ │ │ ├── document/ │ │ │ │ │ └── content.js │ │ │ │ ├── tinymce-5/ │ │ │ │ │ └── content.js │ │ │ │ ├── tinymce-5-dark/ │ │ │ │ │ └── content.js │ │ │ │ └── writer/ │ │ │ │ └── content.js │ │ │ └── ui/ │ │ │ ├── tinymce-5/ │ │ │ │ ├── content.inline.js │ │ │ │ ├── content.js │ │ │ │ ├── skin.js │ │ │ │ └── skin.shadowdom.js │ │ │ └── tinymce-5-dark/ │ │ │ ├── content.inline.js │ │ │ ├── content.js │ │ │ ├── skin.js │ │ │ └── skin.shadowdom.js │ │ └── tinymce.d.ts │ ├── uploads/ │ │ ├── .gitignore │ │ └── .htaccess │ └── web.config ├── readme.md ├── resources/ │ ├── js/ │ │ ├── app.ts │ │ ├── code/ │ │ │ ├── index.mjs │ │ │ ├── languages.js │ │ │ ├── legacy-modes.mjs │ │ │ ├── setups.js │ │ │ ├── simple-editor-interface.js │ │ │ ├── themes.js │ │ │ └── views.js │ │ ├── components/ │ │ │ ├── add-remove-rows.js │ │ │ ├── ajax-delete-row.ts │ │ │ ├── ajax-form.js │ │ │ ├── api-nav.ts │ │ │ ├── attachments-list.js │ │ │ ├── attachments.js │ │ │ ├── auto-submit.js │ │ │ ├── auto-suggest.js │ │ │ ├── back-to-top.js │ │ │ ├── book-sort.js │ │ │ ├── chapter-contents.js │ │ │ ├── code-editor.js │ │ │ ├── code-highlighter.js │ │ │ ├── code-textarea.js │ │ │ ├── collapsible.js │ │ │ ├── component.js │ │ │ ├── confirm-dialog.js │ │ │ ├── custom-checkbox.js │ │ │ ├── details-highlighter.js │ │ │ ├── dropdown-search.js │ │ │ ├── dropdown.js │ │ │ ├── dropzone.js │ │ │ ├── editor-toolbox.ts │ │ │ ├── entity-permissions.js │ │ │ ├── entity-search.js │ │ │ ├── entity-selector-popup.ts │ │ │ ├── entity-selector.ts │ │ │ ├── event-emit-select.js │ │ │ ├── expand-toggle.js │ │ │ ├── global-search.js │ │ │ ├── header-mobile-toggle.js │ │ │ ├── image-manager.js │ │ │ ├── image-picker.js │ │ │ ├── index.ts │ │ │ ├── list-sort-control.js │ │ │ ├── loading-button.ts │ │ │ ├── markdown-editor.js │ │ │ ├── new-user-password.js │ │ │ ├── notification.js │ │ │ ├── optional-input.js │ │ │ ├── page-comment-reference.ts │ │ │ ├── page-comment.ts │ │ │ ├── page-comments.ts │ │ │ ├── page-display.js │ │ │ ├── page-editor.js │ │ │ ├── page-picker.js │ │ │ ├── permissions-table.js │ │ │ ├── pointer.ts │ │ │ ├── popup.js │ │ │ ├── setting-app-color-scheme.js │ │ │ ├── setting-color-picker.js │ │ │ ├── setting-homepage-control.js │ │ │ ├── shelf-sort.js │ │ │ ├── shortcut-input.js │ │ │ ├── shortcuts.js │ │ │ ├── sort-rule-manager.ts │ │ │ ├── sortable-list.js │ │ │ ├── submit-on-change.js │ │ │ ├── tabs.ts │ │ │ ├── tag-manager.js │ │ │ ├── template-manager.js │ │ │ ├── toggle-switch.js │ │ │ ├── tri-layout.ts │ │ │ ├── user-select.js │ │ │ ├── webhook-events.js │ │ │ ├── wysiwyg-editor-tinymce.js │ │ │ ├── wysiwyg-editor.js │ │ │ └── wysiwyg-input.ts │ │ ├── custom.d.ts │ │ ├── global.d.ts │ │ ├── markdown/ │ │ │ ├── actions.ts │ │ │ ├── codemirror.ts │ │ │ ├── common-events.ts │ │ │ ├── display.ts │ │ │ ├── dom-handlers.ts │ │ │ ├── index.mts │ │ │ ├── inputs/ │ │ │ │ ├── codemirror.ts │ │ │ │ ├── interface.ts │ │ │ │ └── textarea.ts │ │ │ ├── markdown.ts │ │ │ ├── settings.ts │ │ │ └── shortcuts.ts │ │ ├── services/ │ │ │ ├── __tests__/ │ │ │ │ └── translations.test.ts │ │ │ ├── animations.ts │ │ │ ├── clipboard.ts │ │ │ ├── components.ts │ │ │ ├── dates.ts │ │ │ ├── dom.ts │ │ │ ├── drawio.ts │ │ │ ├── dual-lists.ts │ │ │ ├── events.ts │ │ │ ├── http.ts │ │ │ ├── keyboard-navigation.ts │ │ │ ├── store.ts │ │ │ ├── text.ts │ │ │ ├── translations.ts │ │ │ ├── util.ts │ │ │ └── vdom.ts │ │ ├── wysiwyg/ │ │ │ ├── api/ │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── api-test-utils.ts │ │ │ │ │ ├── content.test.ts │ │ │ │ │ └── ui.test.ts │ │ │ │ ├── api.ts │ │ │ │ ├── content.ts │ │ │ │ └── ui.ts │ │ │ ├── index.ts │ │ │ ├── lexical/ │ │ │ │ ├── ORIGINAL-LEXICAL-LICENSE │ │ │ │ ├── clipboard/ │ │ │ │ │ ├── clipboard.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── core/ │ │ │ │ │ ├── LexicalCommands.ts │ │ │ │ │ ├── LexicalConstants.ts │ │ │ │ │ ├── LexicalEditor.ts │ │ │ │ │ ├── LexicalEditorState.ts │ │ │ │ │ ├── LexicalEvents.ts │ │ │ │ │ ├── LexicalGC.ts │ │ │ │ │ ├── LexicalMutations.ts │ │ │ │ │ ├── LexicalNode.ts │ │ │ │ │ ├── LexicalNormalization.ts │ │ │ │ │ ├── LexicalReconciler.ts │ │ │ │ │ ├── LexicalSelection.ts │ │ │ │ │ ├── LexicalUpdates.ts │ │ │ │ │ ├── LexicalUtils.ts │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── unit/ │ │ │ │ │ │ │ ├── HTMLCopyAndPaste.test.ts │ │ │ │ │ │ │ ├── LexicalEditor.test.ts │ │ │ │ │ │ │ ├── LexicalEditorState.test.ts │ │ │ │ │ │ │ ├── LexicalNode.test.ts │ │ │ │ │ │ │ ├── LexicalNormalization.test.ts │ │ │ │ │ │ │ ├── LexicalSelection.test.ts │ │ │ │ │ │ │ ├── LexicalSerialization.test.ts │ │ │ │ │ │ │ └── LexicalUtils.test.ts │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── nodes/ │ │ │ │ │ │ ├── ArtificialNode.ts │ │ │ │ │ │ ├── CommonBlockNode.ts │ │ │ │ │ │ ├── LexicalDecoratorNode.ts │ │ │ │ │ │ ├── LexicalElementNode.ts │ │ │ │ │ │ ├── LexicalLineBreakNode.ts │ │ │ │ │ │ ├── LexicalParagraphNode.ts │ │ │ │ │ │ ├── LexicalRootNode.ts │ │ │ │ │ │ ├── LexicalTabNode.ts │ │ │ │ │ │ ├── LexicalTextNode.ts │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ └── unit/ │ │ │ │ │ │ │ ├── LexicalElementNode.test.ts │ │ │ │ │ │ │ ├── LexicalGC.test.ts │ │ │ │ │ │ │ ├── LexicalLineBreakNode.test.ts │ │ │ │ │ │ │ ├── LexicalParagraphNode.test.ts │ │ │ │ │ │ │ ├── LexicalRootNode.test.ts │ │ │ │ │ │ │ ├── LexicalTabNode.test.ts │ │ │ │ │ │ │ └── LexicalTextNode.test.ts │ │ │ │ │ │ └── common.ts │ │ │ │ │ └── shared/ │ │ │ │ │ ├── __mocks__/ │ │ │ │ │ │ └── invariant.ts │ │ │ │ │ ├── canUseDOM.ts │ │ │ │ │ ├── caretFromPoint.ts │ │ │ │ │ ├── environment.ts │ │ │ │ │ ├── invariant.ts │ │ │ │ │ ├── normalizeClassNames.ts │ │ │ │ │ ├── simpleDiffWithCursor.ts │ │ │ │ │ └── warnOnlyOnce.ts │ │ │ │ ├── headless/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── unit/ │ │ │ │ │ │ └── LexicalHeadlessEditor.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── history/ │ │ │ │ │ └── index.ts │ │ │ │ ├── html/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── unit/ │ │ │ │ │ │ └── LexicalHtml.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── link/ │ │ │ │ │ ├── LexicalMentionNode.ts │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── unit/ │ │ │ │ │ │ ├── LexicalAutoLinkNode.test.ts │ │ │ │ │ │ └── LexicalLinkNode.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── list/ │ │ │ │ │ ├── LexicalListItemNode.ts │ │ │ │ │ ├── LexicalListNode.ts │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── unit/ │ │ │ │ │ │ ├── LexicalListItemNode.test.ts │ │ │ │ │ │ ├── LexicalListNode.test.ts │ │ │ │ │ │ └── utils.test.ts │ │ │ │ │ ├── formatList.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── readme.md │ │ │ │ ├── rich-text/ │ │ │ │ │ ├── LexicalCalloutNode.ts │ │ │ │ │ ├── LexicalCodeBlockNode.ts │ │ │ │ │ ├── LexicalDetailsNode.ts │ │ │ │ │ ├── LexicalDiagramNode.ts │ │ │ │ │ ├── LexicalHeadingNode.ts │ │ │ │ │ ├── LexicalHorizontalRuleNode.ts │ │ │ │ │ ├── LexicalImageNode.ts │ │ │ │ │ ├── LexicalMediaNode.ts │ │ │ │ │ ├── LexicalQuoteNode.ts │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── unit/ │ │ │ │ │ │ ├── LexicalDetailsNode.test.ts │ │ │ │ │ │ ├── LexicalHeadingNode.test.ts │ │ │ │ │ │ ├── LexicalMediaNode.test.ts │ │ │ │ │ │ └── LexicalQuoteNode.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── selection/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── unit/ │ │ │ │ │ │ │ ├── LexicalSelection.test.ts │ │ │ │ │ │ │ └── LexicalSelectionHelpers.test.ts │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── lexical-node.ts │ │ │ │ │ ├── range-selection.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── table/ │ │ │ │ │ ├── LexicalCaptionNode.ts │ │ │ │ │ ├── LexicalTableCellNode.ts │ │ │ │ │ ├── LexicalTableCommands.ts │ │ │ │ │ ├── LexicalTableNode.ts │ │ │ │ │ ├── LexicalTableObserver.ts │ │ │ │ │ ├── LexicalTableRowNode.ts │ │ │ │ │ ├── LexicalTableSelection.ts │ │ │ │ │ ├── LexicalTableSelectionHelpers.ts │ │ │ │ │ ├── LexicalTableUtils.ts │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── unit/ │ │ │ │ │ │ ├── LexicalTableCellNode.test.ts │ │ │ │ │ │ ├── LexicalTableNode.test.ts │ │ │ │ │ │ ├── LexicalTableRowNode.test.ts │ │ │ │ │ │ └── LexicalTableSelection.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── utils/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── unit/ │ │ │ │ │ │ ├── LexicalElementHelpers.test.ts │ │ │ │ │ │ ├── LexicalEventHelpers.test.ts │ │ │ │ │ │ ├── LexicalNodeHelpers.test.ts │ │ │ │ │ │ ├── LexicalRootHelpers.test.ts │ │ │ │ │ │ ├── LexicalUtilsKlassEqual.test.ts │ │ │ │ │ │ ├── LexicalUtilsSplitNode.test.ts │ │ │ │ │ │ ├── LexlcaiUtilsInsertNodeToNearestRoot.test.ts │ │ │ │ │ │ └── mergeRegister.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── markSelection.ts │ │ │ │ │ ├── mergeRegister.ts │ │ │ │ │ ├── positionNodeOnRange.ts │ │ │ │ │ └── px.ts │ │ │ │ └── yjs/ │ │ │ │ ├── Bindings.ts │ │ │ │ ├── CollabDecoratorNode.ts │ │ │ │ ├── CollabElementNode.ts │ │ │ │ ├── CollabLineBreakNode.ts │ │ │ │ ├── CollabTextNode.ts │ │ │ │ ├── SyncCursors.ts │ │ │ │ ├── SyncEditorStates.ts │ │ │ │ ├── Utils.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── nodes.ts │ │ │ ├── services/ │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── auto-links.test.ts │ │ │ │ │ ├── keyboard-handling.test.ts │ │ │ │ │ └── mouse-handling.test.ts │ │ │ │ ├── auto-links.ts │ │ │ │ ├── common-events.ts │ │ │ │ ├── drop-paste-handling.ts │ │ │ │ ├── keyboard-handling.ts │ │ │ │ ├── mentions.ts │ │ │ │ ├── mouse-handling.ts │ │ │ │ ├── selection-handling.ts │ │ │ │ └── shortcuts.ts │ │ │ ├── testing.md │ │ │ ├── ui/ │ │ │ │ ├── decorators/ │ │ │ │ │ ├── CodeBlockDecorator.ts │ │ │ │ │ ├── DiagramDecorator.ts │ │ │ │ │ └── MentionDecorator.ts │ │ │ │ ├── defaults/ │ │ │ │ │ ├── buttons/ │ │ │ │ │ │ ├── alignments.ts │ │ │ │ │ │ ├── block-formats.ts │ │ │ │ │ │ ├── controls.ts │ │ │ │ │ │ ├── inline-formats.ts │ │ │ │ │ │ ├── lists.ts │ │ │ │ │ │ ├── objects.ts │ │ │ │ │ │ └── tables.ts │ │ │ │ │ ├── forms/ │ │ │ │ │ │ ├── controls.ts │ │ │ │ │ │ ├── objects.ts │ │ │ │ │ │ └── tables.ts │ │ │ │ │ ├── modals.ts │ │ │ │ │ └── toolbars.ts │ │ │ │ ├── framework/ │ │ │ │ │ ├── blocks/ │ │ │ │ │ │ ├── action-field.ts │ │ │ │ │ │ ├── button-with-menu.ts │ │ │ │ │ │ ├── color-button.ts │ │ │ │ │ │ ├── color-field.ts │ │ │ │ │ │ ├── color-picker.ts │ │ │ │ │ │ ├── dropdown-button.ts │ │ │ │ │ │ ├── external-content.ts │ │ │ │ │ │ ├── format-menu.ts │ │ │ │ │ │ ├── format-preview-button.ts │ │ │ │ │ │ ├── link-field.ts │ │ │ │ │ │ ├── menu-button.ts │ │ │ │ │ │ ├── overflow-container.ts │ │ │ │ │ │ ├── separator.ts │ │ │ │ │ │ └── table-creator.ts │ │ │ │ │ ├── buttons.ts │ │ │ │ │ ├── core.ts │ │ │ │ │ ├── decorator.ts │ │ │ │ │ ├── forms.ts │ │ │ │ │ ├── helpers/ │ │ │ │ │ │ ├── dropdowns.ts │ │ │ │ │ │ ├── mouse-drag-tracker.ts │ │ │ │ │ │ ├── node-resizer.ts │ │ │ │ │ │ ├── table-resizer.ts │ │ │ │ │ │ ├── table-selection-handler.ts │ │ │ │ │ │ └── task-list-handler.ts │ │ │ │ │ ├── manager.ts │ │ │ │ │ ├── modals.ts │ │ │ │ │ └── toolbars.ts │ │ │ │ └── index.ts │ │ │ └── utils/ │ │ │ ├── __tests__/ │ │ │ │ └── lists.test.ts │ │ │ ├── actions.ts │ │ │ ├── details.ts │ │ │ ├── diagrams.ts │ │ │ ├── dom.ts │ │ │ ├── formats.ts │ │ │ ├── images.ts │ │ │ ├── links.ts │ │ │ ├── lists.ts │ │ │ ├── node-clipboard.ts │ │ │ ├── nodes.ts │ │ │ ├── selection.ts │ │ │ ├── table-copy-paste.ts │ │ │ ├── table-map.ts │ │ │ └── tables.ts │ │ └── wysiwyg-tinymce/ │ │ ├── common-events.js │ │ ├── config.js │ │ ├── drop-paste-handling.js │ │ ├── filters.js │ │ ├── fixes.js │ │ ├── icons.js │ │ ├── plugin-codeeditor.js │ │ ├── plugin-drawio.js │ │ ├── plugins-about.js │ │ ├── plugins-customhr.js │ │ ├── plugins-details.js │ │ ├── plugins-imagemanager.js │ │ ├── plugins-stub.js │ │ ├── plugins-table-additions.js │ │ ├── plugins-tasklist.js │ │ ├── scrolling.js │ │ ├── shortcuts.js │ │ ├── toolbars.js │ │ └── util.js │ ├── sass/ │ │ ├── _animations.scss │ │ ├── _blocks.scss │ │ ├── _buttons.scss │ │ ├── _codemirror.scss │ │ ├── _colors.scss │ │ ├── _components.scss │ │ ├── _content.scss │ │ ├── _editor.scss │ │ ├── _footer.scss │ │ ├── _forms.scss │ │ ├── _header.scss │ │ ├── _html.scss │ │ ├── _layout.scss │ │ ├── _lists.scss │ │ ├── _mixins.scss │ │ ├── _opacity.scss │ │ ├── _pages.scss │ │ ├── _print.scss │ │ ├── _reset.scss │ │ ├── _spacing.scss │ │ ├── _tables.scss │ │ ├── _text.scss │ │ ├── _tinymce.scss │ │ ├── _vars.scss │ │ ├── export-styles.scss │ │ └── styles.scss │ └── views/ │ ├── api-docs/ │ │ ├── index.blade.php │ │ └── parts/ │ │ ├── endpoint.blade.php │ │ └── getting-started.blade.php │ ├── attachments/ │ │ ├── list.blade.php │ │ ├── manager-edit-form.blade.php │ │ ├── manager-link-form.blade.php │ │ ├── manager-list.blade.php │ │ └── manager.blade.php │ ├── auth/ │ │ ├── invite-set-password.blade.php │ │ ├── login-initiate.blade.php │ │ ├── login.blade.php │ │ ├── parts/ │ │ │ ├── login-form-ldap.blade.php │ │ │ ├── login-form-oidc.blade.php │ │ │ ├── login-form-saml2.blade.php │ │ │ ├── login-form-standard.blade.php │ │ │ ├── login-message.blade.php │ │ │ └── register-message.blade.php │ │ ├── passwords/ │ │ │ ├── email.blade.php │ │ │ └── reset.blade.php │ │ ├── register-confirm-accept.blade.php │ │ ├── register-confirm-awaiting.blade.php │ │ ├── register-confirm.blade.php │ │ └── register.blade.php │ ├── books/ │ │ ├── copy.blade.php │ │ ├── create.blade.php │ │ ├── delete.blade.php │ │ ├── edit.blade.php │ │ ├── index.blade.php │ │ ├── parts/ │ │ │ ├── convert-to-shelf.blade.php │ │ │ ├── form.blade.php │ │ │ ├── index-sidebar-section-actions.blade.php │ │ │ ├── index-sidebar-section-new.blade.php │ │ │ ├── index-sidebar-section-popular.blade.php │ │ │ ├── index-sidebar-section-recents.blade.php │ │ │ ├── list-item.blade.php │ │ │ ├── list.blade.php │ │ │ ├── show-sidebar-section-actions.blade.php │ │ │ ├── show-sidebar-section-activity.blade.php │ │ │ ├── show-sidebar-section-details.blade.php │ │ │ ├── show-sidebar-section-shelves.blade.php │ │ │ ├── show-sidebar-section-tags.blade.php │ │ │ ├── sort-box-actions.blade.php │ │ │ └── sort-box.blade.php │ │ ├── permissions.blade.php │ │ ├── references.blade.php │ │ ├── show.blade.php │ │ └── sort.blade.php │ ├── chapters/ │ │ ├── copy.blade.php │ │ ├── create.blade.php │ │ ├── delete.blade.php │ │ ├── edit.blade.php │ │ ├── move.blade.php │ │ ├── parts/ │ │ │ ├── child-menu.blade.php │ │ │ ├── convert-to-book.blade.php │ │ │ ├── form.blade.php │ │ │ ├── list-item.blade.php │ │ │ ├── show-sidebar-section-actions.blade.php │ │ │ ├── show-sidebar-section-details.blade.php │ │ │ └── show-sidebar-section-tags.blade.php │ │ ├── permissions.blade.php │ │ ├── references.blade.php │ │ └── show.blade.php │ ├── comments/ │ │ ├── comment-branch.blade.php │ │ ├── comment.blade.php │ │ ├── comments.blade.php │ │ └── create.blade.php │ ├── common/ │ │ ├── activity-item.blade.php │ │ ├── activity-list.blade.php │ │ ├── confirm-dialog.blade.php │ │ ├── dark-mode-toggle.blade.php │ │ ├── detailed-listing-paginated.blade.php │ │ ├── detailed-listing-with-more.blade.php │ │ ├── loading-icon.blade.php │ │ ├── sort.blade.php │ │ └── status-indicator.blade.php │ ├── entities/ │ │ ├── body-tag-classes.blade.php │ │ ├── book-tree.blade.php │ │ ├── breadcrumb-listing.blade.php │ │ ├── breadcrumbs.blade.php │ │ ├── copy-considerations.blade.php │ │ ├── export-menu.blade.php │ │ ├── favourite-action.blade.php │ │ ├── grid-item.blade.php │ │ ├── icon-link.blade.php │ │ ├── list-basic.blade.php │ │ ├── list-item-basic.blade.php │ │ ├── list-item.blade.php │ │ ├── list.blade.php │ │ ├── meta.blade.php │ │ ├── references.blade.php │ │ ├── search-form.blade.php │ │ ├── search-results.blade.php │ │ ├── selector-popup.blade.php │ │ ├── selector.blade.php │ │ ├── sibling-navigation.blade.php │ │ ├── tag-list.blade.php │ │ ├── tag-manager-list.blade.php │ │ ├── tag-manager.blade.php │ │ ├── tag.blade.php │ │ ├── template-selector.blade.php │ │ ├── view-toggle.blade.php │ │ ├── watch-action.blade.php │ │ └── watch-controls.blade.php │ ├── errors/ │ │ ├── 404.blade.php │ │ ├── 500.blade.php │ │ ├── 503.blade.php │ │ ├── debug.blade.php │ │ └── parts/ │ │ └── not-found-text.blade.php │ ├── exports/ │ │ ├── book.blade.php │ │ ├── chapter.blade.php │ │ ├── import-show.blade.php │ │ ├── import.blade.php │ │ ├── page.blade.php │ │ └── parts/ │ │ ├── book-contents-menu.blade.php │ │ ├── chapter-contents-menu.blade.php │ │ ├── chapter-item.blade.php │ │ ├── custom-head.blade.php │ │ ├── import-item.blade.php │ │ ├── import.blade.php │ │ ├── meta.blade.php │ │ ├── page-item.blade.php │ │ └── styles.blade.php │ ├── form/ │ │ ├── checkbox.blade.php │ │ ├── custom-checkbox.blade.php │ │ ├── date.blade.php │ │ ├── description-html-input.blade.php │ │ ├── editor-translations.blade.php │ │ ├── entity-permissions-row.blade.php │ │ ├── entity-permissions.blade.php │ │ ├── errors.blade.php │ │ ├── image-picker.blade.php │ │ ├── number.blade.php │ │ ├── page-picker.blade.php │ │ ├── password.blade.php │ │ ├── request-query-inputs.blade.php │ │ ├── role-checkboxes.blade.php │ │ ├── role-select.blade.php │ │ ├── simple-dropzone.blade.php │ │ ├── text.blade.php │ │ ├── textarea.blade.php │ │ ├── toggle-switch.blade.php │ │ ├── user-mention-list.blade.php │ │ ├── user-select-list.blade.php │ │ └── user-select.blade.php │ ├── help/ │ │ ├── licenses.blade.php │ │ ├── tinymce.blade.php │ │ └── wysiwyg.blade.php │ ├── home/ │ │ ├── books.blade.php │ │ ├── default.blade.php │ │ ├── parts/ │ │ │ ├── expand-toggle.blade.php │ │ │ └── sidebar.blade.php │ │ ├── shelves.blade.php │ │ └── specific-page.blade.php │ ├── layouts/ │ │ ├── base.blade.php │ │ ├── export.blade.php │ │ ├── parts/ │ │ │ ├── base-body-end.blade.php │ │ │ ├── base-body-start.blade.php │ │ │ ├── custom-head.blade.php │ │ │ ├── custom-styles.blade.php │ │ │ ├── export-body-end.blade.php │ │ │ ├── export-body-start.blade.php │ │ │ ├── footer.blade.php │ │ │ ├── header-links-start.blade.php │ │ │ ├── header-links.blade.php │ │ │ ├── header-logo.blade.php │ │ │ ├── header-search.blade.php │ │ │ ├── header-user-menu.blade.php │ │ │ ├── header.blade.php │ │ │ ├── notifications.blade.php │ │ │ └── skip-to-content.blade.php │ │ ├── plain.blade.php │ │ ├── simple.blade.php │ │ └── tri.blade.php │ ├── mfa/ │ │ ├── backup-codes-generate.blade.php │ │ ├── parts/ │ │ │ ├── setup-method-row.blade.php │ │ │ ├── verify-backup_codes.blade.php │ │ │ └── verify-totp.blade.php │ │ ├── setup.blade.php │ │ ├── totp-generate.blade.php │ │ └── verify.blade.php │ ├── misc/ │ │ ├── opensearch.blade.php │ │ └── robots.blade.php │ ├── pages/ │ │ ├── copy.blade.php │ │ ├── delete.blade.php │ │ ├── edit.blade.php │ │ ├── guest-create.blade.php │ │ ├── move.blade.php │ │ ├── parts/ │ │ │ ├── code-editor.blade.php │ │ │ ├── editor-toolbar.blade.php │ │ │ ├── editor-toolbox.blade.php │ │ │ ├── form.blade.php │ │ │ ├── image-manager-form.blade.php │ │ │ ├── image-manager-list.blade.php │ │ │ ├── image-manager.blade.php │ │ │ ├── list-item.blade.php │ │ │ ├── markdown-editor.blade.php │ │ │ ├── page-display.blade.php │ │ │ ├── pointer.blade.php │ │ │ ├── revisions-index-row.blade.php │ │ │ ├── show-sidebar-section-actions.blade.php │ │ │ ├── show-sidebar-section-attachments.blade.php │ │ │ ├── show-sidebar-section-details.blade.php │ │ │ ├── show-sidebar-section-page-nav.blade.php │ │ │ ├── show-sidebar-section-tags.blade.php │ │ │ ├── template-manager-list.blade.php │ │ │ ├── template-manager.blade.php │ │ │ ├── toolbox-comments.blade.php │ │ │ ├── wysiwyg-editor-tinymce.blade.php │ │ │ └── wysiwyg-editor.blade.php │ │ ├── permissions.blade.php │ │ ├── references.blade.php │ │ ├── revision.blade.php │ │ ├── revisions.blade.php │ │ └── show.blade.php │ ├── readme.md │ ├── search/ │ │ ├── all.blade.php │ │ └── parts/ │ │ ├── boolean-filter.blade.php │ │ ├── date-filter.blade.php │ │ ├── entity-selector-list.blade.php │ │ ├── entity-suggestion-list.blade.php │ │ ├── term-list.blade.php │ │ └── type-filter.blade.php │ ├── settings/ │ │ ├── audit.blade.php │ │ ├── categories/ │ │ │ ├── customization.blade.php │ │ │ ├── features.blade.php │ │ │ ├── registration.blade.php │ │ │ └── sorting.blade.php │ │ ├── layout.blade.php │ │ ├── maintenance.blade.php │ │ ├── parts/ │ │ │ ├── footer-links.blade.php │ │ │ ├── navbar.blade.php │ │ │ ├── setting-color-picker.blade.php │ │ │ ├── setting-color-scheme.blade.php │ │ │ └── table-user.blade.php │ │ ├── recycle-bin/ │ │ │ ├── destroy.blade.php │ │ │ ├── index.blade.php │ │ │ ├── parts/ │ │ │ │ ├── deletable-entity-list.blade.php │ │ │ │ ├── entity-display-item.blade.php │ │ │ │ └── recycle-bin-list-item.blade.php │ │ │ └── restore.blade.php │ │ ├── roles/ │ │ │ ├── create.blade.php │ │ │ ├── delete.blade.php │ │ │ ├── edit.blade.php │ │ │ ├── index.blade.php │ │ │ └── parts/ │ │ │ ├── asset-permissions-row.blade.php │ │ │ ├── checkbox.blade.php │ │ │ ├── form.blade.php │ │ │ ├── related-asset-permissions-row.blade.php │ │ │ └── roles-list-item.blade.php │ │ ├── sort-rules/ │ │ │ ├── create.blade.php │ │ │ ├── edit.blade.php │ │ │ └── parts/ │ │ │ ├── form.blade.php │ │ │ ├── operation.blade.php │ │ │ └── sort-rule-list-item.blade.php │ │ └── webhooks/ │ │ ├── create.blade.php │ │ ├── delete.blade.php │ │ ├── edit.blade.php │ │ ├── index.blade.php │ │ └── parts/ │ │ ├── form.blade.php │ │ ├── format-example.blade.php │ │ └── webhooks-list-item.blade.php │ ├── shelves/ │ │ ├── create.blade.php │ │ ├── delete.blade.php │ │ ├── edit.blade.php │ │ ├── index.blade.php │ │ ├── parts/ │ │ │ ├── form.blade.php │ │ │ ├── index-sidebar-section-actions.blade.php │ │ │ ├── index-sidebar-section-new.blade.php │ │ │ ├── index-sidebar-section-popular.blade.php │ │ │ ├── index-sidebar-section-recents.blade.php │ │ │ ├── list-item.blade.php │ │ │ ├── list.blade.php │ │ │ ├── shelf-sort-book-item.blade.php │ │ │ ├── show-sidebar-section-actions.blade.php │ │ │ ├── show-sidebar-section-activity.blade.php │ │ │ ├── show-sidebar-section-details.blade.php │ │ │ └── show-sidebar-section-tags.blade.php │ │ ├── permissions.blade.php │ │ ├── references.blade.php │ │ └── show.blade.php │ ├── tags/ │ │ ├── index.blade.php │ │ └── parts/ │ │ └── tags-list-item.blade.php │ ├── users/ │ │ ├── account/ │ │ │ ├── auth.blade.php │ │ │ ├── delete.blade.php │ │ │ ├── layout.blade.php │ │ │ ├── notifications.blade.php │ │ │ ├── parts/ │ │ │ │ └── shortcut-control.blade.php │ │ │ ├── profile.blade.php │ │ │ └── shortcuts.blade.php │ │ ├── api-tokens/ │ │ │ ├── create.blade.php │ │ │ ├── delete.blade.php │ │ │ ├── edit.blade.php │ │ │ └── parts/ │ │ │ ├── form.blade.php │ │ │ └── list.blade.php │ │ ├── create.blade.php │ │ ├── delete.blade.php │ │ ├── edit.blade.php │ │ ├── index.blade.php │ │ ├── parts/ │ │ │ ├── form.blade.php │ │ │ ├── language-option-row.blade.php │ │ │ └── users-list-item.blade.php │ │ └── profile.blade.php │ └── vendor/ │ ├── notifications/ │ │ ├── email-plain.blade.php │ │ └── email.blade.php │ └── pagination/ │ └── default.blade.php ├── routes/ │ ├── api.php │ └── web.php ├── storage/ │ ├── app/ │ │ └── .gitignore │ ├── backups/ │ │ └── .gitignore │ ├── clockwork/ │ │ └── .gitignore │ ├── fonts/ │ │ └── .gitignore │ ├── framework/ │ │ ├── .gitignore │ │ ├── cache/ │ │ │ └── .gitignore │ │ ├── sessions/ │ │ │ └── .gitignore │ │ └── views/ │ │ └── .gitignore │ ├── logs/ │ │ └── .gitignore │ └── uploads/ │ ├── files/ │ │ └── .gitignore │ └── images/ │ └── .gitignore ├── tests/ │ ├── Activity/ │ │ ├── AuditLogApiTest.php │ │ ├── AuditLogTest.php │ │ ├── CommentDisplayTest.php │ │ ├── CommentMentionTest.php │ │ ├── CommentSettingTest.php │ │ ├── CommentStoreTest.php │ │ ├── CommentsApiTest.php │ │ ├── MentionParserTest.php │ │ ├── WatchTest.php │ │ ├── WebhookCallTest.php │ │ ├── WebhookFormatTesting.php │ │ └── WebhookManagementTest.php │ ├── Api/ │ │ ├── ApiAuthTest.php │ │ ├── ApiConfigTest.php │ │ ├── ApiDocsTest.php │ │ ├── ApiListingTest.php │ │ ├── AttachmentsApiTest.php │ │ ├── BooksApiTest.php │ │ ├── ChaptersApiTest.php │ │ ├── ContentPermissionsApiTest.php │ │ ├── ExportsApiTest.php │ │ ├── ImageGalleryApiTest.php │ │ ├── ImportsApiTest.php │ │ ├── PagesApiTest.php │ │ ├── RecycleBinApiTest.php │ │ ├── RolesApiTest.php │ │ ├── SearchApiTest.php │ │ ├── ShelvesApiTest.php │ │ ├── SystemApiTest.php │ │ ├── TestsApi.php │ │ └── UsersApiTest.php │ ├── Auth/ │ │ ├── AuthTest.php │ │ ├── GroupSyncServiceTest.php │ │ ├── LdapTest.php │ │ ├── LoginAutoInitiateTest.php │ │ ├── MfaConfigurationTest.php │ │ ├── MfaVerificationTest.php │ │ ├── OidcTest.php │ │ ├── RegistrationTest.php │ │ ├── ResetPasswordTest.php │ │ ├── Saml2Test.php │ │ ├── SocialAuthTest.php │ │ └── UserInviteTest.php │ ├── Commands/ │ │ ├── AssignSortRuleCommandTest.php │ │ ├── CleanupImagesCommandTest.php │ │ ├── ClearActivityCommandTest.php │ │ ├── ClearRevisionsCommandTest.php │ │ ├── ClearViewsCommandTest.php │ │ ├── CopyShelfPermissionsCommandTest.php │ │ ├── CreateAdminCommandTest.php │ │ ├── DeleteUsersCommandTest.php │ │ ├── InstallModuleCommandTest.php │ │ ├── RefreshAvatarCommandTest.php │ │ ├── RegeneratePermissionsCommandTest.php │ │ ├── RegenerateReferencesCommandTest.php │ │ ├── RegenerateSearchCommandTest.php │ │ ├── ResetMfaCommandTest.php │ │ ├── UpdateUrlCommandTest.php │ │ └── UpgradeDatabaseEncodingCommandTest.php │ ├── CreatesApplication.php │ ├── DebugViewTest.php │ ├── Entity/ │ │ ├── BookShelfTest.php │ │ ├── BookTest.php │ │ ├── ChapterTest.php │ │ ├── ConvertTest.php │ │ ├── CopyTest.php │ │ ├── DefaultTemplateTest.php │ │ ├── EntityAccessTest.php │ │ ├── EntityQueryTest.php │ │ ├── MarkdownToHtmlTest.php │ │ ├── PageContentFilteringTest.php │ │ ├── PageContentTest.php │ │ ├── PageDraftTest.php │ │ ├── PageEditorTest.php │ │ ├── PageRevisionTest.php │ │ ├── PageTemplateTest.php │ │ ├── PageTest.php │ │ ├── SlugTest.php │ │ └── TagTest.php │ ├── ErrorTest.php │ ├── Exports/ │ │ ├── ExportUiTest.php │ │ ├── HtmlExportTest.php │ │ ├── MarkdownExportTest.php │ │ ├── PdfExportTest.php │ │ ├── TextExportTest.php │ │ ├── ZipExportTest.php │ │ ├── ZipExportValidatorTest.php │ │ ├── ZipImportRunnerTest.php │ │ ├── ZipImportTest.php │ │ ├── ZipResultData.php │ │ └── ZipTestHelper.php │ ├── FavouriteTest.php │ ├── Helpers/ │ │ ├── EntityProvider.php │ │ ├── FileProvider.php │ │ ├── OidcJwtHelper.php │ │ ├── PermissionsProvider.php │ │ ├── TestServiceProvider.php │ │ └── UserRoleProvider.php │ ├── HomepageTest.php │ ├── LanguageTest.php │ ├── Meta/ │ │ ├── HelpTest.php │ │ ├── LicensesTest.php │ │ ├── OpenGraphTest.php │ │ ├── OpensearchTest.php │ │ ├── PwaManifestTest.php │ │ └── RobotsTest.php │ ├── Permissions/ │ │ ├── EntityOwnerChangeTest.php │ │ ├── EntityPermissionsTest.php │ │ ├── ExportPermissionsTest.php │ │ ├── RolePermissionsTest.php │ │ └── Scenarios/ │ │ ├── EntityRolePermissionsTest.php │ │ ├── PermissionScenarioTestCase.php │ │ └── RoleContentPermissionsTest.php │ ├── PublicActionTest.php │ ├── References/ │ │ ├── CrossLinkParserTest.php │ │ └── ReferencesTest.php │ ├── Search/ │ │ ├── EntitySearchTest.php │ │ ├── SearchIndexingTest.php │ │ ├── SearchOptionsTest.php │ │ └── SiblingSearchTest.php │ ├── SecurityHeaderTest.php │ ├── SessionTest.php │ ├── Settings/ │ │ ├── CustomHeadContentTest.php │ │ ├── FooterLinksTest.php │ │ ├── PageListLimitsTest.php │ │ ├── RecycleBinTest.php │ │ ├── RegenerateReferencesTest.php │ │ ├── SettingsTest.php │ │ └── TestEmailTest.php │ ├── Sorting/ │ │ ├── BookSortTest.php │ │ ├── MoveTest.php │ │ └── SortRuleTest.php │ ├── StatusTest.php │ ├── TestCase.php │ ├── Theme/ │ │ ├── LogicalThemeEventsTest.php │ │ ├── LogicalThemeTest.php │ │ ├── ThemeModuleTest.php │ │ └── VisualThemeTest.php │ ├── Unit/ │ │ ├── ConfigTest.php │ │ ├── FrameworkAssumptionTest.php │ │ ├── IpFormatterTest.php │ │ ├── OidcIdTokenTest.php │ │ ├── PageIncludeParserTest.php │ │ └── SsrUrlValidatorTest.php │ ├── Uploads/ │ │ ├── AttachmentTest.php │ │ ├── AvatarTest.php │ │ ├── DrawioTest.php │ │ ├── ImageStorageTest.php │ │ └── ImageTest.php │ ├── UrlTest.php │ ├── User/ │ │ ├── RoleManagementTest.php │ │ ├── UserApiTokenTest.php │ │ ├── UserManagementTest.php │ │ ├── UserMyAccountTest.php │ │ ├── UserPreferencesTest.php │ │ ├── UserProfileTest.php │ │ └── UserSearchTest.php │ ├── Util/ │ │ └── DateFormatterTest.php │ └── test-data/ │ ├── animated.avif │ ├── bad-php.base64 │ ├── bad-phtml-png.base64 │ ├── bad-phtml.base64 │ └── test-file.txt ├── themes/ │ └── .gitignore ├── tsconfig.json └── version ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ * text=auto *.css linguist-vendored *.less linguist-vendored ================================================ FILE: .github/CODE_OF_CONDUCT.md ================================================ # 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 ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [ssddanbrown] ko_fi: ssddanbrown ================================================ FILE: .github/ISSUE_TEMPLATE/api_request.yml ================================================ name: New API Endpoint or API Ability description: Request a new endpoint or API feature be added labels: [":nut_and_bolt: API Request"] body: - type: textarea id: feature attributes: label: API Endpoint or Feature description: Clearly describe what you'd like to have added to the API. validations: required: true - type: textarea id: usecase attributes: label: Use-Case description: Explain the use-case that you're working-on that requires the above request. validations: required: true - type: textarea id: context attributes: label: Additional context description: Add any other context about the feature request here. validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: Create a report to help us fix bugs & issues in existing supported functionality labels: [":bug: Bug"] body: - type: markdown attributes: value: | Thanks for taking the time to fill out a bug report! Please note that this form is for reporting bugs in existing supported functionality. If you are reporting something that's not an issue in functionality we've previously supported and/or is simply something different to your expectations, then it may be more appropriate to raise via a feature or support request instead. - type: textarea id: description attributes: label: Describe the Bug description: Provide a clear and concise description of what the bug is. validations: required: true - type: textarea id: reproduction attributes: label: Steps to Reproduce description: Detail the steps that would replicate this issue. placeholder: | 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error validations: required: true - type: textarea id: expected attributes: label: Expected Behaviour description: Provide clear and concise description of what you expected to happen. validations: required: true - type: textarea id: context attributes: label: Screenshots or Additional Context description: Provide any additional context and screenshots here to help us solve this issue. validations: required: false - type: input id: browserdetails attributes: label: Browser Details description: | If this is an issue that occurs when using the BookStack interface, please provide details of the browser used which presents the reported issue. placeholder: (eg. Firefox 97 (64-bit) on Windows 11) validations: required: false - type: input id: bsversion attributes: label: Exact BookStack Version description: This can be found in the settings view of BookStack. Please provide an exact version(s) you've tested on. placeholder: (eg. v23.06.7) validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Discord Chat Support url: https://discord.gg/ztkBqR2 about: Realtime support & chat with the BookStack community and the team. - name: Debugging & Common Issues url: https://www.bookstackapp.com/docs/admin/debugging/ about: Find details on how to debug issues and view common issues with their resolutions. - name: Official Support Plans url: https://www.bookstackapp.com/support/ about: View our official support plans that offer assured support for business. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature Request description: Request a new feature or idea to be added to BookStack labels: [":hammer: Feature Request"] body: - type: textarea id: description attributes: label: Describe the feature you'd like description: Provide a clear description of the feature you'd like implemented in BookStack validations: required: true - type: textarea id: benefits attributes: label: Describe the benefits this would bring to existing BookStack users description: | Explain the measurable benefits this feature would achieve for existing BookStack users. These benefits should details outcomes in terms of what this request solves/achieves, and should not be specific to implementation. This helps us understand the core desired goal so that a variety of potential implementations could be explored. This field is important. Lack if input here may lead to early issue closure. validations: required: true - type: textarea id: already_achieved attributes: label: Can the goal of this request already be achieved via other means? description: | Yes/No. If yes, please describe how the requested approach fits in with the existing method. validations: required: true - type: checkboxes id: confirm-search attributes: label: Have you searched for an existing open/closed issue? description: | To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundamental benefit/goal of your request. options: - label: I have searched for existing issues and none cover my fundamental request required: true - type: dropdown id: existing_usage attributes: label: How long have you been using BookStack? options: - Not using yet, just scoping - Under 3 months - 3 months to 1 year - 1 to 5 years - Over 5 years validations: required: true - type: textarea id: context attributes: label: Additional context description: Add any other context or screenshots about the feature request here. validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/language_request.yml ================================================ name: Language Request description: Request a new language to be added to Crowdin for you to translate labels: [":earth_africa: Translations"] assignees: - ssddanbrown body: - type: markdown attributes: value: | Thanks for offering to help start a new translation for BookStack! - type: input id: language attributes: label: Language to Add description: What language (and region if applicable) are you offering to help add to BookStack? validations: required: true - type: checkboxes id: confirm attributes: label: Confirmation of Intent description: | This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack). Please don't use this template to request a new language that you are not prepared to provide translations for. options: - label: I confirm I'm offering to help translate for this new language via Crowdin. required: true - type: markdown attributes: value: | *__Note: New languages are added at specific points of the development process so it may be a small while before the requested language is added for translation.__* ================================================ FILE: .github/ISSUE_TEMPLATE/support_request.yml ================================================ name: Support Request description: Request support for a specific problem you have not been able to solve yourself labels: [":dog2: Support"] body: - type: checkboxes id: useddocs attributes: label: Attempted Debugging description: | I have read the [BookStack debugging](https://www.bookstackapp.com/docs/admin/debugging/) page and seeked resolution or more detail for the issue. options: - label: I have read the debugging page required: true - type: checkboxes id: searchissue attributes: label: Searched GitHub Issues description: | I have searched for the issue and potential resolutions within the [project's GitHub issue list](https://github.com/BookStackApp/BookStack/issues) options: - label: I have searched GitHub for the issue. required: true - type: textarea id: scenario attributes: label: Describe the Scenario description: Detail the problem that you're having or what you need support with. validations: required: true - type: input id: bsversion attributes: label: Exact BookStack Version description: This can be found in the settings view of BookStack. Please provide an exact version. placeholder: (eg. v23.06.7) validations: required: true - type: textarea id: logs attributes: label: Log Content description: If the issue has produced an error, provide any [BookStack or server log](https://www.bookstackapp.com/docs/admin/debugging/) content below. placeholder: Be sure to remove any confidential details in your logs render: text validations: required: false - type: textarea id: hosting attributes: label: Hosting Environment description: Describe your hosting environment as much as possible including any proxies used (If applicable). placeholder: (eg. PHP8.1 on Ubuntu 22.04 VPS, installed using official installation script) validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/z_blank_request.yml ================================================ name: Blank Request (Maintainers Only) description: For maintainers only - Start a blank request body: - type: markdown attributes: value: "**This blank request option is only for existing official maintainers of the project!** Please instead use a different request option. If you use this your issue will be closed off." - type: textarea attributes: label: Description ================================================ FILE: .github/SECURITY.md ================================================ # Security Policy ## Supported Versions Only the [latest version](https://github.com/BookStackApp/BookStack/releases) of BookStack is supported. We generally don't support older versions of BookStack due to maintenance effort and since we aim to provide a fairly stable upgrade path for new versions. ## Security Notifications If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](https://updates.bookstackapp.com/signup/bookstack-security-updates). ## Reporting a Vulnerability If you've found an issue that likely has no impact to existing users (For example, in a development-only branch) feel free to raise it via a standard GitHub bug report issue. If the issue could have a security impact to BookStack instances, please directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown). You will need to log in to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown). Alternatively you can send a DM via Mastodon to [@danb@fosstodon.org](https://fosstodon.org/@danb). Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability can often take a little time due to the amount of preparation required, to ensure the vulnerability has been covered, and to create the content required to adequately notify the user-base. Thank you for keeping BookStack instances safe! ================================================ FILE: .github/translators.txt ================================================ Name :: Languages @robertlandes :: German @SergioMendolia :: French @NakaharaL :: Portuguese, Brazilian @ReeseSebastian :: German @arietimmerman :: Dutch @diegoseso :: Spanish @S64 :: Japanese @JachuPL :: Polish @Joorem :: French @timoschwarzer :: German @sanderdw :: Dutch @lbguilherme :: Portuguese, Brazilian @marcusforsberg :: Swedish @artur-trzesiok :: Polish @Alwaysin :: French @msaus :: Japanese @moucho :: Spanish @vriic :: German @DeehSlash :: Portuguese, Brazilian @alex2702 :: German @nicobubulle :: French @kmoj86 :: Arabic @houbaron :: Chinese Traditional; Chinese Simplified @mullinsmikey :: Russian @limkukhyun :: Korean @CliffyPrime :: German @kejjang :: Chinese Traditional @TheLastOperator :: French @qianmengnet :: Simplified Chinese @ezzra :: German; German Informal @vasiliev123 :: Polish @Mant1kor :: Ukrainian @Xiphoseer :: German; German Informal @maantje :: Dutch @cima :: Czech @agvol :: Russian @Hambern :: Swedish @NootoNooto :: Dutch @kostefun :: Russian @lucaguindani :: French @miles75 :: Hungarian @danielroehrig-mm :: German @oykenfurkan :: Turkish @qligier :: French @johnroyer :: Traditional Chinese @artskoczylas :: Polish @dellamina :: Italian @jzoy :: Simplified Chinese @ististudio :: Korean @leomartinez :: Spanish Argentina @geins :: German @Ereza :: Catalan @benediktvolke :: German @Baptistou :: French @arcoai :: Spanish @Jokuna :: Korean @smartshogu :: German; German Informal @samadha56 :: Persian @mrmuminov :: Uzbek cipi1965 :: Italian Mykola Ronik (Mantikor) :: Ukrainian furkanoyk :: Turkish m0uch0 :: Spanish Maxim Zalata (zlatin) :: Russian; Ukrainian nutsflag :: French Leonardo Mario Martinez (leonardo.m.martinez) :: Spanish, Argentina Rodrigo Saczuk Niz (rodrigoniz) :: Portuguese, Brazilian 叫钦叔就好 (254351722) :: Chinese Traditional; Chinese Simplified aekramer :: Dutch JachuPL :: Polish milesteg :: Hungarian Beenbag :: German; German Informal Lett3rs :: Danish Julian (julian.henneberg) :: German; German Informal 3GNWn :: Danish dbguichu :: Chinese Simplified Randy Kim (hyunjun) :: Korean Francesco M. Taurino (ftaurino) :: Italian DanielFrederiksen :: Danish Finn Wessel (19finnwessel6) :: German Informal; German Gustav Kånåhols (Kurbitz) :: Swedish Vuong Trung Hieu (fpooon) :: Vietnamese Emil Petersen (emoyly) :: Danish mrjaboozy :: Slovenian Statium :: Russian Mikkel Struntze (MStruntze) :: Danish kostefun :: Russian Tuyen.NG (tuyendev) :: Vietnamese Ghost_chu (dbguichu) :: Chinese Simplified Ziipen :: Danish Samuel Schwarz (Guiph7quan) :: Czech Aleph (toishoki) :: Turkish Julio Alberto García (Yllelder) :: Spanish Rafael (raribeir) :: Portuguese, Brazilian Hiroyuki Odake (dakesan) :: Japanese Alex Lee (qianmengnet) :: Chinese Simplified swinn37 :: French Hasan Özbey (the-turk) :: Turkish rcy :: Swedish Ali Yasir Yılmaz (ayyilmaz) :: Turkish scureza :: Italian Biepa :: German Informal; German syecu :: Chinese Simplified Lap1t0r :: French Thinkverse (thinkverse) :: Swedish alef (toishoki) :: Turkish Robbert Feunekes (Muukuro) :: Dutch seohyeon.joo :: Korean Orenda (OREDNA) :: Bulgarian Marek Pavelka (marapavelka) :: Czech Venkinovec :: Czech Tommy Ku (tommyku) :: Chinese Traditional; Japanese Michał Bielejewski (bielej) :: Polish jozefrebjak :: Slovak Ikhwan Koo (Ikhwan.Koo) :: Korean Whay (remkovdhoef) :: Dutch jc7115 :: Chinese Traditional 주서현 (seohyeon.joo) :: Korean ReadySystems :: Arabic HFinch :: German; German Informal brechtgijsens :: Dutch Lowkey (v587ygq) :: Chinese Simplified sdl-blue :: German Informal sqlik :: Polish Roy van Schaijk (royvanschaijk) :: Dutch Simsimpicpic :: French Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal tatsuya.info :: Japanese fadiapp :: Arabic Jakub Bouček (jakubboucek) :: Czech Marco (cdrfun) :: German; German Informal 10935336 :: Chinese Simplified 孟繁阳 (FanyangMeng) :: Chinese Simplified Andrej Močan (andrejm) :: Slovenian gilane9_ :: Arabic Raed alnahdi (raednahdi) :: Arabic Xiphoseer :: German MerlinSVK (merlinsvk) :: Slovak Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian MatthieuParis :: French Douradinho :: Portuguese, Brazilian; Portuguese Gaku Yaguchi (tama11) :: Japanese Zero Huang (johnroyer) :: Chinese Traditional jackaaa :: Chinese Traditional Irfan Hukama Arsyad (IrfanArsyad) :: Indonesian Jeff Huang (s8321414) :: Chinese Traditional Luís Tiago Favas (starkyller) :: Portuguese semirte :: Bosnian aarchijs :: Latvian Martins Pilsetnieks (pilsetnieks) :: Latvian Yonatan Magier (yonatanmgr) :: Hebrew FastHogi :: German Informal; German Ole Anders (Swoy) :: Norwegian Bokmal Atlochowski (atlochowski) :: Polish Simon (DefaultSimon) :: Slovenian Reinis Mednis (Mednis) :: Latvian toisho (toishoki) :: Turkish nikservik :: Ukrainian; Russian; Polish HenrijsS :: Latvian Pascal R-B (pborgner) :: German Boris (Ginfred) :: Russian Jonas Anker Rasmussen (jonasanker) :: Danish Gerwin de Keijzer (gdekeijzer) :: Dutch; German Informal; German kometchtech :: Japanese Auri (Atalonica) :: Catalan Francesco Franchina (ffranchina) :: Italian Aimrane Kds (aimrane.kds) :: Arabic whenwesober :: Indonesian Rem (remkovdhoef) :: Dutch syn7ax69 :: Bulgarian; Turkish; German Blaade :: French Behzad HosseinPoor (behzad.hp) :: Persian Ole Aldric (Swoy) :: Norwegian Bokmal fharis arabia (raednahdi) :: Arabic Alexander Predl (Harveyhase68) :: German Rem (Rem9000) :: Dutch Michał Stelmach (stelmach-web) :: Polish arniom :: French REMOVED_USER :: French; German; Dutch; Portuguese, Brazilian; Portuguese; Turkish; 林祖年 (contagion) :: Chinese Traditional Siamak Guodarzi (siamakgoudarzi88) :: Persian Lis Maestrelo (lismtrl) :: Portuguese, Brazilian Nathanaël (nathanaelhoun) :: French A Ibnu Hibban (abd.ibnuhibban) :: Indonesian Frost-ZX :: Chinese Simplified Kuzma Simonov (ovmach) :: Russian Vojtěch Krystek (acantophis) :: Czech Michał Lipok (mLipok) :: Polish Nicolas Pawlak (Mikolajek) :: French; Polish; German Thomas Hansen (thomasdk81) :: Danish Hl2run :: Slovak Ngo Tri Hoai (trihoai) :: Vietnamese Atalonica :: Catalan 慕容潭谈 (591442386) :: Chinese Simplified Radim Pesek (ramess18) :: Czech anastasiia.motylko :: Ukrainian Indrek Haav (IndrekHaav) :: Estonian na3shkw :: Japanese Giancarlo Di Massa (digitall-it) :: Italian M Nafis Al Mukhdi (mnafisalmukhdi1) :: Indonesian sulfo :: Danish Raukze :: German zygimantus :: Lithuanian marinkaberg :: Russian Vitaliy (gviabcua) :: Ukrainian mannycarreiro :: Portuguese Thiago Rafael Pereira de Carvalho (thiago.rafael) :: Portuguese, Brazilian Ken Roger Bolgnes (kenbo124) :: Norwegian Bokmal Nguyen Hung Phuong (hnwolf) :: Vietnamese Umut ERGENE (umutergene67) :: Turkish Tomáš Batelka (Vofy) :: Czech Mundo Racional (ismael.mesquita) :: Portuguese, Brazilian Zarik (3apuk) :: Russian Ali Shaatani (a.shaatani) :: Arabic ChacMaster :: Portuguese, Brazilian Saeed (saeed205) :: Persian Julesdevops :: French peter cerny (posli.to.semka) :: Slovak Pavel Karlin (pavelkarlin) :: Russian SmokingCrop :: Dutch Maciej Lebiest (Szwendacz) :: Polish DiscordDigital :: German; German Informal Gábor Marton (dodver) :: Hungarian Jakob Åsell (Jasell) :: Swedish Ghost_chu (ghostchu) :: Chinese Simplified Ravid Shachar (ravidshachar) :: Hebrew Helga Guchshenskaya (guchshenskaya) :: Russian daniel chou (chou0214) :: Chinese Traditional Manolis PATRIARCHE (m.patriarche) :: French Mohammed Haboubi (haboubi92) :: Arabic roncallyt :: Portuguese, Brazilian goegol :: Dutch msevgen :: Turkish Khroners :: French MASOUD HOSSEINY (masoudme) :: Persian Thomerson Roncally (roncallyt) :: Portuguese, Brazilian metaarch :: Bulgarian Xabi (xabikip) :: Basque pedromcsousa :: Portuguese Nir Louk (looknear) :: Hebrew Alex (qianmengnet) :: Chinese Simplified stothew :: German sgenc :: Turkish Shukrullo (vodiylik) :: Uzbek William W. (Nevnt) :: Chinese Traditional eamaro :: Portuguese Ypsilon-dev :: Arabic Hieu Vuong Trung (vuongtrunghieu) :: Vietnamese David Clubb (davidoclubb) :: Welsh welles freire (wellesximenes) :: Portuguese, Brazilian Magnus Jensen (MagnusHJensen) :: Danish Hesley Magno (hesleymagno) :: Portuguese, Brazilian Éric Gaspar (erga) :: French Fr3shlama :: German DSR :: Spanish, Argentina Andrii Bodnar (andrii-bodnar) :: Ukrainian Younes el Anjri (younesea28) :: Dutch Guclu Ozturk (gucluoz) :: Turkish Atmis :: French redjack666 :: Chinese Traditional Ashita007 :: Russian lihaorr :: Chinese Simplified Marcus Silber (marcus.silber82) :: German PellNet :: Croatian Winetradr :: German Sebastian Klaus (sebklaus) :: German Filip Antala (AntalaFilip) :: Slovak mcgong (GongMingCai) :: Chinese Simplified; Chinese Traditional Nanang Setia Budi (sefidananang) :: Indonesian Андрей Павлов (andrei.pavlov) :: Russian Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian Jihyeon Gim (PotatoGim) :: Korean Mihai Ochian (soulstorm19) :: Romanian HeartCore :: German Informal; German simon.pct :: French okaeiz :: Persian Naoto Ishikawa (na3shkw) :: Japanese sdhadi :: Persian DerLinkman (derlinkman) :: German; German Informal TurnArabic :: Arabic Martin Sebek (sebekmartin) :: Czech Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified digilady :: Greek Linus (LinusOP) :: Swedish Felipe Cardoso (felipecardosoruff) :: Portuguese, Brazilian RandomUser0815 :: German Informal; German Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian 구인회 (laskdjlaskdj12) :: Korean LiZerui (CNLiZerui) :: Chinese Traditional Fabrice Boyer (FabriceBoyer) :: French mikael (bitcanon) :: Swedish Matthias Mai (schnapsidee) :: German Informal; German Ufuk Ayyıldız (ufukayyildiz) :: Turkish Jan Mitrof (jan.kachlik) :: Czech edwardsmirnov :: Russian Mr_OSS117 :: French shotu :: French Cesar_Lopez_Aguillon :: Spanish bdewoop :: German dina davoudi (dina.davoudi) :: Persian Angelos Chouvardas (achouvardas) :: Greek rndrss :: Portuguese, Brazilian rirac294 :: Russian David Furman (thefourCraft) :: Hebrew Pafzedog :: French Yllelder :: Spanish Adrian Ocneanu (aocneanu) :: Romanian Eduardo Castanho (EduardoCastanho) :: Portuguese VIET NAM VPS (vietnamvps) :: Vietnamese m4tthi4s :: French toras9000 :: Japanese pathab :: German MichelSchoon85 :: Dutch Jøran Haugli (haugli92) :: Norwegian Bokmal Vasileios Kouvelis (VasilisKouvelis) :: Greek Dremski :: Bulgarian Frédéric SENE (nothingfr) :: French bendem :: French kostasdizas :: Greek Ricardo Schroeder (brownstone666) :: Portuguese, Brazilian Eitan MG (EitanMG) :: Hebrew Robin Flikkema (RobinFlikkema) :: Dutch Michal Gurcik (mgurcik) :: Slovak Pooyan Arab (pooyanarab) :: Persian Ochi Darma Putra (troke12) :: Indonesian Hsin-Hsiang Peng (Hsins) :: Chinese Traditional Mosi Wang (mosiwang) :: Chinese Traditional 骆言 (LawssssCat) :: Chinese Simplified Stickers Gaming Shøw (StickerSGSHOW) :: French Le Van Chinh (Chino) (lvanchinh86) :: Vietnamese Rubens nagios (rubenix) :: Catalan Patrick Dantas (pa-tiq) :: Portuguese, Brazilian Michal (michalgurcik) :: Slovak Nepomacs :: German Rubens (rubenix) :: Catalan m4z :: German; German Informal TheRazvy :: Romanian Yossi Zilber (lortens) :: Hebrew; Uzbek desdinova :: French Ingus Rūķis (ingus.rukis) :: Latvian Eugene Pershin (SilentEugene) :: Russian 周盛道 (zhoushengdao) :: Chinese Simplified hamidreza amini (hamidrezaamini2022) :: Persian Tomislav Kraljević (tomislav.kraljevic) :: Croatian Taygun Yıldırım (yildirimtaygun) :: Turkish robing29 :: German Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian Igor V Belousov (biv) :: Russian David Bauer (davbauer) :: German; German Informal Guttorm Hveem (guttormhveem) :: Norwegian Nynorsk; Norwegian Bokmal Minh Giang Truong (minhgiang1204) :: Vietnamese Ioannis Ioannides (i.ioannides) :: Greek Vadim (vadrozh) :: Russian Flip333 :: German Informal; German Paulo Henrique (paulohsantos114) :: Portuguese, Brazilian Dženan (Dzenan) :: Swedish Péter Péli (peter.peli) :: Hungarian TWME :: Chinese Traditional Sascha (Man-in-Black) :: German; German Informal Mohammadreza Madadi (madadi.efl) :: Persian Konstantin (kkovacheli) :: Ukrainian; Russian link1183 :: French Renan (rfpe) :: Portuguese, Brazilian Lowkey (bbsweb) :: Chinese Simplified ZZnOB (zznobzz) :: Russian rupus :: Swedish developernecsys :: Norwegian Nynorsk xuan LI (xuanli233) :: Chinese Simplified LameeQS :: Latvian Sorin T. (trimbitassorin) :: Romanian poesty :: Chinese Simplified balmag :: Hungarian Antti-Jussi Nygård (ajnyga) :: Finnish Eduard Ereza Martínez (Ereza) :: Catalan Jabir Lang (amar.almrad) :: Arabic Jaroslav Kobližek (foretix) :: Czech; French Wiktor Adamczyk (adamczyk.wiktor) :: Polish Abdulmajeed Alshuaibi (4Majeed) :: Arabic NotSmartZakk :: Czech HyoungMin Lee (ddokkaebi) :: Korean Dasferco :: Chinese Simplified Marcus Teräs (mteras) :: Finnish Serkan Yardim (serkanzz) :: Turkish Y (cnsr) :: Ukrainian ZY ZV (vy0b0x) :: Chinese Simplified diegobenitez :: Spanish Marc Hagen (MarcHagen) :: Dutch Kasper Alsøe (zeonos) :: Danish sultani :: Persian renge :: Korean Tim (thegatesdev) :: Dutch; German Informal; French; Romanian; Catalan; Czech; Danish; German; Finnish; Hungarian; Italian; Japanese; Korean; Polish; Russian; Ukrainian; Chinese Simplified; Chinese Traditional; Portuguese, Brazilian; Persian; Spanish, Argentina; Croatian; Norwegian Nynorsk; Estonian; Uzbek; Norwegian Bokmal Irdi (irdiOL) :: Albanian KateBarber :: Welsh Twister (theuncles75) :: Hebrew algernon19 :: Hungarian Ivan Krstic (ikrstic) :: Serbian (Cyrillic) Show :: Russian xBahamut :: Portuguese, Brazilian Pavle Knežević (pavleknezzevic) :: Serbian (Cyrillic) Vanja Cvelbar (b100w11) :: Slovenian simonpct :: French Honza Nagy (honza.nagy) :: Czech asd20752 :: Norwegian Bokmal Jan Picka (polipones) :: Czech diogoalex991 :: Portuguese Ehsan Sadeghi (ehsansadeghi) :: Persian ka_picit :: Danish cracrayol :: French CapuaSC :: Dutch Guardian75 :: German Informal mr-kanister :: German Michele Bastianelli (makoblaster) :: Italian jespernissen :: Danish Andrey (avmaksimov) :: Russian Gonzalo Loyola (AlFcl) :: Spanish, Argentina; Spanish grobert63 :: French wusst. (Supporti) :: German MaximMaximS :: Czech damian-klima :: Slovak crow_ :: Latvian JocelynDelalande :: French Jan (JW-CH) :: German Informal Timo B (lommes) :: German Informal Erik Lundstedt (Erik.Lundstedt) :: Swedish yngams (younessmouhid) :: Arabic Ohadp :: Hebrew cbridi :: Portuguese, Brazilian nanangsb :: Indonesian Michal Melich (michalmelich) :: Czech David (david-prv) :: German; German Informal Larry (lahoje) :: Swedish Marcia dos Santos (marciab80) :: Portuguese Ricard López Torres (richilpez.torres) :: Catalan sarahalves7 :: Portuguese, Brazilian petr.husak :: Czech javadataherian :: Persian Ludo-code :: French hollsten :: Swedish Ngoc Lan Phung (lanpncz) :: Vietnamese Worive :: Catalan; French Илья Скаба (skabailya) :: Russian Irjan Olsen (Irch) :: Norwegian Bokmal Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic) Red (RedVortex) :: Hebrew xgrug :: Chinese Simplified HrCalmar :: Danish Avishay Rapp (AvishayRapp) :: Hebrew matthias4217 :: French Berke BOYLU2 (berkeboylu2) :: Turkish etwas7B :: German Mohammed srhiri (m.sghiri20) :: Arabic YongMin Kim (kym0118) :: Korean Rivo Zängov (Eraser) :: Estonian Francisco Rafael Fonseca (chicoraf) :: Portuguese, Brazilian ИEØ_ΙΙØZ (NEO_IIOZ) :: Chinese Traditional madnjpn (madnjpn.) :: Georgian Ásgeir Shiny Ásgeirsson (AsgeirShiny) :: Icelandic Mohammad Aftab Uddin (chirohorit) :: Bengali Yannis Karlaftis (meliseus) :: Greek felixxx :: German Informal randi (randi65535) :: Korean test65428 :: Greek zeronell :: Chinese Simplified julien Vinber (julienVinber) :: French Hyunwoo Park (oksure) :: Korean aram.rafeq.7 (aramrafeq2) :: Kurdish Raphael Moreno (RaphaelMoreno) :: Portuguese, Brazilian yn (user99) :: Arabic Pavel Zlatarov (pzlatarov) :: Bulgarian ingelres :: French mabdullah :: Arabic Skrabák Csaba (kekcsi) :: Hungarian Evert Meulie (Evert) :: Norwegian Bokmal Jasper Backer (jasperb) :: Dutch Alexandar Cavdarovski (ace.200112) :: Swedish 구닥다리TV (yjj8353) :: Korean Onur Oskay (o.oskay) :: Turkish Sébastien Merveille (SebastienMerv) :: French Maxim Kouznetsov (masya.work) :: Hebrew neodvisnost :: Slovenian Soubi Agatsuma (bisouya) :: Hebrew Ilya Shaulov (ishaulov) :: Russian Konstantin Bobkov (b.konstantv) :: Russian Ruben Sutter (rubensutter) :: German jellium :: French Qxlkdr :: Swedish Hari (muhhari) :: Indonesian 仙君御 (xjy) :: Chinese Simplified TapioM :: Finnish lingb58 :: Chinese Traditional Angel Pandey (angel-pandey) :: Nepali Supriya Shrestha (supriyashrestha) :: Nepali gprabhat :: Nepali CellCat :: Chinese Simplified Al Desrahim (aldesrahim) :: Indonesian ahmad abbaspour (deshneh.dar.diss) :: Persian Erjon K. (ekr) :: Albanian LiZerui (iamzrli) :: Chinese Traditional Ticker (ticker.com) :: Hebrew CrazyComputer :: Chinese Simplified Firr (FirrV) :: Russian João Faro (FaroJoaoFaro) :: Portuguese Danilo dos Santos Barbosa (bozochegou) :: Portuguese, Brazilian Chris (furesoft) :: German Silvia Isern (eiendragon) :: Catalan Dennis Kron Pedersen (ahjdp) :: Danish iamwhoiamwhoami :: Swedish Grogui :: French MrCharlesIII :: Arabic David Olsen (dawin) :: Danish ltnzr :: French Frank Holler (holler.frank) :: German; German Informal Korab Arifi (korabidev) :: Albanian Petr Husák (petrhusak) :: Czech Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian Amr (amr3k) :: Arabic Tahsin Ahmed (tahsinahmed2012) :: Bengali bojan_che :: Serbian (Cyrillic) setiawan setiawan (culture.setiawan) :: Indonesian Donald Mac Kenzie (kiuman) :: Norwegian Bokmal Gabriel Silver (GabrielBSilver) :: Hebrew Tomas Darius Davainis (Tomasdd) :: Lithuanian CriedHero :: Chinese Simplified Henrik (henrik2105) :: Norwegian Bokmal FoW (fofwisdom) :: Korean serinf-lauza :: French Diyan Nikolaev (nikolaev.diyan) :: Bulgarian Shadluk Avan (quldosh) :: Uzbek Marci (MartonPoto) :: Hungarian Michał Sadurski (wheeskeey) :: Polish JanDziaslo :: Polish Charllys Fernandes (CharllysFernandes) :: Portuguese, Brazilian Ilgiz Zigangirov (inov8) :: Russian Max Israelsson (Blezie) :: Swedish ================================================ FILE: .github/workflows/analyse-php.yml ================================================ name: analyse-php on: push: paths: - '**.php' pull_request: paths: - '**.php' jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: 8.3 extensions: gd, mbstring, json, curl, xml, mysql, ldap - name: Get Composer Cache Directory id: composer-cache run: | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer packages uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-8.3 restore-keys: ${{ runner.os }}-composer- - name: Install composer dependencies run: composer install --prefer-dist --no-interaction --ansi - name: Run static analysis check run: composer check-static ================================================ FILE: .github/workflows/lint-js.yml ================================================ name: lint-js on: push: paths: - '**.js' - '**.json' pull_request: paths: - '**.js' - '**.json' jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Install NPM deps run: npm ci - name: Run formatting check run: npm run lint ================================================ FILE: .github/workflows/lint-php.yml ================================================ name: lint-php on: push: paths: - '**.php' pull_request: paths: - '**.php' jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: 8.3 tools: phpcs - name: Run formatting check run: composer lint ================================================ FILE: .github/workflows/test-js.yml ================================================ name: test-js on: push: paths: - '**.js' - '**.ts' - '**.json' pull_request: paths: - '**.js' - '**.ts' - '**.json' jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Install NPM deps run: npm ci - name: Run TypeScript type checking run: npm run ts:lint - name: Run JavaScript tests run: npm run test ================================================ FILE: .github/workflows/test-migrations.yml ================================================ name: test-migrations on: push: paths: - '**.php' - 'composer.*' pull_request: paths: - '**.php' - 'composer.*' jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} runs-on: ubuntu-24.04 strategy: matrix: php: ['8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: gd, mbstring, json, curl, xml, mysql, ldap - name: Get Composer Cache Directory id: composer-cache run: | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer packages uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ matrix.php }} restore-keys: ${{ runner.os }}-composer- - name: Start MySQL run: | sudo systemctl start mysql - name: Create database & user run: | mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;' mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';" mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';" mysql -uroot -proot -e 'FLUSH PRIVILEGES;' - name: Install composer dependencies run: composer install --prefer-dist --no-interaction --ansi - name: Start migration test run: | php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing - name: Start migration:rollback test run: | php${{ matrix.php }} artisan migrate:rollback --force -n --database=mysql_testing - name: Start migration rerun test run: | php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing ================================================ FILE: .github/workflows/test-php.yml ================================================ name: test-php on: push: paths: - '**.php' - 'composer.*' pull_request: paths: - '**.php' - 'composer.*' jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} runs-on: ubuntu-24.04 strategy: matrix: php: ['8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp - name: Get Composer Cache Directory id: composer-cache run: | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer packages uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ matrix.php }} restore-keys: ${{ runner.os }}-composer- - name: Start Database run: | sudo systemctl start mysql - name: Setup Database run: | mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;' mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';" mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';" mysql -uroot -proot -e 'FLUSH PRIVILEGES;' - name: Install composer dependencies run: composer install --prefer-dist --no-interaction --ansi - name: Migrate and seed the database run: | php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing - name: Run PHP tests run: php${{ matrix.php }} ./vendor/bin/phpunit ================================================ FILE: .gitignore ================================================ /vendor /node_modules /.vscode /composer /coverage Homestead.yaml .env .idea npm-debug.log yarn-error.log /public/dist /public/plugins /public/css /public/js /public/bower /public/build/ /public/favicon.ico /storage/images _ide_helper.php /storage/debugbar .phpstorm.meta.php yarn.lock /bin nbproject .buildpath .project .nvmrc .settings/ webpack-stats.json .phpunit.result.cache .DS_Store phpstan.neon esbuild-meta.json .phpactor.json /*.zip ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2015-2026, Dan Brown and the BookStack project contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: app/Access/Controllers/ConfirmEmailController.php ================================================ loginService->getLastLoginAttemptUser(); if ($user === null) { $this->showErrorNotification(trans('errors.login_user_not_found')); return redirect('/login'); } return view('auth.register-confirm-awaiting'); } /** * Show the form for a user to provide their positive confirmation of their email. */ public function showAcceptForm(string $token) { return view('auth.register-confirm-accept', ['token' => $token]); } /** * Confirms an email via a token and logs the user into the system. * * @throws ConfirmationEmailException * @throws Exception */ public function confirm(Request $request) { $validated = $this->validate($request, [ 'token' => ['required', 'string'] ]); $token = $validated['token']; try { $userId = $this->emailConfirmationService->checkTokenAndGetUserId($token); } catch (UserTokenNotFoundException $exception) { $this->showErrorNotification(trans('errors.email_confirmation_invalid')); return redirect('/register'); } catch (UserTokenExpiredException $exception) { $user = $this->userRepo->getById($exception->userId); $this->emailConfirmationService->sendConfirmation($user); $this->showErrorNotification(trans('errors.email_confirmation_expired')); return redirect('/register/confirm'); } $user = $this->userRepo->getById($userId); $user->email_confirmed = true; $user->save(); $this->emailConfirmationService->deleteByUser($user); $this->showSuccessNotification(trans('auth.email_confirm_success')); return redirect('/login'); } /** * Resend the confirmation email. */ public function resend() { $user = $this->loginService->getLastLoginAttemptUser(); if ($user === null) { $this->showErrorNotification(trans('errors.login_user_not_found')); return redirect('/login'); } try { $this->emailConfirmationService->sendConfirmation($user); } catch (ConfirmationEmailException $e) { $this->showErrorNotification($e->getMessage()); return redirect('/login'); } catch (Exception $e) { $this->showErrorNotification(trans('auth.email_confirm_send_error')); return redirect('/register/awaiting'); } $this->showSuccessNotification(trans('auth.email_confirm_resent')); return redirect('/register/confirm'); } } ================================================ FILE: app/Access/Controllers/ForgotPasswordController.php ================================================ middleware('guest'); $this->middleware('guard:standard'); } /** * Display the form to request a password reset link. */ public function showLinkRequestForm() { return view('auth.passwords.email'); } /** * Send a reset link to the given user. */ public function sendResetLinkEmail(Request $request) { $this->validate($request, [ 'email' => ['required', 'email'], ]); // Add random pause to the response to help avoid time-base sniffing // of valid resets via slower email send handling. Sleep::for(random_int(1000, 3000))->milliseconds(); // We will send the password reset link to this user. Once we have attempted // to send the link, we will examine the response then see the message we // need to show to the user. Finally, we'll send out a proper response. $response = Password::broker()->sendResetLink( $request->only('email') ); if ($response === Password::RESET_LINK_SENT) { $this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('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')]); $this->showSuccessNotification($message); return redirect('/password/email')->with('status', trans($response)); } // If an error was returned by the password broker, we will get this message // translated so we can notify a user of the problem. We'll redirect back // to where the users came from so they can attempt this process again. return redirect('/password/email')->withErrors( ['email' => trans($response)] ); } } ================================================ FILE: app/Access/Controllers/HandlesPartialLogins.php ================================================ make(LoginService::class); $user = auth()->user() ?? $loginService->getLastLoginAttemptUser(); if (!$user) { throw new NotFoundException(trans('errors.login_user_not_found')); } return $user; } } ================================================ FILE: app/Access/Controllers/LoginController.php ================================================ middleware('guest', ['only' => ['getLogin', 'login']]); $this->middleware('guard:standard,ldap', ['only' => ['login']]); $this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]); } /** * Show the application login form. */ public function getLogin(Request $request) { $socialDrivers = $this->socialDriverManager->getActive(); $authMethod = config('auth.method'); $preventInitiation = $request->get('prevent_auto_init') === 'true'; if ($request->has('email')) { session()->flashInput([ 'email' => $request->get('email'), 'password' => (config('app.env') === 'demo') ? $request->get('password', '') : '', ]); } // Store the previous location for redirect after login $this->updateIntendedFromPrevious(); if (!$preventInitiation && $this->loginService->shouldAutoInitiate()) { return view('auth.login-initiate', [ 'authMethod' => $authMethod, ]); } return view('auth.login', [ 'socialDrivers' => $socialDrivers, 'authMethod' => $authMethod, ]); } /** * Handle a login request to the application. */ public function login(Request $request) { $this->validateLogin($request); $username = $request->get($this->username()); // Check login throttling attempts to see if they've gone over the limit if ($this->hasTooManyLoginAttempts($request)) { Activity::logFailedLogin($username); return $this->sendLockoutResponse($request); } try { if ($this->attemptLogin($request)) { return $this->sendLoginResponse($request); } } catch (LoginAttemptException $exception) { Activity::logFailedLogin($username); return $this->sendLoginAttemptExceptionResponse($exception, $request); } // On unsuccessful login attempt, Increment login attempts for throttling and log failed login. $this->incrementLoginAttempts($request); Activity::logFailedLogin($username); // Throw validation failure for failed login throw ValidationException::withMessages([ $this->username() => [trans('auth.failed')], ])->redirectTo('/login'); } /** * Logout user and perform subsequent redirect. */ public function logout() { return redirect($this->loginService->logout()); } /** * Get the expected username input based upon the current auth method. */ protected function username(): string { return config('auth.method') === 'standard' ? 'email' : 'username'; } /** * Get the needed authorization credentials from the request. */ protected function credentials(Request $request): array { return $request->only('username', 'email', 'password'); } /** * Send the response after the user was authenticated. * @return RedirectResponse */ protected function sendLoginResponse(Request $request) { $request->session()->regenerate(); $this->clearLoginAttempts($request); return redirect()->intended('/'); } /** * Attempt to log the user into the application. */ protected function attemptLogin(Request $request): bool { return $this->loginService->attempt( $this->credentials($request), auth()->getDefaultDriver(), $request->filled('remember') ); } /** * Validate the user login request. * @throws ValidationException */ protected function validateLogin(Request $request): void { $rules = ['password' => ['required', 'string']]; $authMethod = config('auth.method'); if ($authMethod === 'standard') { $rules['email'] = ['required', 'email']; } if ($authMethod === 'ldap') { $rules['username'] = ['required', 'string']; $rules['email'] = ['email']; } $request->validate($rules); } /** * Send a response when a login attempt exception occurs. */ protected function sendLoginAttemptExceptionResponse(LoginAttemptException $exception, Request $request) { if ($exception instanceof LoginAttemptEmailNeededException) { $request->flash(); session()->flash('request-email', true); } if ($message = $exception->getMessage()) { $this->showWarningNotification($message); } return redirect('/login'); } /** * Update the intended URL location from their previous URL. * Ignores if not from the current app instance or if from certain * login or authentication routes. */ protected function updateIntendedFromPrevious(): void { // Store the previous location for redirect after login $previous = url()->previous(''); $isPreviousFromInstance = str_starts_with($previous, url('/')); if (!$previous || !setting('app-public') || !$isPreviousFromInstance) { return; } $ignorePrefixList = [ '/login', '/mfa', ]; foreach ($ignorePrefixList as $ignorePrefix) { if (str_starts_with($previous, url($ignorePrefix))) { return; } } redirect()->setIntendedUrl($previous); } } ================================================ FILE: app/Access/Controllers/MfaBackupCodesController.php ================================================ generateNewSet(); session()->put(self::SETUP_SECRET_SESSION_KEY, encrypt($codes)); $downloadUrl = 'data:application/octet-stream;base64,' . base64_encode(implode("\n\n", $codes)); $this->setPageTitle(trans('auth.mfa_gen_backup_codes_title')); return view('mfa.backup-codes-generate', [ 'codes' => $codes, 'downloadUrl' => $downloadUrl, ]); } /** * Confirm the setup of backup codes, storing them against the user. * * @throws Exception */ public function confirm() { if (!session()->has(self::SETUP_SECRET_SESSION_KEY)) { return response('No generated codes found in the session', 500); } $codes = decrypt(session()->pull(self::SETUP_SECRET_SESSION_KEY)); MfaValue::upsertWithValue($this->currentOrLastAttemptedUser(), MfaValue::METHOD_BACKUP_CODES, json_encode($codes)); $this->logActivity(ActivityType::MFA_SETUP_METHOD, 'backup-codes'); if (!auth()->check()) { $this->showSuccessNotification(trans('auth.mfa_setup_login_notification')); return redirect('/login'); } return redirect('/mfa/setup'); } /** * Verify the MFA method submission on check. * * @throws NotFoundException * @throws ValidationException */ public function verify(Request $request, BackupCodeService $codeService, MfaSession $mfaSession, LoginService $loginService) { $user = $this->currentOrLastAttemptedUser(); $codes = MfaValue::getValueForUser($user, MfaValue::METHOD_BACKUP_CODES) ?? '[]'; $this->validate($request, [ 'code' => [ 'required', 'max:12', 'min:8', function ($attribute, $value, $fail) use ($codeService, $codes) { if (!$codeService->inputCodeExistsInSet($value, $codes)) { $fail(trans('validation.backup_codes')); } }, ], ]); $updatedCodes = $codeService->removeInputCodeFromSet($request->get('code'), $codes); MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, $updatedCodes); $mfaSession->markVerifiedForUser($user); $loginService->reattemptLoginFor($user); if ($codeService->countCodesInSet($updatedCodes) < 5) { $this->showWarningNotification(trans('auth.mfa_backup_codes_usage_limit_warning')); } return redirect()->intended(); } } ================================================ FILE: app/Access/Controllers/MfaController.php ================================================ currentOrLastAttemptedUser() ->mfaValues() ->get(['id', 'method']) ->groupBy('method'); $this->setPageTitle(trans('auth.mfa_setup')); return view('mfa.setup', [ 'userMethods' => $userMethods, ]); } /** * Remove an MFA method for the current user. * * @throws \Exception */ public function remove(string $method) { if (in_array($method, MfaValue::allMethods())) { $value = user()->mfaValues()->where('method', '=', $method)->first(); if ($value) { $value->delete(); $this->logActivity(ActivityType::MFA_REMOVE_METHOD, $method); } } return redirect('/mfa/setup'); } /** * Show the page to start an MFA verification. */ public function verify(Request $request) { $desiredMethod = $request->get('method'); $userMethods = $this->currentOrLastAttemptedUser() ->mfaValues() ->get(['id', 'method']) ->groupBy('method'); // Basic search for the default option for a user. // (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; })->all(); return view('mfa.verify', [ 'userMethods' => $userMethods, 'method' => $method, 'otherMethods' => $otherMethods, ]); } } ================================================ FILE: app/Access/Controllers/MfaTotpController.php ================================================ has(static::SETUP_SECRET_SESSION_KEY)) { $totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY)); } else { $totpSecret = $this->totp->generateSecret(); session()->put(static::SETUP_SECRET_SESSION_KEY, encrypt($totpSecret)); } $qrCodeUrl = $this->totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser()); $svg = $this->totp->generateQrCodeSvg($qrCodeUrl); $this->setPageTitle(trans('auth.mfa_gen_totp_title')); return view('mfa.totp-generate', [ 'url' => $qrCodeUrl, 'svg' => $svg, ]); } /** * Confirm the setup of TOTP and save the auth method secret * against the current user. * * @throws ValidationException * @throws NotFoundException */ public function confirm(Request $request) { $totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY)); $this->validate($request, [ 'code' => [ 'required', 'max:12', 'min:4', new TotpValidationRule($totpSecret, $this->totp), ], ]); MfaValue::upsertWithValue($this->currentOrLastAttemptedUser(), MfaValue::METHOD_TOTP, $totpSecret); session()->remove(static::SETUP_SECRET_SESSION_KEY); $this->logActivity(ActivityType::MFA_SETUP_METHOD, 'totp'); if (!auth()->check()) { $this->showSuccessNotification(trans('auth.mfa_setup_login_notification')); return redirect('/login'); } return redirect('/mfa/setup'); } /** * Verify the MFA method submission on check. * * @throws NotFoundException */ public function verify(Request $request, LoginService $loginService, MfaSession $mfaSession) { $user = $this->currentOrLastAttemptedUser(); $totpSecret = MfaValue::getValueForUser($user, MfaValue::METHOD_TOTP); $this->validate($request, [ 'code' => [ 'required', 'max:12', 'min:4', new TotpValidationRule($totpSecret, $this->totp), ], ]); $mfaSession->markVerifiedForUser($user); $loginService->reattemptLoginFor($user); return redirect()->intended(); } } ================================================ FILE: app/Access/Controllers/OidcController.php ================================================ middleware('guard:oidc'); } /** * Start the authorization login flow via OIDC. */ public function login() { try { $loginDetails = $this->oidcService->login(); } catch (OidcException $exception) { $this->showErrorNotification($exception->getMessage()); return redirect('/login'); } session()->put('oidc_state', time() . ':' . $loginDetails['state']); return redirect($loginDetails['url']); } /** * Authorization flow redirect callback. * Processes authorization response from the OIDC Authorization Server. */ public function callback(Request $request) { $responseState = $request->query('state'); $splitState = explode(':', session()->pull('oidc_state', ':'), 2); if (count($splitState) !== 2) { $splitState = [null, null]; } [$storedStateTime, $storedState] = $splitState; $threeMinutesAgo = time() - 3 * 60; if (!$storedState || $storedState !== $responseState || intval($storedStateTime) < $threeMinutesAgo) { $this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')])); return redirect('/login'); } try { $this->oidcService->processAuthorizeResponse($request->query('code')); } catch (OidcException $oidcException) { $this->showErrorNotification($oidcException->getMessage()); return redirect('/login'); } return redirect()->intended(); } /** * Log the user out, then start the OIDC RP-initiated logout process. */ public function logout() { return redirect($this->oidcService->logout()); } } ================================================ FILE: app/Access/Controllers/RegisterController.php ================================================ middleware('guest'); $this->middleware('guard:standard'); } /** * Show the application registration form. * * @throws UserRegistrationException */ public function getRegister() { $this->registrationService->ensureRegistrationAllowed(); $socialDrivers = $this->socialDriverManager->getActive(); return view('auth.register', [ 'socialDrivers' => $socialDrivers, ]); } /** * Handle a registration request for the application. * * @throws UserRegistrationException * @throws StoppedAuthenticationException */ public function postRegister(Request $request) { $this->registrationService->ensureRegistrationAllowed(); $this->validator($request->all())->validate(); $userData = $request->all(); try { $user = $this->registrationService->registerUser($userData); $this->loginService->login($user, auth()->getDefaultDriver()); } catch (UserRegistrationException $exception) { if ($exception->getMessage()) { $this->showErrorNotification($exception->getMessage()); } return redirect($exception->redirectLocation); } $this->showSuccessNotification(trans('auth.register_success')); return redirect('/'); } /** * Get a validator for an incoming registration request. */ protected function validator(array $data): ValidatorContract { return Validator::make($data, [ 'name' => ['required', 'min:2', 'max:100'], 'email' => ['required', 'email', 'max:255', 'unique:users'], 'password' => ['required', Password::default()], // Basic honey for bots that must not be filled in 'username' => ['prohibited'], ]); } } ================================================ FILE: app/Access/Controllers/ResetPasswordController.php ================================================ middleware('guest'); $this->middleware('guard:standard'); } /** * Display the password reset view for the given token. * If no token is present, display the link request form. */ public function showResetForm(Request $request) { $token = $request->route()->parameter('token'); return view('auth.passwords.reset')->with( ['token' => $token, 'email' => $request->email] ); } /** * Reset the given user's password. */ public function reset(Request $request) { $request->validate([ 'token' => 'required', 'email' => 'required|email', 'password' => ['required', 'confirmed', PasswordRule::defaults()], ]); // 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. $credentials = $request->only('email', 'password', 'password_confirmation', 'token'); $response = Password::broker()->reset($credentials, function (User $user, string $password) { $user->password = Hash::make($password); $user->setRememberToken(Str::random(60)); $user->save(); $this->loginService->login($user, auth()->getDefaultDriver()); }); // If the password was successfully reset, we will redirect the user back to // the application's home authenticated view. If there is an error we can // 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')); } /** * Get the response for a successful password reset. */ protected function sendResetResponse(): RedirectResponse { $this->showSuccessNotification(trans('auth.reset_password_success')); $this->logActivity(ActivityType::AUTH_PASSWORD_RESET_UPDATE, user()); return redirect('/'); } /** * Get the response for a failed password reset. */ protected function sendResetFailedResponse(Request $request, string $response, string $token): RedirectResponse { // We show invalid users as invalid tokens as to not leak what // users may exist in the system. if ($response === Password::INVALID_USER) { $response = Password::INVALID_TOKEN; } return redirect("/password/reset/{$token}") ->withInput($request->only('email')) ->withErrors(['email' => trans($response)]); } } ================================================ FILE: app/Access/Controllers/Saml2Controller.php ================================================ middleware('guard:saml2'); } /** * Start the login flow via SAML2. */ public function login() { $loginDetails = $this->samlService->login(); session()->flash('saml2_request_id', $loginDetails['id']); return redirect($loginDetails['url']); } /** * Start the logout flow via SAML2. */ public function logout() { $user = user(); if ($user->isGuest()) { return redirect('/login'); } $logoutDetails = $this->samlService->logout($user); if ($logoutDetails['id']) { session()->flash('saml2_logout_request_id', $logoutDetails['id']); } return redirect($logoutDetails['url']); } /* * Get the metadata for this SAML2 service provider. */ public function metadata() { $metaData = $this->samlService->metadata(); return response()->make($metaData, 200, [ 'Content-Type' => 'text/xml', ]); } /** * Single logout service. * Handle logout requests and responses. */ public function sls() { $requestId = session()->pull('saml2_logout_request_id', null); $redirect = $this->samlService->processSlsResponse($requestId); return redirect($redirect); } /** * Assertion Consumer Service start URL. Takes the SAMLResponse from the IDP. * Due to being an external POST request, we likely won't have context of the * current user session due to lax cookies. To work around this we store the * SAMLResponse data and redirect to the processAcs endpoint for the actual * processing of the request with proper context of the user session. */ public function startAcs(Request $request) { $samlResponse = $request->get('SAMLResponse', null); if (empty($samlResponse)) { $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')])); return redirect('/login'); } $acsId = Str::random(16); $cacheKey = 'saml2_acs:' . $acsId; cache()->set($cacheKey, encrypt($samlResponse), 10); return redirect()->guest('/saml2/acs?id=' . $acsId); } /** * Assertion Consumer Service process endpoint. * Processes the SAML response from the IDP with context of the current session. * Takes the SAML request from the cache, added by the startAcs method above. */ public function processAcs(Request $request) { $acsId = $request->get('id', null); $cacheKey = 'saml2_acs:' . $acsId; $samlResponse = null; try { $samlResponse = decrypt(cache()->pull($cacheKey)); } catch (\Exception $exception) { } $requestId = session()->pull('saml2_request_id', null); if (empty($acsId) || empty($samlResponse)) { $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')])); return redirect('/login'); } $user = $this->samlService->processAcsResponse($requestId, $samlResponse); if (is_null($user)) { $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')])); return redirect('/login'); } return redirect()->intended(); } } ================================================ FILE: app/Access/Controllers/SocialController.php ================================================ middleware('guest')->only(['register']); } /** * Redirect to the relevant social site. * * @throws SocialDriverNotConfigured */ public function login(string $socialDriver) { session()->put('social-callback', 'login'); return $this->socialAuthService->startLogIn($socialDriver); } /** * Redirect to the social site for authentication intended to register. * * @throws SocialDriverNotConfigured * @throws UserRegistrationException */ public function register(string $socialDriver) { $this->registrationService->ensureRegistrationAllowed(); session()->put('social-callback', 'register'); return $this->socialAuthService->startRegister($socialDriver); } /** * The callback for social login services. * * @throws SocialSignInException * @throws SocialDriverNotConfigured * @throws UserRegistrationException */ public function callback(Request $request, string $socialDriver) { if (!session()->has('social-callback')) { throw new SocialSignInException(trans('errors.social_no_action_defined'), '/login'); } // Check request for error information if ($request->has('error') && $request->has('error_description')) { throw new SocialSignInException(trans('errors.social_login_bad_response', [ 'socialAccount' => $socialDriver, 'error' => $request->get('error_description'), ]), '/login'); } $action = session()->pull('social-callback'); // Attempt login or fall-back to register if allowed. $socialUser = $this->socialAuthService->getSocialUser($socialDriver); if ($action === 'login') { try { return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser); } catch (SocialSignInAccountNotUsed $exception) { if ($this->socialAuthService->drivers()->isAutoRegisterEnabled($socialDriver)) { return $this->socialRegisterCallback($socialDriver, $socialUser); } throw $exception; } } if ($action === 'register') { return $this->socialRegisterCallback($socialDriver, $socialUser); } return redirect('/'); } /** * Detach a social account from a user. */ public function detach(string $socialDriver) { $this->socialAuthService->detachSocialAccount($socialDriver); session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => Str::title($socialDriver)])); return redirect('/my-account/auth#social-accounts'); } /** * Register a new user after a registration callback. * * @throws UserRegistrationException */ protected function socialRegisterCallback(string $socialDriver, SocialUser $socialUser) { $socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser); $socialAccount = $this->socialAuthService->newSocialAccount($socialDriver, $socialUser); $emailVerified = $this->socialAuthService->drivers()->isAutoConfirmEmailEnabled($socialDriver); // Create an array of the user data to create a new user instance $userData = [ 'name' => $socialUser->getName(), 'email' => $socialUser->getEmail(), 'password' => Str::random(32), ]; // Take name from email address if empty if (!$userData['name']) { $userData['name'] = explode('@', $userData['email'])[0]; } $user = $this->registrationService->registerUser($userData, $socialAccount, $emailVerified); $this->showSuccessNotification(trans('auth.register_success')); $this->loginService->login($user, $socialDriver); return redirect('/'); } } ================================================ FILE: app/Access/Controllers/ThrottlesLogins.php ================================================ limiter()->tooManyAttempts( $this->throttleKey($request), $this->maxAttempts() ); } /** * Increment the login attempts for the user. */ protected function incrementLoginAttempts(Request $request): void { $this->limiter()->hit( $this->throttleKey($request), $this->decayMinutes() * 60 ); } /** * Redirect the user after determining they are locked out. * @throws ValidationException */ protected function sendLockoutResponse(Request $request): \Symfony\Component\HttpFoundation\Response { $seconds = $this->limiter()->availableIn( $this->throttleKey($request) ); throw ValidationException::withMessages([ $this->username() => [trans('auth.throttle', [ 'seconds' => $seconds, 'minutes' => ceil($seconds / 60), ])], ])->status(Response::HTTP_TOO_MANY_REQUESTS); } /** * Clear the login locks for the given user credentials. */ protected function clearLoginAttempts(Request $request): void { $this->limiter()->clear($this->throttleKey($request)); } /** * Get the throttle key for the given request. */ protected function throttleKey(Request $request): string { return Str::transliterate(Str::lower($request->input($this->username())) . '|' . $request->ip()); } /** * Get the rate limiter instance. */ protected function limiter(): RateLimiter { return app()->make(RateLimiter::class); } /** * Get the maximum number of attempts to allow. */ public function maxAttempts(): int { return 5; } /** * Get the number of minutes to throttle for. */ public function decayMinutes(): int { return 1; } } ================================================ FILE: app/Access/Controllers/UserInviteController.php ================================================ middleware('guest'); $this->middleware('guard:standard'); $this->inviteService = $inviteService; $this->userRepo = $userRepo; } /** * Show the page for the user to set the password for their account. * * @throws Exception */ public function showSetPassword(string $token) { try { $this->inviteService->checkTokenAndGetUserId($token); } catch (Exception $exception) { return $this->handleTokenException($exception); } return view('auth.invite-set-password', [ 'token' => $token, ]); } /** * Sets the password for an invited user and then grants them access. * * @throws Exception */ public function setPassword(Request $request, string $token) { $this->validate($request, [ 'password' => ['required', Password::default()], ]); try { $userId = $this->inviteService->checkTokenAndGetUserId($token); } catch (Exception $exception) { return $this->handleTokenException($exception); } $user = $this->userRepo->getById($userId); $user->password = Hash::make($request->get('password')); $user->email_confirmed = true; $user->save(); $this->inviteService->deleteByUser($user); $this->showSuccessNotification(trans('auth.user_invite_success_login', ['appName' => setting('app-name')])); return redirect('/login'); } /** * Check and validate the exception thrown when checking an invite token. * * @throws Exception * * @return RedirectResponse|Redirector */ protected function handleTokenException(Exception $exception) { if ($exception instanceof UserTokenNotFoundException) { return redirect('/'); } if ($exception instanceof UserTokenExpiredException) { $this->showErrorNotification(trans('errors.invite_token_expired')); return redirect('/password/email'); } throw $exception; } } ================================================ FILE: app/Access/EmailConfirmationService.php ================================================ email_confirmed) { throw new ConfirmationEmailException(trans('errors.email_already_confirmed'), '/login'); } $this->deleteByUser($user); $token = $this->createTokenForUser($user); $user->notify(new ConfirmEmailNotification($token)); } /** * Check if confirmation is required in this instance. */ public function confirmationRequired(): bool { return setting('registration-confirmation') || setting('registration-restrict'); } } ================================================ FILE: app/Access/ExternalBaseUserProvider.php ================================================ find($identifier); } /** * Retrieve a user by their unique identifier and "remember me" token. * * @param string $token */ public function retrieveByToken(mixed $identifier, $token): null { return null; } /** * Update the "remember me" token for the given user in storage. * * @param Authenticatable $user * @param string $token * * @return void */ public function updateRememberToken(Authenticatable $user, $token) { // } /** * Retrieve a user by the given credentials. */ public function retrieveByCredentials(array $credentials): ?Authenticatable { return User::query() ->where('external_auth_id', $credentials['external_auth_id']) ->first(); } /** * Validate a user against the given credentials. */ public function validateCredentials(Authenticatable $user, array $credentials): bool { // Should be done in the guard. return false; } public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false) { // No action to perform, any passwords are external in the auth system } } ================================================ FILE: app/Access/GroupSyncService.php ================================================ external_auth_id) { return $this->externalIdMatchesGroupNames($role->external_auth_id, $groupNames); } $roleName = str_replace(' ', '-', trim(strtolower($role->display_name))); return in_array($roleName, $groupNames); } /** * Check if the given external auth ID string matches one of the given group names. */ protected function externalIdMatchesGroupNames(string $externalId, array $groupNames): bool { foreach ($this->parseRoleExternalAuthId($externalId) as $externalAuthId) { if (in_array($externalAuthId, $groupNames)) { return true; } } return false; } protected function parseRoleExternalAuthId(string $externalId): array { $inputIds = preg_split('/(? $groupName) { $groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName))); } $roles = Role::query()->get(['id', 'external_auth_id', 'display_name']); $matchedRoles = $roles->filter(function (Role $role) use ($groupNames) { return $this->roleMatchesGroupNames($role, $groupNames); }); return $matchedRoles->pluck('id'); } /** * Sync the groups to the user roles for the current user. */ public function syncUserWithFoundGroups(User $user, array $userGroups, bool $detachExisting): void { // Get the ids for the roles from the names $groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups); // Sync groups if ($detachExisting) { $user->roles()->sync($groupsAsRoles); $user->attachDefaultRole(); } else { $user->roles()->syncWithoutDetaching($groupsAsRoles); } } } ================================================ FILE: app/Access/Guards/AsyncExternalBaseSessionGuard.php ================================================ name = $name; $this->session = $session; $this->provider = $provider; $this->registrationService = $registrationService; } /** * Get the currently authenticated user. */ public function user(): Authenticatable|null { if ($this->loggedOut) { return null; } // If we've already retrieved the user for the current request we can just // return it back immediately. We do not want to fetch the user data on // every call to this method because that would be tremendously slow. if (!is_null($this->user)) { return $this->user; } $id = $this->session->get($this->getName()); // First we will try to load the user using the // identifier in the session if one exists. if (!is_null($id)) { $this->user = $this->provider->retrieveById($id); } return $this->user; } /** * Get the ID for the currently authenticated user. */ public function id(): int|null { if ($this->loggedOut) { return null; } return $this->user() ? $this->user()->getAuthIdentifier() : $this->session->get($this->getName()); } /** * Log a user into the application without sessions or cookies. */ public function once(array $credentials = []): bool { if ($this->validate($credentials)) { $this->setUser($this->lastAttempted); return true; } return false; } /** * Log the given user ID into the application without sessions or cookies. */ public function onceUsingId($id): Authenticatable|false { if (!is_null($user = $this->provider->retrieveById($id))) { $this->setUser($user); return $user; } return false; } /** * Validate a user's credentials. */ public function validate(array $credentials = []): bool { return false; } /** * Attempt to authenticate a user using the given credentials. * @param bool $remember */ public function attempt(array $credentials = [], $remember = false): bool { return false; } /** * Log the given user ID into the application. * @param bool $remember */ public function loginUsingId(mixed $id, $remember = false): Authenticatable|false { // Always return false as to disable this method, // Logins should route through LoginService. return false; } /** * Log a user into the application. * * @param bool $remember */ public function login(Authenticatable $user, $remember = false): void { $this->updateSession($user->getAuthIdentifier()); $this->setUser($user); } /** * Update the session with the given ID. */ protected function updateSession(string|int $id): void { $this->session->put($this->getName(), $id); $this->session->migrate(true); } /** * Log the user out of the application. */ public function logout(): void { $this->clearUserDataFromStorage(); // Now we will clear the users out of memory so they are no longer available // as the user is no longer considered as being signed into this // application and should not be available here. $this->user = null; $this->loggedOut = true; } /** * Remove the user data from the session and cookies. */ protected function clearUserDataFromStorage(): void { $this->session->remove($this->getName()); } /** * Get the last user we attempted to authenticate. */ public function getLastAttempted(): Authenticatable { return $this->lastAttempted; } /** * Get a unique identifier for the auth session value. */ public function getName(): string { return 'login_' . $this->name . '_' . sha1(static::class); } /** * Determine if the user was authenticated via "remember me" cookie. */ public function viaRemember(): bool { return false; } /** * Return the currently cached user. */ public function getUser(): Authenticatable|null { return $this->user; } /** * Set the current user. */ public function setUser(Authenticatable $user): self { $this->user = $user; $this->loggedOut = false; return $this; } } ================================================ FILE: app/Access/Guards/LdapSessionGuard.php ================================================ ldapService = $ldapService; parent::__construct($name, $provider, $session, $registrationService); } /** * Validate a user's credentials. * * @throws LdapException */ public function validate(array $credentials = []): bool { $userDetails = $this->ldapService->getUserDetails($credentials['username']); if (isset($userDetails['uid'])) { $this->lastAttempted = $this->provider->retrieveByCredentials([ 'external_auth_id' => $userDetails['uid'], ]); } return $this->ldapService->validateUserCredentials($userDetails, $credentials['password']); } /** * Attempt to authenticate a user using the given credentials. * * @param bool $remember * * @throws LdapException * @throws LoginAttemptException * @throws JsonDebugException */ public function attempt(array $credentials = [], $remember = false): bool { $username = $credentials['username']; $userDetails = $this->ldapService->getUserDetails($username); $user = null; if (isset($userDetails['uid'])) { $this->lastAttempted = $user = $this->provider->retrieveByCredentials([ 'external_auth_id' => $userDetails['uid'], ]); } if (!$this->ldapService->validateUserCredentials($userDetails, $credentials['password'])) { return false; } if (is_null($user)) { try { $user = $this->createNewFromLdapAndCreds($userDetails, $credentials); } catch (UserRegistrationException $exception) { throw new LoginAttemptException($exception->getMessage()); } } // Sync LDAP groups if required if ($this->ldapService->shouldSyncGroups()) { $this->ldapService->syncGroups($user, $username); } // Attach avatar if non-existent if (!$user->avatar()->exists()) { $this->ldapService->saveAndAttachAvatar($user, $userDetails); } $this->login($user, $remember); return true; } /** * Create a new user from the given ldap credentials and login credentials. * * @throws LoginAttemptEmailNeededException * @throws LoginAttemptException * @throws UserRegistrationException */ protected function createNewFromLdapAndCreds(array $ldapUserDetails, array $credentials): User { $email = trim($ldapUserDetails['email'] ?: ($credentials['email'] ?? '')); if (empty($email)) { throw new LoginAttemptEmailNeededException(); } $details = [ 'name' => $ldapUserDetails['name'], 'email' => $ldapUserDetails['email'] ?: $credentials['email'], 'external_auth_id' => $ldapUserDetails['uid'], 'password' => Str::random(32), ]; $user = $this->registrationService->registerUser($details, null, false); $this->ldapService->saveAndAttachAvatar($user, $ldapUserDetails); return $user; } } ================================================ FILE: app/Access/Ldap.php ================================================ setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version); } /** * Search LDAP tree using the provided filter. * * @param resource|\LDAP\Connection $ldapConnection * * @return \LDAP\Result|array|false */ public function search($ldapConnection, string $baseDn, string $filter, array $attributes = []) { return ldap_search($ldapConnection, $baseDn, $filter, $attributes); } /** * Read an entry from the LDAP tree. * * @param resource|\Ldap\Connection $ldapConnection * * @return \LDAP\Result|array|false */ public function read($ldapConnection, string $baseDn, string $filter, array $attributes = []) { return ldap_read($ldapConnection, $baseDn, $filter, $attributes); } /** * Get entries from an LDAP search result. * * @param resource|\LDAP\Connection $ldapConnection * @param resource|\LDAP\Result $ldapSearchResult */ public function getEntries($ldapConnection, $ldapSearchResult): array|false { return ldap_get_entries($ldapConnection, $ldapSearchResult); } /** * Search and get entries immediately. * * @param resource|\LDAP\Connection $ldapConnection */ public function searchAndGetEntries($ldapConnection, string $baseDn, string $filter, array $attributes = []): array|false { $search = $this->search($ldapConnection, $baseDn, $filter, $attributes); return $this->getEntries($ldapConnection, $search); } /** * Bind to LDAP directory. * * @param resource|\LDAP\Connection $ldapConnection */ public function bind($ldapConnection, ?string $bindRdn = null, ?string $bindPassword = null): bool { return ldap_bind($ldapConnection, $bindRdn, $bindPassword); } /** * Explode an LDAP dn string into an array of components. */ public function explodeDn(string $dn, int $withAttrib): array|false { return ldap_explode_dn($dn, $withAttrib); } /** * Escape a string for use in an LDAP filter. */ public function escape(string $value, string $ignore = '', int $flags = 0): string { return ldap_escape($value, $ignore, $flags); } } ================================================ FILE: app/Access/LdapService.php ================================================ config = config('services.ldap'); $this->enabled = config('auth.method') === 'ldap'; } /** * Check if groups should be synced. */ public function shouldSyncGroups(): bool { return $this->enabled && $this->config['user_to_groups'] !== false; } /** * Search for attributes for a specific user on the ldap. * * @throws LdapException */ private function getUserWithAttributes(string $userName, array $attributes): ?array { $ldapConnection = $this->getConnection(); $this->bindSystemUser($ldapConnection); // Clean attributes foreach ($attributes as $index => $attribute) { if (str_starts_with($attribute, 'BIN;')) { $attributes[$index] = substr($attribute, strlen('BIN;')); } } // Find user $userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]); $baseDn = $this->config['base_dn']; $followReferrals = $this->config['follow_referrals'] ? 1 : 0; $this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals); $users = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $userFilter, $attributes); if ($users['count'] === 0) { return null; } return $users[0]; } /** * Build the user display name from the (potentially multiple) attributes defined by the configuration. */ protected function getUserDisplayName(array $userDetails, array $displayNameAttrs, string $defaultValue): string { $displayNameParts = []; foreach ($displayNameAttrs as $dnAttr) { $dnComponent = $this->getUserResponseProperty($userDetails, $dnAttr, null); if ($dnComponent) { $displayNameParts[] = $dnComponent; } } if (empty($displayNameParts)) { return $defaultValue; } return implode(' ', $displayNameParts); } /** * Get the details of a user from LDAP using the given username. * User found via configurable user filter. * * @throws LdapException|JsonDebugException */ public function getUserDetails(string $userName): ?array { $idAttr = $this->config['id_attribute']; $emailAttr = $this->config['email_attribute']; $displayNameAttrs = explode('|', $this->config['display_name_attribute']); $thumbnailAttr = $this->config['thumbnail_attribute']; $user = $this->getUserWithAttributes($userName, array_filter([ 'cn', 'dn', $idAttr, $emailAttr, ...$displayNameAttrs, $thumbnailAttr, ])); if (is_null($user)) { return null; } $nameDefault = $this->getUserResponseProperty($user, 'cn', null); if (is_null($nameDefault)) { $nameDefault = ldap_explode_dn($user['dn'], 1)[0] ?? $user['dn']; } $formatted = [ 'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']), 'name' => $this->getUserDisplayName($user, $displayNameAttrs, $nameDefault), 'dn' => $user['dn'], 'email' => $this->getUserResponseProperty($user, $emailAttr, null), 'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null, ]; if ($this->config['dump_user_details']) { throw new JsonDebugException([ 'details_from_ldap' => $user, 'details_bookstack_parsed' => $formatted, ]); } return $formatted; } /** * Get a property from an LDAP user response fetch. * Handles properties potentially being part of an array. * If the given key is prefixed with 'BIN;', that indicator will be stripped * from the key and any fetched values will be converted from binary to hex. */ protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue) { $isBinary = str_starts_with($propertyKey, 'BIN;'); $propertyKey = strtolower($propertyKey); $value = $defaultValue; if ($isBinary) { $propertyKey = substr($propertyKey, strlen('BIN;')); } if (isset($userDetails[$propertyKey])) { $value = (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]); if ($isBinary) { $value = bin2hex($value); } } return $value; } /** * Check if the given credentials are valid for the given user. * * @throws LdapException */ public function validateUserCredentials(?array $ldapUserDetails, string $password): bool { if (is_null($ldapUserDetails)) { return false; } $ldapConnection = $this->getConnection(); try { $ldapBind = $this->ldap->bind($ldapConnection, $ldapUserDetails['dn'], $password); } catch (ErrorException $e) { $ldapBind = false; } return $ldapBind; } /** * Bind the system user to the LDAP connection using the given credentials * otherwise anonymous access is attempted. * * @param resource|\LDAP\Connection $connection * * @throws LdapException */ protected function bindSystemUser($connection): void { $ldapDn = $this->config['dn']; $ldapPass = $this->config['pass']; $isAnonymous = ($ldapDn === false || $ldapPass === false); if ($isAnonymous) { $ldapBind = $this->ldap->bind($connection); } else { $ldapBind = $this->ldap->bind($connection, $ldapDn, $ldapPass); } if (!$ldapBind) { throw new LdapException(($isAnonymous ? trans('errors.ldap_fail_anonymous') : trans('errors.ldap_fail_authed'))); } } /** * Get the connection to the LDAP server. * Creates a new connection if one does not exist. * * @throws LdapException * * @return resource|\LDAP\Connection */ protected function getConnection() { if ($this->ldapConnection !== null) { return $this->ldapConnection; } // Check LDAP extension in installed if (!function_exists('ldap_connect') && config('app.env') !== 'testing') { throw new LdapException(trans('errors.ldap_extension_not_installed')); } // Disable certificate verification. // This option works globally and must be set before a connection is created. if ($this->config['tls_insecure']) { $this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER); } // Configure any user-provided CA cert files for LDAP. // This option works globally and must be set before a connection is created. if ($this->config['tls_ca_cert']) { $this->configureTlsCaCerts($this->config['tls_ca_cert']); } $ldapHost = $this->parseServerString($this->config['server']); $ldapConnection = $this->ldap->connect($ldapHost); if ($ldapConnection === false) { throw new LdapException(trans('errors.ldap_cannot_connect')); } // Set any required options if ($this->config['version']) { $this->ldap->setVersion($ldapConnection, $this->config['version']); } // Start and verify TLS if it's enabled if ($this->config['start_tls']) { try { $started = $this->ldap->startTls($ldapConnection); } catch (\Exception $exception) { $error = $exception->getMessage() . ' :: ' . ldap_error($ldapConnection); ldap_get_option($ldapConnection, LDAP_OPT_DIAGNOSTIC_MESSAGE, $detail); Log::info("LDAP STARTTLS failure: {$error} {$detail}"); throw new LdapException('Could not start TLS connection. Further details in the application log.'); } if (!$started) { throw new LdapException('Could not start TLS connection'); } } $this->ldapConnection = $ldapConnection; return $this->ldapConnection; } /** * Configure TLS CA certs globally for ldap use. * This will detect if the given path is a directory or file, and set the relevant * LDAP TLS options appropriately otherwise throw an exception if no file/folder found. * * Note: When using a folder, certificates are expected to be correctly named by hash * which can be done via the c_rehash utility. * * @throws LdapException */ protected function configureTlsCaCerts(string $caCertPath): void { $errMessage = "Provided path [{$caCertPath}] for LDAP TLS CA certs could not be resolved to an existing location"; $path = realpath($caCertPath); if ($path === false) { throw new LdapException($errMessage); } if (is_dir($path)) { $this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTDIR, $path); } else if (is_file($path)) { $this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTFILE, $path); } else { throw new LdapException($errMessage); } } /** * Parse an LDAP server string and return the host suitable for a connection. * Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'. */ protected function parseServerString(string $serverString): string { if (str_starts_with($serverString, 'ldaps://') || str_starts_with($serverString, 'ldap://')) { return $serverString; } return "ldap://{$serverString}"; } /** * Build a filter string by injecting common variables. * Both "${var}" and "{var}" style placeholders are supported. * Dollar based are old format but supported for compatibility. */ protected function buildFilter(string $filterString, array $attrs): string { $newAttrs = []; foreach ($attrs as $key => $attrText) { $escapedText = $this->ldap->escape($attrText); $oldVarKey = '${' . $key . '}'; $newVarKey = '{' . $key . '}'; $newAttrs[$oldVarKey] = $escapedText; $newAttrs[$newVarKey] = $escapedText; } return strtr($filterString, $newAttrs); } /** * Get the groups a user is a part of on ldap. * * @throws LdapException * @throws JsonDebugException */ public function getUserGroups(string $userName): array { $groupsAttr = $this->config['group_attribute']; $user = $this->getUserWithAttributes($userName, [$groupsAttr]); if ($user === null) { return []; } $userGroups = $this->extractGroupsFromSearchResponseEntry($user); $allGroups = $this->getGroupsRecursive($userGroups, []); $formattedGroups = $this->extractGroupNamesFromLdapGroupDns($allGroups); if ($this->config['dump_user_groups']) { throw new JsonDebugException([ 'details_from_ldap' => $user, 'parsed_direct_user_groups' => $userGroups, 'parsed_recursive_user_groups' => $allGroups, 'parsed_resulting_group_names' => $formattedGroups, ]); } return $formattedGroups; } protected function extractGroupNamesFromLdapGroupDns(array $groupDNs): array { $names = []; foreach ($groupDNs as $groupDN) { $exploded = $this->ldap->explodeDn($groupDN, 1); if ($exploded !== false && count($exploded) > 0) { $names[] = $exploded[0]; } } return array_unique($names); } /** * Build an array of all relevant groups DNs after recursively scanning * across parents of the groups given. * * @throws LdapException */ protected function getGroupsRecursive(array $groupDNs, array $checked): array { $groupsToAdd = []; foreach ($groupDNs as $groupDN) { if (in_array($groupDN, $checked)) { continue; } $parentGroups = $this->getParentsOfGroup($groupDN); $groupsToAdd = array_merge($groupsToAdd, $parentGroups); $checked[] = $groupDN; } $uniqueDNs = array_unique(array_merge($groupDNs, $groupsToAdd), SORT_REGULAR); if (empty($groupsToAdd)) { return $uniqueDNs; } return $this->getGroupsRecursive($uniqueDNs, $checked); } /** * @throws LdapException */ protected function getParentsOfGroup(string $groupDN): array { $groupsAttr = strtolower($this->config['group_attribute']); $ldapConnection = $this->getConnection(); $this->bindSystemUser($ldapConnection); $followReferrals = $this->config['follow_referrals'] ? 1 : 0; $this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals); $read = $this->ldap->read($ldapConnection, $groupDN, '(objectClass=*)', [$groupsAttr]); $results = $this->ldap->getEntries($ldapConnection, $read); if ($results['count'] === 0) { return []; } return $this->extractGroupsFromSearchResponseEntry($results[0]); } /** * Extract an array of group DN values from the given LDAP search response entry */ protected function extractGroupsFromSearchResponseEntry(array $ldapEntry): array { $groupsAttr = strtolower($this->config['group_attribute']); $groupDNs = []; $count = 0; if (isset($ldapEntry[$groupsAttr]['count'])) { $count = (int) $ldapEntry[$groupsAttr]['count']; } for ($i = 0; $i < $count; $i++) { $dn = $ldapEntry[$groupsAttr][$i]; if (!in_array($dn, $groupDNs)) { $groupDNs[] = $dn; } } return $groupDNs; } /** * Sync the LDAP groups to the user roles for the current user. * * @throws LdapException * @throws JsonDebugException */ public function syncGroups(User $user, string $username): void { $userLdapGroups = $this->getUserGroups($username); $this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']); } /** * Save and attach an avatar image, if found in the ldap details, and attach * to the given user model. */ public function saveAndAttachAvatar(User $user, array $ldapUserDetails): void { if (is_null(config('services.ldap.thumbnail_attribute')) || is_null($ldapUserDetails['avatar'])) { return; } try { $imageData = $ldapUserDetails['avatar']; $this->userAvatars->assignToUserFromExistingData($user, $imageData, 'jpg'); } catch (\Exception $exception) { Log::info("Failed to use avatar image from LDAP data for user id {$user->id}"); } } } ================================================ FILE: app/Access/LoginService.php ================================================ isGuest()) { throw new LoginAttemptInvalidUserException('Login not allowed for guest user'); } if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) { $this->setLastLoginAttemptedForUser($user, $method, $remember); throw new StoppedAuthenticationException($user, $this); } $this->clearLastLoginAttempted(); auth()->login($user, $remember); Activity::add(ActivityType::AUTH_LOGIN, "{$method}; {$user->logDescriptor()}"); Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user); // Authenticate on all session guards if a likely admin if ($user->can(Permission::UsersManage) && $user->can(Permission::UserRolesManage)) { $guards = ['standard', 'ldap', 'saml2', 'oidc']; foreach ($guards as $guard) { auth($guard)->login($user); } } } /** * Reattempt a system login after a previous stopped attempt. * * @throws Exception */ public function reattemptLoginFor(User $user): void { if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) { throw new Exception('Login reattempt user does align with current session state'); } $lastLoginDetails = $this->getLastLoginAttemptDetails(); $this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember'] ?? false); } /** * Get the last user that was attempted to be logged in. * Only exists if the last login attempt had correct credentials * but had been prevented by a secondary factor. */ public function getLastLoginAttemptUser(): ?User { $id = $this->getLastLoginAttemptDetails()['user_id']; return User::query()->where('id', '=', $id)->first(); } /** * Get the details of the last login attempt. * Checks upon a ttl of about 1 hour since that last attempted login. * * @return array{user_id: ?string, method: ?string, remember: bool} */ protected function getLastLoginAttemptDetails(): array { $value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY); if (!$value) { return ['user_id' => null, 'method' => null, 'remember' => false]; } [$id, $method, $remember, $time] = explode(':', $value); $hourAgo = time() - (60 * 60); if ($time < $hourAgo) { $this->clearLastLoginAttempted(); return ['user_id' => null, 'method' => null, 'remember' => false]; } return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)]; } /** * Set the last login-attempted user. * Must be only used when credentials are correct and a login could be * achieved, but a secondary factor has stopped the login. */ protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember): void { session()->put( self::LAST_LOGIN_ATTEMPTED_SESSION_KEY, implode(':', [$user->id, $method, $remember, time()]) ); } /** * Clear the last login attempted session value. */ protected function clearLastLoginAttempted(): void { session()->remove(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY); } /** * Check if MFA verification is needed. */ public function needsMfaVerification(User $user): bool { return !$this->mfaSession->isVerifiedForUser($user) && $this->mfaSession->isRequiredForUser($user); } /** * Check if the given user is awaiting email confirmation. */ public function awaitingEmailConfirmation(User $user): bool { return $this->emailConfirmationService->confirmationRequired() && !$user->email_confirmed; } /** * Attempt the login of a user using the given credentials. * Meant to mirror Laravel's default guard 'attempt' method * but in a manner that always routes through our login system. * May interrupt the flow if extra authentication requirements are imposed. * * @throws StoppedAuthenticationException * @throws LoginAttemptException */ public function attempt(array $credentials, string $method, bool $remember = false): bool { if ($this->areCredentialsForGuest($credentials)) { return false; } $result = auth()->attempt($credentials, $remember); if ($result) { $user = auth()->user(); auth()->logout(); try { $this->login($user, $method, $remember); } catch (LoginAttemptInvalidUserException $e) { // Catch and return false for non-login accounts // so it looks like a normal invalid login. return false; } } return $result; } /** * Check if the given credentials are likely for the system guest account. */ protected function areCredentialsForGuest(array $credentials): bool { if (isset($credentials['email'])) { return User::query()->where('email', '=', $credentials['email']) ->where('system_name', '=', 'public') ->exists(); } return false; } /** * Logs the current user out of the application. * Returns an app post-redirect path. */ public function logout(): string { auth()->logout(); session()->invalidate(); session()->regenerateToken(); return $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/'; } /** * Check if login auto-initiate should be active based upon authentication config. */ public function shouldAutoInitiate(): bool { $autoRedirect = config('auth.auto_initiate'); if (!$autoRedirect) { return false; } $socialDrivers = $this->socialDriverManager->getActive(); $authMethod = config('auth.method'); return count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']); } } ================================================ FILE: app/Access/Mfa/BackupCodeService.php ================================================ cleanInputCode($code); $codes = json_decode($codeSet); return in_array($cleanCode, $codes); } /** * Remove the given input code from the given available options. * Will return a JSON string containing the codes. */ public function removeInputCodeFromSet(string $code, string $codeSet): string { $cleanCode = $this->cleanInputCode($code); $codes = json_decode($codeSet); $pos = array_search($cleanCode, $codes, true); array_splice($codes, $pos, 1); return json_encode($codes); } /** * Count the number of codes in the given set. */ public function countCodesInSet(string $codeSet): int { return count(json_decode($codeSet)); } protected function cleanInputCode(string $code): string { return strtolower(str_replace(' ', '-', trim($code))); } } ================================================ FILE: app/Access/Mfa/MfaSession.php ================================================ mfaValues()->exists() || $this->userRoleEnforcesMfa($user); } /** * Check if the given user is pending MFA setup. * (MFA required but not yet configured). */ public function isPendingMfaSetup(User $user): bool { return $this->isRequiredForUser($user) && !$user->mfaValues()->exists(); } /** * Check if a role of the given user enforces MFA. */ protected function userRoleEnforcesMfa(User $user): bool { return $user->roles() ->where('mfa_enforced', '=', true) ->exists(); } /** * Check if the current MFA session has already been verified for the given user. */ public function isVerifiedForUser(User $user): bool { return session()->get($this->getMfaVerifiedSessionKey($user)) === 'true'; } /** * Mark the current session as MFA-verified. */ public function markVerifiedForUser(User $user): void { session()->put($this->getMfaVerifiedSessionKey($user), 'true'); } /** * Get the session key in which the MFA verification status is stored. */ protected function getMfaVerifiedSessionKey(User $user): string { return 'mfa-verification-passed:' . $user->id; } } ================================================ FILE: app/Access/Mfa/MfaValue.php ================================================ firstOrNew([ 'user_id' => $user->id, 'method' => $method, ]); $mfaVal->setValue($value); $mfaVal->save(); } /** * Easily get the decrypted MFA value for the given user and method. */ public static function getValueForUser(User $user, string $method): ?string { /** @var MfaValue $mfaVal */ $mfaVal = static::query() ->where('user_id', '=', $user->id) ->where('method', '=', $method) ->first(); return $mfaVal ? $mfaVal->getValue() : null; } /** * Decrypt the value attribute upon access. */ protected function getValue(): string { return decrypt($this->value); } /** * Encrypt the value attribute upon access. */ protected function setValue($value): void { $this->value = encrypt($value); } } ================================================ FILE: app/Access/Mfa/TotpService.php ================================================ google2fa = $google2fa; // Use SHA1 as a default, Personal testing of other options in 2021 found // many apps lack support for other algorithms yet still will scan // the code causing a confusing UX. $this->google2fa->setAlgorithm(Constants::SHA1); } /** * Generate a new totp secret key. */ public function generateSecret(): string { /** @noinspection PhpUnhandledExceptionInspection */ return $this->google2fa->generateSecretKey(); } /** * Generate a TOTP URL from a secret key. */ public function generateUrl(string $secret, User $user): string { return $this->google2fa->getQRCodeUrl( setting('app-name'), $user->email, $secret ); } /** * Generate a QR code to display a TOTP URL. */ public function generateQrCodeSvg(string $url): string { $color = Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(32, 110, 167)); return (new Writer( new ImageRenderer( new RendererStyle(192, 4, null, null, $color), new SvgImageBackEnd() ) ))->writeString($url); } /** * Verify that the user provided code is valid for the secret. * The secret must be known, not user-provided. */ public function verifyCode(string $code, string $secret): bool { /** @noinspection PhpUnhandledExceptionInspection */ return $this->google2fa->verifyKey($secret, $code); } } ================================================ FILE: app/Access/Mfa/TotpValidationRule.php ================================================ totpService->verifyCode($value, $this->secret); if (!$passes) { $fail(trans('validation.totp')); } } } ================================================ FILE: app/Access/Notifications/ConfirmEmailNotification.php ================================================ setting('app-name')]; return $this->newMailMessage() ->subject(trans('auth.email_confirm_subject', $appName)) ->greeting(trans('auth.email_confirm_greeting', $appName)) ->line(trans('auth.email_confirm_text')) ->action(trans('auth.email_confirm_action'), url('/register/confirm/' . $this->token)); } } ================================================ FILE: app/Access/Notifications/ResetPasswordNotification.php ================================================ newMailMessage() ->subject(trans('auth.email_reset_subject', ['appName' => setting('app-name')])) ->line(trans('auth.email_reset_text')) ->action(trans('auth.reset_password'), url('password/reset/' . $this->token)) ->line(trans('auth.email_reset_not_requested')); } } ================================================ FILE: app/Access/Notifications/UserInviteNotification.php ================================================ setting('app-name')]; $locale = $notifiable->getLocale(); return $this->newMailMessage($locale) ->subject($locale->trans('auth.user_invite_email_subject', $appName)) ->greeting($locale->trans('auth.user_invite_email_greeting', $appName)) ->line($locale->trans('auth.user_invite_email_text')) ->action($locale->trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token)); } } ================================================ FILE: app/Access/Oidc/OidcAccessToken.php ================================================ validate($options); } /** * Validate this access token response for OIDC. * As per https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK. */ private function validate(array $options): void { // access_token: REQUIRED. Access Token for the UserInfo Endpoint. // Performed on the extended class // token_type: REQUIRED. OAuth 2.0 Token Type value. The value MUST be Bearer, as specified in OAuth 2.0 // Bearer Token Usage [RFC6750], for Clients using this subset. // Note that the token_type value is case-insensitive. if (strtolower(($options['token_type'] ?? '')) !== 'bearer') { throw new InvalidArgumentException('The response token type MUST be "Bearer"'); } // id_token: REQUIRED. ID Token. if (empty($options['id_token'])) { throw new InvalidArgumentException('An "id_token" property must be provided'); } } /** * Get the id token value from this access token response. */ public function getIdToken(): string { return $this->getValues()['id_token']; } } ================================================ FILE: app/Access/Oidc/OidcException.php ================================================ validateTokenClaims($clientId); return true; } /** * Validate the claims of the token. * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation. * * @throws OidcInvalidTokenException */ protected function validateTokenClaims(string $clientId): void { // 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) // MUST exactly match the value of the iss (issuer) Claim. // Already done in parent. // 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered // at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected // if the ID Token does not list the Client as a valid audience, or if it contains additional // audiences not trusted by the Client. // Partially done in parent. $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud']; if (count($aud) !== 1) { throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1'); } // 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present. // NOTE: Addressed by enforcing a count of 1 above. // 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id // is the Claim Value. if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) { throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id'); } // 5. The current time MUST be before the time represented by the exp Claim // (possibly allowing for some small leeway to account for clock skew). if (empty($this->payload['exp'])) { throw new OidcInvalidTokenException('Missing token expiration time value'); } $skewSeconds = 120; $now = time(); if ($now >= (intval($this->payload['exp']) + $skewSeconds)) { throw new OidcInvalidTokenException('Token has expired'); } // 6. The iat Claim can be used to reject tokens that were issued too far away from the current time, // limiting the amount of time that nonces need to be stored to prevent attacks. // The acceptable range is Client specific. if (empty($this->payload['iat'])) { throw new OidcInvalidTokenException('Missing token issued at time value'); } $dayAgo = time() - 86400; $iat = intval($this->payload['iat']); if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) { throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid'); } // 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate. // The meaning and processing of acr Claim Values is out of scope for this document. // NOTE: Not used for our case here. acr is not requested. // 8. When a max_age request is made, the Client SHOULD check the auth_time Claim value and request // re-authentication if it determines too much time has elapsed since the last End-User authentication. // NOTE: Not used for our case here. A max_age request is not made. // Custom: Ensure the "sub" (Subject) Claim exists and has a value. if (empty($this->payload['sub'])) { throw new OidcInvalidTokenException('Missing token subject value'); } } } ================================================ FILE: app/Access/Oidc/OidcInvalidKeyException.php ================================================ 'RSA', 'alg' => 'RS256', 'n' => 'abc123...']. * * @param array|string $jwkOrKeyPath * * @throws OidcInvalidKeyException */ public function __construct($jwkOrKeyPath) { if (is_array($jwkOrKeyPath)) { $this->loadFromJwkArray($jwkOrKeyPath); } elseif (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) { $this->loadFromPath($jwkOrKeyPath); } else { throw new OidcInvalidKeyException('Unexpected type of key value provided'); } } /** * @throws OidcInvalidKeyException */ protected function loadFromPath(string $path) { try { $key = PublicKeyLoader::load( file_get_contents($path) ); } catch (\Exception $exception) { throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}"); } if (!$key instanceof RSA) { throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected'); } $this->key = $key->withPadding(RSA::SIGNATURE_PKCS1); } /** * @throws OidcInvalidKeyException */ protected function loadFromJwkArray(array $jwk) { // 'alg' is optional for a JWK, but we will still attempt to validate if // it exists otherwise presume it will be compatible. $alg = $jwk['alg'] ?? null; if ($jwk['kty'] !== 'RSA' || !(is_null($alg) || $alg === 'RS256')) { throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}"); } // 'use' is optional for a JWK but we assume 'sig' where no value exists since that's what // the OIDC discovery spec infers since 'sig' MUST be set if encryption keys come into play. $use = $jwk['use'] ?? 'sig'; if ($use !== 'sig') { throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}"); } if (empty($jwk['e'])) { throw new OidcInvalidKeyException('An "e" parameter on the provided key is expected'); } if (empty($jwk['n'])) { throw new OidcInvalidKeyException('A "n" parameter on the provided key is expected'); } $n = strtr($jwk['n'] ?? '', '-_', '+/'); try { $key = PublicKeyLoader::load([ 'e' => new BigInteger(base64_decode($jwk['e']), 256), 'n' => new BigInteger(base64_decode($n), 256), ]); } catch (\Exception $exception) { throw new OidcInvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}"); } if (!$key instanceof RSA) { throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected'); } $this->key = $key->withPadding(RSA::SIGNATURE_PKCS1); } /** * Use this key to sign the given content and return the signature. */ public function verify(string $content, string $signature): bool { return $this->key->verify($content, $signature); } /** * Convert the key to a PEM encoded key string. */ public function toPem(): string { return $this->key->toString('PKCS8'); } } ================================================ FILE: app/Access/Oidc/OidcJwtWithClaims.php ================================================ keys = $keys; $this->issuer = $issuer; $this->parse($token); } /** * Parse the token content into its components. */ protected function parse(string $token): void { $this->tokenParts = explode('.', $token); $this->header = $this->parseEncodedTokenPart($this->tokenParts[0]); $this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? ''); $this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: ''; } /** * Parse a Base64-JSON encoded token part. * Returns the data as a key-value array or empty array upon error. */ protected function parseEncodedTokenPart(string $part): array { $json = $this->base64UrlDecode($part) ?: '{}'; $decoded = json_decode($json, true); return is_array($decoded) ? $decoded : []; } /** * Base64URL decode. Needs some character conversions to be compatible * with PHP's default base64 handling. */ protected function base64UrlDecode(string $encoded): string { return base64_decode(strtr($encoded, '-_', '+/')); } /** * Validate common parts of OIDC JWT tokens. * * @throws OidcInvalidTokenException */ public function validateCommonTokenDetails(string $clientId): bool { $this->validateTokenStructure(); $this->validateTokenSignature(); $this->validateCommonClaims($clientId); return true; } /** * Fetch a specific claim from this token. * Returns null if it is null or does not exist. */ public function getClaim(string $claim): mixed { return $this->payload[$claim] ?? null; } /** * Get all returned claims within the token. */ public function getAllClaims(): array { return $this->payload; } /** * Replace the existing claim data of this token with that provided. */ public function replaceClaims(array $claims): void { $this->payload = $claims; } /** * Validate the structure of the given token and ensure we have the required pieces. * As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2. * * @throws OidcInvalidTokenException */ protected function validateTokenStructure(): void { foreach (['header', 'payload'] as $prop) { if (empty($this->$prop) || !is_array($this->$prop)) { throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token"); } } if (empty($this->signature) || !is_string($this->signature)) { throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token'); } } /** * Validate the signature of the given token and ensure it validates against the provided key. * * @throws OidcInvalidTokenException */ protected function validateTokenSignature(): void { if ($this->header['alg'] !== 'RS256') { throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}"); } $parsedKeys = array_map(function ($key) { try { return new OidcJwtSigningKey($key); } catch (OidcInvalidKeyException $e) { throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage()); } }, $this->keys); $parsedKeys = array_filter($parsedKeys); $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; /** @var OidcJwtSigningKey $parsedKey */ foreach ($parsedKeys as $parsedKey) { if ($parsedKey->verify($contentToSign, $this->signature)) { return; } } throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys'); } /** * Validate common claims for OIDC JWT tokens. * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation * and https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse * * @throws OidcInvalidTokenException */ protected function validateCommonClaims(string $clientId): void { // 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) // MUST exactly match the value of the iss (issuer) Claim. if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) { throw new OidcInvalidTokenException('Missing or non-matching token issuer value'); } // 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered // at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected // if the ID Token does not list the Client as a valid audience. if (empty($this->payload['aud'])) { throw new OidcInvalidTokenException('Missing token audience value'); } $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud']; if (!in_array($clientId, $aud, true)) { throw new OidcInvalidTokenException('Token audience value did not match the expected client_id'); } } } ================================================ FILE: app/Access/Oidc/OidcOAuthProvider.php ================================================ authorizationEndpoint; } /** * Returns the base URL for requesting an access token. */ public function getBaseAccessTokenUrl(array $params): string { return $this->tokenEndpoint; } /** * Returns the URL for requesting the resource owner's details. */ public function getResourceOwnerDetailsUrl(AccessToken $token): string { return ''; } /** * Add another scope to this provider upon the default. */ public function addScope(string $scope): void { $this->scopes[] = $scope; $this->scopes = array_unique($this->scopes); } /** * Returns the default scopes used by this provider. * * This should only be the scopes that are required to request the details * of the resource owner, rather than all the available scopes. */ protected function getDefaultScopes(): array { return $this->scopes; } /** * Returns the string that should be used to separate scopes when building * the URL for requesting an access token. */ protected function getScopeSeparator(): string { return ' '; } /** * Checks a provider response for errors. * @throws IdentityProviderException */ protected function checkResponse(ResponseInterface $response, $data): void { if ($response->getStatusCode() >= 400 || isset($data['error'])) { throw new IdentityProviderException( $data['error'] ?? $response->getReasonPhrase(), $response->getStatusCode(), (string) $response->getBody() ); } } /** * Generates a resource owner object from a successful resource owner * details request. */ protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface { return new GenericResourceOwner($response, ''); } /** * Creates an access token from a response. * * The grant that was used to fetch the response can be used to provide * additional context. */ protected function createAccessToken(array $response, AbstractGrant $grant): OidcAccessToken { return new OidcAccessToken($response); } /** * Get the method used for PKCE code verifier hashing, which is passed * in the "code_challenge_method" parameter in the authorization request. */ protected function getPkceMethod(): string { return static::PKCE_METHOD_S256; } } ================================================ FILE: app/Access/Oidc/OidcProviderSettings.php ================================================ applySettingsFromArray($settings); $this->validateInitial(); } /** * Apply an array of settings to populate setting properties within this class. */ protected function applySettingsFromArray(array $settingsArray): void { foreach ($settingsArray as $key => $value) { if (property_exists($this, $key)) { $this->$key = $value; } } } /** * Validate any core, required properties have been set. * * @throws InvalidArgumentException */ protected function validateInitial(): void { $required = ['clientId', 'clientSecret', 'issuer']; foreach ($required as $prop) { if (empty($this->$prop)) { throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); } } if (!str_starts_with($this->issuer, 'https://')) { throw new InvalidArgumentException('Issuer value must start with https://'); } } /** * Perform a full validation on these settings. * * @throws InvalidArgumentException */ public function validate(): void { $this->validateInitial(); $required = ['keys', 'tokenEndpoint', 'authorizationEndpoint']; foreach ($required as $prop) { if (empty($this->$prop)) { throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); } } $endpointProperties = ['tokenEndpoint', 'authorizationEndpoint', 'userinfoEndpoint']; foreach ($endpointProperties as $prop) { if (is_string($this->$prop) && !str_starts_with($this->$prop, 'https://')) { throw new InvalidArgumentException("Endpoint value for \"{$prop}\" must start with https://"); } } } /** * Discover and autoload settings from the configured issuer. * * @throws OidcIssuerDiscoveryException */ public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes): void { try { $cacheKey = 'oidc-discovery::' . $this->issuer; $discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function () use ($httpClient) { return $this->loadSettingsFromIssuerDiscovery($httpClient); }); $this->applySettingsFromArray($discoveredSettings); } catch (ClientExceptionInterface $exception) { throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}"); } } /** * @throws OidcIssuerDiscoveryException * @throws ClientExceptionInterface */ protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array { $issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration'; $request = new Request('GET', $issuerUrl); $response = $httpClient->sendRequest($request); $result = json_decode($response->getBody()->getContents(), true); if (empty($result) || !is_array($result)) { throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}"); } if ($result['issuer'] !== $this->issuer) { throw new OidcIssuerDiscoveryException('Unexpected issuer value found on discovery response'); } $discoveredSettings = []; if (!empty($result['authorization_endpoint'])) { $discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint']; } if (!empty($result['token_endpoint'])) { $discoveredSettings['tokenEndpoint'] = $result['token_endpoint']; } if (!empty($result['userinfo_endpoint'])) { $discoveredSettings['userinfoEndpoint'] = $result['userinfo_endpoint']; } if (!empty($result['jwks_uri'])) { $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient); $discoveredSettings['keys'] = $this->filterKeys($keys); } if (!empty($result['end_session_endpoint'])) { $discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint']; } return $discoveredSettings; } /** * Filter the given JWK keys down to just those we support. */ protected function filterKeys(array $keys): array { return array_filter($keys, function (array $key) { $alg = $key['alg'] ?? 'RS256'; $use = $key['use'] ?? 'sig'; return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256'; }); } /** * Return an array of jwks as PHP key=>value arrays. * * @throws ClientExceptionInterface * @throws OidcIssuerDiscoveryException */ protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array { $request = new Request('GET', $uri); $response = $httpClient->sendRequest($request); $result = json_decode($response->getBody()->getContents(), true); if (empty($result) || !is_array($result) || !isset($result['keys'])) { throw new OidcIssuerDiscoveryException('Error reading keys from issuer jwks_uri'); } return $result['keys']; } /** * Get the settings needed by an OAuth provider, as a key=>value array. */ public function arrayForOAuthProvider(): array { $settingKeys = ['clientId', 'clientSecret', 'authorizationEndpoint', 'tokenEndpoint', 'userinfoEndpoint']; $settings = []; foreach ($settingKeys as $setting) { $settings[$setting] = $this->$setting; } return $settings; } } ================================================ FILE: app/Access/Oidc/OidcService.php ================================================ getProviderSettings(); $provider = $this->getProvider($settings); $url = $provider->getAuthorizationUrl(); session()->put('oidc_pkce_code', $provider->getPkceCode() ?? ''); $returnUrl = Theme::dispatch(ThemeEvents::OIDC_AUTH_PRE_REDIRECT, $url); if (is_string($returnUrl)) { $url = $returnUrl; } return [ 'url' => $url, 'state' => $provider->getState(), ]; } /** * Process the Authorization response from the authorization server and * return the matching, or new if registration active, user matched to the * authorization server. Throws if the user cannot be auth if not authenticated. * * @throws JsonDebugException * @throws OidcException * @throws StoppedAuthenticationException * @throws IdentityProviderException */ public function processAuthorizeResponse(?string $authorizationCode): User { $settings = $this->getProviderSettings(); $provider = $this->getProvider($settings); // Set PKCE code flashed at login $pkceCode = session()->pull('oidc_pkce_code', ''); $provider->setPkceCode($pkceCode); // Try to exchange authorization code for access token $accessToken = $provider->getAccessToken('authorization_code', [ 'code' => $authorizationCode, ]); return $this->processAccessTokenCallback($accessToken, $settings); } /** * @throws OidcException */ protected function getProviderSettings(): OidcProviderSettings { $config = $this->config(); $settings = new OidcProviderSettings([ 'issuer' => $config['issuer'], 'clientId' => $config['client_id'], 'clientSecret' => $config['client_secret'], 'authorizationEndpoint' => $config['authorization_endpoint'], 'tokenEndpoint' => $config['token_endpoint'], 'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null, 'userinfoEndpoint' => $config['userinfo_endpoint'], ]); // Use keys if configured if (!empty($config['jwt_public_key'])) { $settings->keys = [$config['jwt_public_key']]; } // Run discovery if ($config['discover'] ?? false) { try { $settings->discoverFromIssuer($this->http->buildClient(5), Cache::store(null), 15); } catch (OidcIssuerDiscoveryException $exception) { throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage()); } } // Prevent use of RP-initiated logout if specifically disabled // Or force use of a URL if specifically set. if ($config['end_session_endpoint'] === false) { $settings->endSessionEndpoint = null; } else if (is_string($config['end_session_endpoint'])) { $settings->endSessionEndpoint = $config['end_session_endpoint']; } $settings->validate(); return $settings; } /** * Load the underlying OpenID Connect Provider. */ protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider { $provider = new OidcOAuthProvider([ ...$settings->arrayForOAuthProvider(), 'redirectUri' => url('/oidc/callback'), ], [ 'httpClient' => $this->http->buildClient(5), 'optionProvider' => new HttpBasicAuthOptionProvider(), ]); foreach ($this->getAdditionalScopes() as $scope) { $provider->addScope($scope); } return $provider; } /** * Get any user-defined addition/custom scopes to apply to the authentication request. * * @return string[] */ protected function getAdditionalScopes(): array { $scopeConfig = $this->config()['additional_scopes'] ?: ''; $scopeArr = explode(',', $scopeConfig); $scopeArr = array_map(fn (string $scope) => trim($scope), $scopeArr); return array_filter($scopeArr); } /** * Processes a received access token for a user. Login the user when * they exist, optionally registering them automatically. * * @throws OidcException * @throws JsonDebugException * @throws StoppedAuthenticationException */ protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User { $idTokenText = $accessToken->getIdToken(); $idToken = new OidcIdToken( $idTokenText, $settings->issuer, $settings->keys, ); session()->put("oidc_id_token", $idTokenText); $returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [ 'access_token' => $accessToken->getToken(), 'expires_in' => $accessToken->getExpires(), 'refresh_token' => $accessToken->getRefreshToken(), ]); if (!is_null($returnClaims)) { $idToken->replaceClaims($returnClaims); } if ($this->config()['dump_user_details']) { throw new JsonDebugException($idToken->getAllClaims()); } try { $idToken->validate($settings->clientId); } catch (OidcInvalidTokenException $exception) { throw new OidcException("ID token validation failed with error: {$exception->getMessage()}"); } $userDetails = $this->getUserDetailsFromToken($idToken, $accessToken, $settings); if (empty($userDetails->email)) { throw new OidcException(trans('errors.oidc_no_email_address')); } if (empty($userDetails->name)) { $userDetails->name = $userDetails->externalId; } $isLoggedIn = auth()->check(); if ($isLoggedIn) { throw new OidcException(trans('errors.oidc_already_logged_in')); } try { $user = $this->registrationService->findOrRegister( $userDetails->name, $userDetails->email, $userDetails->externalId ); } catch (UserRegistrationException $exception) { throw new OidcException($exception->getMessage()); } if ($this->config()['fetch_avatar'] && !$user->avatar()->exists() && $userDetails->picture) { $this->userAvatars->assignToUserFromUrl($user, $userDetails->picture); } if ($this->shouldSyncGroups()) { $detachExisting = $this->config()['remove_from_groups']; $this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting); } $this->loginService->login($user, 'oidc'); return $user; } /** * @throws OidcException */ protected function getUserDetailsFromToken(OidcIdToken $idToken, OidcAccessToken $accessToken, OidcProviderSettings $settings): OidcUserDetails { $userDetails = new OidcUserDetails(); $userDetails->populate( $idToken, $this->config()['external_id_claim'], $this->config()['display_name_claims'] ?? '', $this->config()['groups_claim'] ?? '' ); if (!$userDetails->isFullyPopulated($this->shouldSyncGroups()) && !empty($settings->userinfoEndpoint)) { $provider = $this->getProvider($settings); $request = $provider->getAuthenticatedRequest('GET', $settings->userinfoEndpoint, $accessToken->getToken()); $response = new OidcUserinfoResponse( $provider->getResponse($request), $settings->issuer, $settings->keys, ); try { $response->validate($idToken->getClaim('sub'), $settings->clientId); } catch (OidcInvalidTokenException $exception) { throw new OidcException("Userinfo endpoint response validation failed with error: {$exception->getMessage()}"); } $userDetails->populate( $response, $this->config()['external_id_claim'], $this->config()['display_name_claims'] ?? '', $this->config()['groups_claim'] ?? '' ); } return $userDetails; } /** * Get the OIDC config from the application. */ protected function config(): array { return config('oidc'); } /** * Check if groups should be synced. */ protected function shouldSyncGroups(): bool { return $this->config()['user_to_groups'] !== false; } /** * Start the RP-initiated logout flow if active, otherwise start a standard logout flow. * Returns a post-app-logout redirect URL. * Reference: https://openid.net/specs/openid-connect-rpinitiated-1_0.html * @throws OidcException */ public function logout(): string { $oidcToken = session()->pull("oidc_id_token"); $defaultLogoutUrl = url($this->loginService->logout()); $oidcSettings = $this->getProviderSettings(); if (!$oidcSettings->endSessionEndpoint) { return $defaultLogoutUrl; } $endpointParams = [ 'id_token_hint' => $oidcToken, 'post_logout_redirect_uri' => $defaultLogoutUrl, ]; $joiner = str_contains($oidcSettings->endSessionEndpoint, '?') ? '&' : '?'; return $oidcSettings->endSessionEndpoint . $joiner . http_build_query($endpointParams); } } ================================================ FILE: app/Access/Oidc/OidcUserDetails.php ================================================ externalId) || empty($this->email) || empty($this->name) || ($groupSyncActive && $this->groups === null); return !$hasEmpty; } /** * Populate user details from the given claim data. */ public function populate( ProvidesClaims $claims, string $idClaim, string $displayNameClaims, string $groupsClaim, ): void { $this->externalId = $claims->getClaim($idClaim) ?? $this->externalId; $this->email = $claims->getClaim('email') ?? $this->email; $this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name; $this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups; $this->picture = static::getPicture($claims) ?: $this->picture; } protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $claims): string { $displayNameClaimParts = explode('|', $displayNameClaims); $displayName = []; foreach ($displayNameClaimParts as $claim) { $component = $claims->getClaim(trim($claim)) ?? ''; if ($component !== '') { $displayName[] = $component; } } return implode(' ', $displayName); } protected static function getUserGroups(string $groupsClaim, ProvidesClaims $claims): ?array { if (empty($groupsClaim)) { return null; } $groupsList = Arr::get($claims->getAllClaims(), $groupsClaim); if (!is_array($groupsList)) { return null; } return array_values(array_filter($groupsList, function ($val) { return is_string($val); })); } protected static function getPicture(ProvidesClaims $claims): ?string { $picture = $claims->getClaim('picture'); if (is_string($picture) && str_starts_with($picture, 'http')) { return $picture; } return null; } } ================================================ FILE: app/Access/Oidc/OidcUserinfoResponse.php ================================================ getHeader('Content-Type')[0] ?? ''; $contentType = strtolower(trim(explode(';', $contentTypeHeaderValue, 2)[0])); if ($contentType === 'application/json') { $this->claims = json_decode($response->getBody()->getContents(), true); } if ($contentType === 'application/jwt') { $this->jwt = new OidcJwtWithClaims($response->getBody()->getContents(), $issuer, $keys); $this->claims = $this->jwt->getAllClaims(); } } /** * @throws OidcInvalidTokenException */ public function validate(string $idTokenSub, string $clientId): bool { if (!is_null($this->jwt)) { $this->jwt->validateCommonTokenDetails($clientId); } $sub = $this->getClaim('sub'); // Spec: v1.0 5.3.2: The sub (subject) Claim MUST always be returned in the UserInfo Response. if (!is_string($sub) || empty($sub)) { throw new OidcInvalidTokenException("No valid subject value found in userinfo data"); } // Spec: v1.0 5.3.2: The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token; // if they do not match, the UserInfo Response values MUST NOT be used. if ($idTokenSub !== $sub) { throw new OidcInvalidTokenException("Subject value provided in the userinfo endpoint does not match the provided ID token value"); } // Spec v1.0 5.3.4 Defines the following: // Verify that the OP that responded was the intended OP through a TLS server certificate check, per RFC 6125 [RFC6125]. // This is effectively done as part of the HTTP request we're making through CURLOPT_SSL_VERIFYHOST on the request. // If the Client has provided a userinfo_encrypted_response_alg parameter during Registration, decrypt the UserInfo Response using the keys specified during Registration. // We don't currently support JWT encryption for OIDC // If the response was signed, the Client SHOULD validate the signature according to JWS [JWS]. // This is done as part of the validateCommonClaims above. return true; } public function getClaim(string $claim): mixed { return $this->claims[$claim] ?? null; } public function getAllClaims(): array { return $this->claims; } } ================================================ FILE: app/Access/Oidc/ProvidesClaims.php ================================================ registrationAllowed()) { throw new UserRegistrationException(trans('auth.registrations_disabled'), '/login'); } } /** * Check if standard BookStack User registrations are currently allowed. * Does not prevent external-auth based registration. */ protected function registrationAllowed(): bool { $authMethod = config('auth.method'); $authMethodsWithRegistration = ['standard']; return in_array($authMethod, $authMethodsWithRegistration) && setting('registration-enabled'); } /** * Attempt to find a user in the system otherwise register them as a new * user. For use with external auth systems since password is auto-generated. * * @throws UserRegistrationException */ public function findOrRegister(string $name, string $email, string $externalId): User { $user = User::query() ->where('external_auth_id', '=', $externalId) ->first(); if (is_null($user)) { $userData = [ 'name' => $name, 'email' => $email, 'password' => Str::random(32), 'external_auth_id' => $externalId, ]; $user = $this->registerUser($userData, null, false); } return $user; } /** * The registrations flow for all users. * * @throws UserRegistrationException */ public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User { $userEmail = $userData['email']; $authSystem = $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver(); // Email restriction $this->ensureEmailDomainAllowed($userEmail); // Ensure user does not already exist $alreadyUser = !is_null($this->userRepo->getByEmail($userEmail)); if ($alreadyUser) { throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login'); } /** @var ?bool $shouldRegister */ $shouldRegister = Theme::dispatch(ThemeEvents::AUTH_PRE_REGISTER, $authSystem, $userData); if ($shouldRegister === false) { throw new UserRegistrationException(trans('errors.auth_pre_register_theme_prevention'), '/login'); } // Create the user $newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed); $newUser->attachDefaultRole(); // Assign social account if given if ($socialAccount) { $newUser->socialAccounts()->save($socialAccount); } Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser); Theme::dispatch(ThemeEvents::AUTH_REGISTER, $authSystem, $newUser); // Start email confirmation flow if required if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) { $newUser->save(); try { $this->emailConfirmationService->sendConfirmation($newUser); session()->flash('sent-email-confirmation', true); } catch (Exception $e) { $message = trans('auth.email_confirm_send_error'); throw new UserRegistrationException($message, '/register/confirm'); } } return $newUser; } /** * Ensure that the given email meets any active email domain registration restrictions. * Throws if restrictions are active and the email does not match an allowed domain. * * @throws UserRegistrationException */ protected function ensureEmailDomainAllowed(string $userEmail): void { $registrationRestrict = setting('registration-restrict'); if (!$registrationRestrict) { return; } $restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict)); $userEmailDomain = mb_substr(mb_strrchr($userEmail, '@'), 1); if (!in_array($userEmailDomain, $restrictedEmailDomains)) { $redirect = $this->registrationAllowed() ? '/register' : '/login'; throw new UserRegistrationException(trans('auth.registration_email_domain_invalid'), $redirect); } } } ================================================ FILE: app/Access/Saml2Service.php ================================================ config = config('saml2'); } /** * Initiate a login flow. * * @throws Error */ public function login(): array { $toolKit = $this->getToolkit(); $returnRoute = url('/saml2/acs'); return [ 'url' => $toolKit->login($returnRoute, [], false, false, true), 'id' => $toolKit->getLastRequestID(), ]; } /** * Initiate a logout flow. * Returns the SAML2 request ID, and the URL to redirect the user to. * * @throws Error * @return array{url: string, id: ?string} */ public function logout(User $user): array { $toolKit = $this->getToolkit(); $sessionIndex = session()->get('saml2_session_index'); $returnUrl = url($this->loginService->logout()); try { $url = $toolKit->logout( $returnUrl, [], $user->email, $sessionIndex, true, Constants::NAMEID_EMAIL_ADDRESS ); $id = $toolKit->getLastRequestID(); } catch (Error $error) { if ($error->getCode() !== Error::SAML_SINGLE_LOGOUT_NOT_SUPPORTED) { throw $error; } $url = $returnUrl; $id = null; } return ['url' => $url, 'id' => $id]; } /** * Process the ACS response from the idp and return the * matching, or new if registration active, user matched to the idp. * Returns null if not authenticated. * * @throws Error * @throws SamlException * @throws ValidationError * @throws JsonDebugException * @throws UserRegistrationException */ public function processAcsResponse(?string $requestId, string $samlResponse): ?User { // The SAML2 toolkit expects the response to be within the $_POST superglobal // so we need to manually put it back there at this point. $_POST['SAMLResponse'] = $samlResponse; $toolkit = $this->getToolkit(); $toolkit->processResponse($requestId); $errors = $toolkit->getErrors(); if (!empty($errors)) { $reason = $toolkit->getLastErrorReason(); $message = 'Invalid ACS Response; Errors: ' . implode(', ', $errors); $message .= $reason ? "; Reason: {$reason}" : ''; throw new Error($message); } if (!$toolkit->isAuthenticated()) { return null; } $attrs = $toolkit->getAttributes(); $id = $toolkit->getNameId(); session()->put('saml2_session_index', $toolkit->getSessionIndex()); return $this->processLoginCallback($id, $attrs); } /** * Process a response for the single logout service. * * @throws Error */ public function processSlsResponse(?string $requestId): string { $toolkit = $this->getToolkit(); // The $retrieveParametersFromServer in the call below will mean the library will take the query // parameters, used for the response signing, from the raw $_SERVER['QUERY_STRING'] // value so that the exact encoding format is matched when checking the signature. // This is primarily due to ADFS encoding query params with lowercase percent encoding while // PHP (And most other sensible providers) standardise on uppercase. /** @var ?string $samlRedirect */ $samlRedirect = $toolkit->processSLO(true, $requestId, true, null, true); $errors = $toolkit->getErrors(); if (!empty($errors)) { throw new Error( 'Invalid SLS Response: ' . implode(', ', $errors) ); } $defaultBookStackRedirect = $this->loginService->logout(); return $samlRedirect ?? $defaultBookStackRedirect; } /** * Get the metadata for this service provider. * * @throws Error */ public function metadata(): string { $toolKit = $this->getToolkit(true); $settings = $toolKit->getSettings(); $metadata = $settings->getSPMetadata(); $errors = $settings->validateMetadata($metadata); if (!empty($errors)) { throw new Error( 'Invalid SP metadata: ' . implode(', ', $errors), Error::METADATA_SP_INVALID ); } return $metadata; } /** * Load the underlying Onelogin SAML2 toolkit. * * @throws Error * @throws Exception */ protected function getToolkit(bool $spOnly = false): Auth { $settings = $this->config['onelogin']; $overrides = $this->config['onelogin_overrides'] ?? []; if ($overrides && is_string($overrides)) { $overrides = json_decode($overrides, true); } $metaDataSettings = []; if (!$spOnly && $this->config['autoload_from_metadata']) { $metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']); } $spSettings = $this->loadOneloginServiceProviderDetails(); $settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides); return new Auth($settings, $spOnly); } /** * Load dynamic service provider options required by the onelogin toolkit. */ protected function loadOneloginServiceProviderDetails(): array { $spDetails = [ 'entityId' => url('/saml2/metadata'), 'assertionConsumerService' => [ 'url' => url('/saml2/acs'), ], 'singleLogoutService' => [ 'url' => url('/saml2/sls'), ], ]; return [ 'baseurl' => url('/saml2'), 'sp' => $spDetails, ]; } /** * Check if groups should be synced. */ protected function shouldSyncGroups(): bool { return $this->config['user_to_groups'] !== false; } /** * Calculate the display name. */ protected function getUserDisplayName(array $samlAttributes, string $defaultValue): string { $displayNameAttr = $this->config['display_name_attributes']; $displayName = []; foreach ($displayNameAttr as $dnAttr) { $dnComponent = $this->getSamlResponseAttribute($samlAttributes, $dnAttr, null); if ($dnComponent !== null) { $displayName[] = $dnComponent; } } if (count($displayName) == 0) { $displayName = $defaultValue; } else { $displayName = implode(' ', $displayName); } return $displayName; } /** * Get the value to use as the external id saved in BookStack * used to link the user to an existing BookStack DB user. */ protected function getExternalId(array $samlAttributes, string $defaultValue) { $userNameAttr = $this->config['external_id_attribute']; if ($userNameAttr === null) { return $defaultValue; } return $this->getSamlResponseAttribute($samlAttributes, $userNameAttr, $defaultValue); } /** * Extract the details of a user from a SAML response. * * @return array{external_id: string, name: string, email: string, saml_id: string} */ protected function getUserDetails(string $samlID, $samlAttributes): array { $emailAttr = $this->config['email_attribute']; $externalId = $this->getExternalId($samlAttributes, $samlID); $defaultEmail = filter_var($samlID, FILTER_VALIDATE_EMAIL) ? $samlID : null; $email = $this->getSamlResponseAttribute($samlAttributes, $emailAttr, $defaultEmail); return [ 'external_id' => $externalId, 'name' => $this->getUserDisplayName($samlAttributes, $externalId), 'email' => $email, 'saml_id' => $samlID, ]; } /** * Get the groups a user is a part of from the SAML response. */ public function getUserGroups(array $samlAttributes): array { $groupsAttr = $this->config['group_attribute']; $userGroups = $samlAttributes[$groupsAttr] ?? null; if (!is_array($userGroups)) { $userGroups = []; } return $userGroups; } /** * For an array of strings, return a default for an empty array, * a string for an array with one element and the full array for * more than one element. */ protected function simplifyValue(array $data, $defaultValue) { switch (count($data)) { case 0: $data = $defaultValue; break; case 1: $data = $data[0]; break; } return $data; } /** * Get a property from an SAML response. * Handles properties potentially being an array. */ protected function getSamlResponseAttribute(array $samlAttributes, string $propertyKey, $defaultValue) { if (isset($samlAttributes[$propertyKey])) { return $this->simplifyValue($samlAttributes[$propertyKey], $defaultValue); } return $defaultValue; } /** * Process the SAML response for a user. Login the user when * they exist, optionally registering them automatically. * * @throws SamlException * @throws JsonDebugException * @throws UserRegistrationException * @throws StoppedAuthenticationException */ public function processLoginCallback(string $samlID, array $samlAttributes): User { $userDetails = $this->getUserDetails($samlID, $samlAttributes); $isLoggedIn = auth()->check(); if ($this->shouldSyncGroups()) { $userDetails['groups'] = $this->getUserGroups($samlAttributes); } if ($this->config['dump_user_details']) { throw new JsonDebugException([ 'id_from_idp' => $samlID, 'attrs_from_idp' => $samlAttributes, 'attrs_after_parsing' => $userDetails, ]); } if ($userDetails['email'] === null) { throw new SamlException(trans('errors.saml_no_email_address')); } if ($isLoggedIn) { throw new SamlException(trans('errors.saml_already_logged_in'), '/login'); } $user = $this->registrationService->findOrRegister( $userDetails['name'], $userDetails['email'], $userDetails['external_id'] ); if ($this->shouldSyncGroups()) { $this->groupSyncService->syncUserWithFoundGroups($user, $userDetails['groups'], $this->config['remove_from_groups']); } $this->loginService->login($user, 'saml2'); return $user; } } ================================================ FILE: app/Access/SocialAccount.php ================================================ */ public function user(): BelongsTo { return $this->belongsTo(User::class); } /** * {@inheritdoc} */ public function logDescriptor(): string { return "{$this->driver}; {$this->user->logDescriptor()}"; } } ================================================ FILE: app/Access/SocialAuthService.php ================================================ driverManager->ensureDriverActive($socialDriver); return $this->getDriverForRedirect($socialDriver)->redirect(); } /** * Start the social registration process. * * @throws SocialDriverNotConfigured */ public function startRegister(string $socialDriver): RedirectResponse { $socialDriver = trim(strtolower($socialDriver)); $this->driverManager->ensureDriverActive($socialDriver); return $this->getDriverForRedirect($socialDriver)->redirect(); } /** * Handle the social registration process on callback. * * @throws UserRegistrationException */ public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser): SocialUser { // Check social account has not already been used if (SocialAccount::query()->where('driver_id', '=', $socialUser->getId())->exists()) { throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount' => $socialDriver]), '/login'); } if (User::query()->where('email', '=', $socialUser->getEmail())->exists()) { $email = $socialUser->getEmail(); throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $email]), '/login'); } return $socialUser; } /** * Get the social user details via the social driver. * * @throws SocialDriverNotConfigured */ public function getSocialUser(string $socialDriver): SocialUser { $socialDriver = trim(strtolower($socialDriver)); $this->driverManager->ensureDriverActive($socialDriver); return $this->socialite->driver($socialDriver)->user(); } /** * Handle the login process on a oAuth callback. * * @throws SocialSignInAccountNotUsed */ public function handleLoginCallback(string $socialDriver, SocialUser $socialUser) { $socialDriver = trim(strtolower($socialDriver)); $socialId = $socialUser->getId(); // Get any attached social accounts or users $socialAccount = SocialAccount::query()->where('driver_id', '=', $socialId)->first(); $isLoggedIn = auth()->check(); $currentUser = user(); $titleCaseDriver = Str::title($socialDriver); // When a user is not logged in and a matching SocialAccount exists, // Simply log the user into the application. if (!$isLoggedIn && $socialAccount !== null) { $this->loginService->login($socialAccount->user, $socialDriver); return redirect()->intended('/'); } // When a user is logged in but the social account does not exist, // Create the social account and attach it to the user & redirect to the profile page. if ($isLoggedIn && $socialAccount === null) { $account = $this->newSocialAccount($socialDriver, $socialUser); $currentUser->socialAccounts()->save($account); session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver])); return redirect('/my-account/auth#social_accounts'); } // When a user is logged in and the social account exists and is already linked to the current user. if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) { session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver])); return redirect('/my-account/auth#social_accounts'); } // When a user is logged in, A social account exists but the users do not match. if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) { session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver])); return redirect('/my-account/auth#social_accounts'); } // Otherwise let the user know this social account is not used by anyone. $message = trans('errors.social_account_not_used', ['socialAccount' => $titleCaseDriver]); if (setting('registration-enabled') && config('auth.method') !== 'ldap' && config('auth.method') !== 'saml2') { $message .= trans('errors.social_account_register_instructions', ['socialAccount' => $titleCaseDriver]); } throw new SocialSignInAccountNotUsed($message, '/login'); } /** * Get the social driver manager used by this service. */ public function drivers(): SocialDriverManager { return $this->driverManager; } /** * Fill and return a SocialAccount from the given driver name and SocialUser. */ public function newSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount { return new SocialAccount([ 'driver' => $socialDriver, 'driver_id' => $socialUser->getId(), 'avatar' => $socialUser->getAvatar(), ]); } /** * Detach a social account from a user. */ public function detachSocialAccount(string $socialDriver): void { user()->socialAccounts()->where('driver', '=', $socialDriver)->delete(); } /** * Provide redirect options per service for the Laravel Socialite driver. */ protected function getDriverForRedirect(string $driverName): Provider { $driver = $this->socialite->driver($driverName); if ($driver instanceof GoogleProvider && config('services.google.select_account')) { $driver->with(['prompt' => 'select_account']); } $this->driverManager->getConfigureForRedirectCallback($driverName)($driver); return $driver; } } ================================================ FILE: app/Access/SocialDriverManager.php ================================================ */ protected array $configureForRedirectCallbacks = []; /** * Check if the current config for the given driver allows auto-registration. */ public function isAutoRegisterEnabled(string $driver): bool { return $this->getDriverConfigProperty($driver, 'auto_register') === true; } /** * Check if the current config for the given driver allow email address auto-confirmation. */ public function isAutoConfirmEmailEnabled(string $driver): bool { return $this->getDriverConfigProperty($driver, 'auto_confirm') === true; } /** * Gets the names of the active social drivers, keyed by driver id. * @return array */ public function getActive(): array { $activeDrivers = []; foreach ($this->validDrivers as $driverKey) { if ($this->checkDriverConfigured($driverKey)) { $activeDrivers[$driverKey] = $this->getName($driverKey); } } return $activeDrivers; } /** * Get the configure-for-redirect callback for the given driver. * This is a callable that allows modification of the driver at redirect time. * Commonly used to perform custom dynamic configuration where required. * The callback is passed a \Laravel\Socialite\Contracts\Provider instance. */ public function getConfigureForRedirectCallback(string $driver): callable { return $this->configureForRedirectCallbacks[$driver] ?? (fn() => true); } /** * Add a custom socialite driver to be used. * Driver name should be lower_snake_case. * Config array should mirror the structure of a service * within the `Config/services.php` file. * Handler should be a Class@method handler to the SocialiteWasCalled event. */ public function addSocialDriver( string $driverName, array $config, string $socialiteHandler, ?callable $configureForRedirect = null ) { $this->validDrivers[] = $driverName; config()->set('services.' . $driverName, $config); config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback')); config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName); Event::listen(SocialiteWasCalled::class, $socialiteHandler); if (!is_null($configureForRedirect)) { $this->configureForRedirectCallbacks[$driverName] = $configureForRedirect; } } /** * Get the presentational name for a driver. */ protected function getName(string $driver): string { return $this->getDriverConfigProperty($driver, 'name') ?? ''; } protected function getDriverConfigProperty(string $driver, string $property): mixed { return config("services.{$driver}.{$property}"); } /** * Ensure the social driver is correct and supported. * * @throws SocialDriverNotConfigured */ public function ensureDriverActive(string $driverName): void { if (!in_array($driverName, $this->validDrivers)) { abort(404, trans('errors.social_driver_not_found')); } if (!$this->checkDriverConfigured($driverName)) { throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($driverName)])); } } /** * Check a social driver has been configured correctly. */ protected function checkDriverConfigured(string $driver): bool { $lowerName = strtolower($driver); $configPrefix = 'services.' . $lowerName . '.'; $config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')]; return !in_array(false, $config) && !in_array(null, $config); } } ================================================ FILE: app/Access/UserInviteException.php ================================================ deleteByUser($user); $token = $this->createTokenForUser($user); try { $user->notify(new UserInviteNotification($token)); } catch (\Exception $exception) { throw new UserInviteException($exception->getMessage(), $exception->getCode(), $exception); } } } ================================================ FILE: app/Access/UserTokenService.php ================================================ tokenTable) ->where('user_id', '=', $user->id) ->delete(); } /** * Get the user id from a token, while checking the token exists and has not expired. * * @throws UserTokenNotFoundException * @throws UserTokenExpiredException */ public function checkTokenAndGetUserId(string $token): int { $entry = $this->getEntryByToken($token); if (is_null($entry)) { throw new UserTokenNotFoundException('Token "' . $token . '" not found'); } if ($this->entryExpired($entry)) { throw new UserTokenExpiredException("Token of id {$entry->id} has expired.", $entry->user_id); } return $entry->user_id; } /** * Creates a unique token within the email confirmation database. */ protected function generateToken(): string { $token = Str::random(24); while ($this->tokenExists($token)) { $token = Str::random(25); } return $token; } /** * Generate and store a token for the given user. */ protected function createTokenForUser(User $user): string { $token = $this->generateToken(); DB::table($this->tokenTable)->insert([ 'user_id' => $user->id, 'token' => $token, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]); return $token; } /** * Check if the given token exists. */ protected function tokenExists(string $token): bool { return DB::table($this->tokenTable) ->where('token', '=', $token)->exists(); } /** * Get a token entry for the given token. */ protected function getEntryByToken(string $token): ?stdClass { return DB::table($this->tokenTable) ->where('token', '=', $token) ->first(); } /** * Check if the given token entry has expired. */ protected function entryExpired(stdClass $tokenEntry): bool { return Carbon::now()->subHours($this->expiryTime) ->gt(new Carbon($tokenEntry->created_at)); } } ================================================ FILE: app/Activity/ActivityQueries.php ================================================ permissions ->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type') ->orderBy('created_at', 'desc') ->with(['user']) ->skip($count * $page) ->take($count) ->get(); $this->listLoader->loadIntoRelations($activityList->all(), 'loggable', false); return $this->filterSimilar($activityList); } /** * Gets the latest activity for an entity, Filtering out similar * items to prevent a message activity list. */ public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array { /** @var array $queryIds */ $queryIds = [$entity->getMorphClass() => [$entity->id]]; if ($entity instanceof Book) { $queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->scopes('visible')->pluck('id'); } if ($entity instanceof Book || $entity instanceof Chapter) { $queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id'); } $query = Activity::query(); $query->where(function (Builder $query) use ($queryIds) { foreach ($queryIds as $morphClass => $idArr) { $query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) { $innerQuery->where('loggable_type', '=', $morphClass) ->whereIn('loggable_id', $idArr); }); } }); $activity = $query->orderBy('created_at', 'desc') ->with(['loggable' => function (Relation $query) { /** @var MorphTo $query */ $query->withTrashed(); }, 'user.avatar']) ->skip($count * ($page - 1)) ->take($count) ->get(); return $this->filterSimilar($activity); } /** * Get the latest activity for a user, Filtering out similar items. */ public function userActivity(User $user, int $count = 20, int $page = 0): array { $activityList = $this->permissions ->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type') ->orderBy('created_at', 'desc') ->where('user_id', '=', $user->id) ->skip($count * $page) ->take($count) ->get(); return $this->filterSimilar($activityList); } /** * Filters out similar activity. * * @param Activity[] $activities */ protected function filterSimilar(iterable $activities): array { $newActivity = []; $previousItem = null; foreach ($activities as $activityItem) { if (!$previousItem || !$activityItem->isSimilarTo($previousItem)) { $newActivity[] = $activityItem; } $previousItem = $activityItem; } return $newActivity; } } ================================================ FILE: app/Activity/ActivityType.php ================================================ getConstants(); } } ================================================ FILE: app/Activity/CommentRepo.php ================================================ findOrFail($id); } /** * Get a comment by ID, ensuring it is visible to the user based upon access to the page * which the comment is attached to. */ public function getVisibleById(int $id): Comment { return $this->getQueryForVisible()->findOrFail($id); } /** * Start a query for comments visible to the user. * @return Builder */ public function getQueryForVisible(): Builder { return Comment::query()->scopes('visible'); } /** * Create a new comment on an entity. */ public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment { // Prevent comments being added to draft pages if ($entity instanceof Page && $entity->draft) { throw new \Exception(trans('errors.cannot_add_comment_to_draft')); } // Validate parent ID if ($parentId !== null) { $parentCommentExists = Comment::query() ->where('commentable_id', '=', $entity->id) ->where('commentable_type', '=', $entity->getMorphClass()) ->where('local_id', '=', $parentId) ->exists(); if (!$parentCommentExists) { $parentId = null; } } $userId = user()->id; $comment = new Comment(); $comment->html = HtmlDescriptionFilter::filterFromString($html); $comment->created_by = $userId; $comment->updated_by = $userId; $comment->local_id = $this->getNextLocalId($entity); $comment->parent_id = $parentId; $comment->content_ref = preg_match('/^bkmrk-(.*?):\d+:(\d*-\d*)?$/', $contentRef) === 1 ? $contentRef : ''; $entity->comments()->save($comment); ActivityService::add(ActivityType::COMMENT_CREATE, $comment); ActivityService::add(ActivityType::COMMENTED_ON, $entity); $comment->refresh()->unsetRelations(); return $comment; } /** * Update an existing comment. */ public function update(Comment $comment, string $html): Comment { $comment->updated_by = user()->id; $comment->html = HtmlDescriptionFilter::filterFromString($html); $comment->save(); ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); return $comment; } /** * Archive an existing comment. */ public function archive(Comment $comment, bool $log = true): Comment { if ($comment->parent_id) { throw new NotifyException('Only top-level comments can be archived.', '/', 400); } $comment->archived = true; $comment->save(); if ($log) { ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); } return $comment; } /** * Un-archive an existing comment. */ public function unarchive(Comment $comment, bool $log = true): Comment { if ($comment->parent_id) { throw new NotifyException('Only top-level comments can be un-archived.', '/', 400); } $comment->archived = false; $comment->save(); if ($log) { ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); } return $comment; } /** * Delete a comment from the system. */ public function delete(Comment $comment): void { $comment->delete(); ActivityService::add(ActivityType::COMMENT_DELETE, $comment); } /** * Get the next local ID relative to the linked entity. */ protected function getNextLocalId(Entity $entity): int { $currentMaxId = $entity->comments()->max('local_id'); return $currentMaxId + 1; } } ================================================ FILE: app/Activity/Controllers/AuditLogApiController.php ================================================ checkPermission(Permission::SettingsManage); $this->checkPermission(Permission::UsersManage); $query = Activity::query()->with(['user']); return $this->apiListingResponse($query, [ 'id', 'type', 'detail', 'user_id', 'loggable_id', 'loggable_type', 'ip', 'created_at', ]); } } ================================================ FILE: app/Activity/Controllers/AuditLogController.php ================================================ checkPermission(Permission::SettingsManage); $this->checkPermission(Permission::UsersManage); $sort = $request->get('sort', 'activity_date'); $order = $request->get('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', ''), ]; $query = Activity::query() ->with([ 'loggable' => fn ($query) => $query->withTrashed(), 'user', ]) ->orderBy($listOptions->getSort(), $listOptions->getOrder()); if ($filters['event']) { $query->where('type', '=', $filters['event']); } if ($filters['user']) { $query->where('user_id', '=', $filters['user']); } if ($filters['date_from']) { $query->where('created_at', '>=', $filters['date_from']); } if ($filters['date_to']) { $query->where('created_at', '<=', $filters['date_to']); } if ($filters['ip']) { $query->where('ip', 'like', $filters['ip'] . '%'); } $activities = $query->paginate(100); $activities->appends($request->all()); $types = ActivityType::all(); $this->setPageTitle(trans('settings.audit')); return view('settings.audit', [ 'activities' => $activities, 'filters' => $filters, 'listOptions' => $listOptions, 'activityTypes' => $types, 'filterSortUrl' => new SortUrl('settings/audit', array_filter($request->except('page'))) ]); } } ================================================ FILE: app/Activity/Controllers/CommentApiController.php ================================================ [ 'page_id' => ['required', 'integer'], 'reply_to' => ['nullable', 'integer'], 'html' => ['required', 'string'], 'content_ref' => ['string'], ], 'update' => [ 'html' => ['string'], 'archived' => ['boolean'], ] ]; public function __construct( protected CommentRepo $commentRepo, protected PageQueries $pageQueries, ) { } /** * Get a listing of comments visible to the user. */ public function list(): JsonResponse { $query = $this->commentRepo->getQueryForVisible(); return $this->apiListingResponse($query, [ 'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at' ]); } /** * Create a new comment on a page. * If commenting as a reply to an existing comment, the 'reply_to' parameter * should be provided, set to the 'local_id' of the comment being replied to. */ public function create(Request $request): JsonResponse { $this->checkPermission(Permission::CommentCreateAll); $input = $this->validate($request, $this->rules()['create']); $page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']); $comment = $this->commentRepo->create( $page, $input['html'], $input['reply_to'] ?? null, $input['content_ref'] ?? '', ); return response()->json($comment); } /** * Read the details of a single comment, along with its direct replies. */ public function read(string $id): JsonResponse { $comment = $this->commentRepo->getVisibleById(intval($id)); $comment->load('createdBy', 'updatedBy'); $replies = $this->commentRepo->getQueryForVisible() ->where('parent_id', '=', $comment->local_id) ->where('commentable_id', '=', $comment->commentable_id) ->where('commentable_type', '=', $comment->commentable_type) ->get(); /** @var Comment[] $toProcess */ $toProcess = [$comment, ...$replies]; foreach ($toProcess as $commentToProcess) { $commentToProcess->setAttribute('html', $commentToProcess->safeHtml()); $commentToProcess->makeVisible('html'); } $comment->setRelation('replies', $replies); return response()->json($comment); } /** * Update the content or archived status of an existing comment. * * Only provide a new archived status if needing to actively change the archive state. * Only top-level comments (non-replies) can be archived or unarchived. */ public function update(Request $request, string $id): JsonResponse { $comment = $this->commentRepo->getVisibleById(intval($id)); $this->checkOwnablePermission(Permission::CommentUpdate, $comment); $input = $this->validate($request, $this->rules()['update']); $hasHtml = isset($input['html']); if (isset($input['archived'])) { if ($input['archived']) { $this->commentRepo->archive($comment, !$hasHtml); } else { $this->commentRepo->unarchive($comment, !$hasHtml); } } if ($hasHtml) { $comment = $this->commentRepo->update($comment, $input['html']); } return response()->json($comment); } /** * Delete a single comment from the system. */ public function delete(string $id): Response { $comment = $this->commentRepo->getVisibleById(intval($id)); $this->checkOwnablePermission(Permission::CommentDelete, $comment); $this->commentRepo->delete($comment); return response('', 204); } } ================================================ FILE: app/Activity/Controllers/CommentController.php ================================================ validate($request, [ 'html' => ['required', 'string'], 'parent_id' => ['nullable', 'integer'], 'content_ref' => ['string'], ]); $page = $this->pageQueries->findVisibleById($pageId); if ($page === null) { return response('Not found', 404); } // Create a new comment. $this->checkPermission(Permission::CommentCreateAll); $contentRef = $input['content_ref'] ?? ''; $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef); return view('comments.comment-branch', [ 'readOnly' => false, 'branch' => new CommentTreeNode($comment, 0, []), ]); } /** * Update an existing comment. * * @throws ValidationException */ public function update(Request $request, int $commentId) { $input = $this->validate($request, [ 'html' => ['required', 'string'], ]); $comment = $this->commentRepo->getById($commentId); $this->checkOwnablePermission(Permission::PageView, $comment->entity); $this->checkOwnablePermission(Permission::CommentUpdate, $comment); $comment = $this->commentRepo->update($comment, $input['html']); return view('comments.comment', [ 'comment' => $comment, 'readOnly' => false, ]); } /** * Mark a comment as archived. */ public function archive(int $id) { $comment = $this->commentRepo->getById($id); $this->checkOwnablePermission(Permission::PageView, $comment->entity); if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) { $this->showPermissionError(); } $this->commentRepo->archive($comment); $tree = new CommentTree($comment->entity); return view('comments.comment-branch', [ 'readOnly' => false, 'branch' => $tree->getCommentNodeForId($id), ]); } /** * Unmark a comment as archived. */ public function unarchive(int $id) { $comment = $this->commentRepo->getById($id); $this->checkOwnablePermission(Permission::PageView, $comment->entity); if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) { $this->showPermissionError(); } $this->commentRepo->unarchive($comment); $tree = new CommentTree($comment->entity); return view('comments.comment-branch', [ 'readOnly' => false, 'branch' => $tree->getCommentNodeForId($id), ]); } /** * Delete a comment from the system. */ public function destroy(int $id) { $comment = $this->commentRepo->getById($id); $this->checkOwnablePermission(Permission::CommentDelete, $comment); $this->commentRepo->delete($comment); return response()->json(['message' => trans('entities.comment_deleted')]); } } ================================================ FILE: app/Activity/Controllers/FavouriteController.php ================================================ get('page', 1)); $favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount)); $hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null; $this->setPageTitle(trans('entities.my_favourites')); return view('common.detailed-listing-with-more', [ 'title' => trans('entities.my_favourites'), 'entities' => $favourites->slice(0, $viewCount), 'hasMoreLink' => $hasMoreLink, ]); } /** * Add a new item as a favourite. */ public function add(Request $request) { $modelInfo = $this->validate($request, $this->entityHelper->validationRules()); $entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo); $entity->favourites()->firstOrCreate([ 'user_id' => user()->id, ]); $this->showSuccessNotification(trans('activities.favourite_add_notification', [ 'name' => $entity->name, ])); return redirect($entity->getUrl()); } /** * Remove an item as a favourite. */ public function remove(Request $request) { $modelInfo = $this->validate($request, $this->entityHelper->validationRules()); $entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo); $entity->favourites()->where([ 'user_id' => user()->id, ])->delete(); $this->showSuccessNotification(trans('activities.favourite_remove_notification', [ 'name' => $entity->name, ])); return redirect($entity->getUrl()); } } ================================================ FILE: app/Activity/Controllers/TagController.php ================================================ withSortOptions([ 'name' => trans('common.sort_name'), 'usages' => trans('entities.tags_usages'), ]); $nameFilter = $request->get('name', ''); $tags = $this->tagRepo ->queryWithTotals($listOptions, $nameFilter) ->paginate(50) ->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [ 'name' => $nameFilter, ]))); $this->setPageTitle(trans('entities.tags')); return view('tags.index', [ 'tags' => $tags, 'nameFilter' => $nameFilter, 'listOptions' => $listOptions, ]); } /** * Get tag name suggestions from a given search term. */ public function getNameSuggestions(Request $request) { $searchTerm = $request->get('search', ''); $suggestions = $this->tagRepo->getNameSuggestions($searchTerm); return response()->json($suggestions); } /** * Get tag value suggestions from a given search term. */ public function getValueSuggestions(Request $request) { $searchTerm = $request->get('search', ''); $tagName = $request->get('name', ''); $suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName); return response()->json($suggestions); } } ================================================ FILE: app/Activity/Controllers/WatchController.php ================================================ checkPermission(Permission::ReceiveNotifications); $this->preventGuestAccess(); $requestData = $this->validate($request, array_merge([ 'level' => ['required', 'string'], ], $entityHelper->validationRules())); $watchable = $entityHelper->getVisibleEntityFromRequestData($requestData); $watchOptions = new UserEntityWatchOptions(user(), $watchable); $watchOptions->updateLevelByName($requestData['level']); $this->showSuccessNotification(trans('activities.watch_update_level_notification')); return redirect($watchable->getUrl()); } } ================================================ FILE: app/Activity/Controllers/WebhookController.php ================================================ middleware([ Permission::SettingsManage->middleware() ]); } /** * Show all webhooks configured in the system. */ public function index(Request $request) { $listOptions = SimpleListOptions::fromRequest($request, 'webhooks')->withSortOptions([ 'name' => trans('common.sort_name'), 'endpoint' => trans('settings.webhooks_endpoint'), 'created_at' => trans('common.sort_created_at'), 'updated_at' => trans('common.sort_updated_at'), 'active' => trans('common.status'), ]); $webhooks = (new WebhooksAllPaginatedAndSorted())->run(20, $listOptions); $webhooks->appends($listOptions->getPaginationAppends()); $this->setPageTitle(trans('settings.webhooks')); return view('settings.webhooks.index', [ 'webhooks' => $webhooks, 'listOptions' => $listOptions, ]); } /** * Show the view for creating a new webhook in the system. */ public function create() { $this->setPageTitle(trans('settings.webhooks_create')); return view('settings.webhooks.create'); } /** * Store a new webhook in the system. */ public function store(Request $request) { $validated = $this->validate($request, [ 'name' => ['required', 'max:150'], 'endpoint' => ['required', 'url', 'max:500'], 'events' => ['required', 'array'], 'active' => ['required'], 'timeout' => ['required', 'integer', 'min:1', 'max:600'], ]); $webhook = new Webhook($validated); $webhook->active = $validated['active'] === 'true'; $webhook->save(); $webhook->updateTrackedEvents(array_values($validated['events'])); $this->logActivity(ActivityType::WEBHOOK_CREATE, $webhook); return redirect('/settings/webhooks'); } /** * Show the view to edit an existing webhook. */ public function edit(string $id) { /** @var Webhook $webhook */ $webhook = Webhook::query() ->with('trackedEvents') ->findOrFail($id); $this->setPageTitle(trans('settings.webhooks_edit')); return view('settings.webhooks.edit', ['webhook' => $webhook]); } /** * Update an existing webhook with the provided request data. */ public function update(Request $request, string $id) { $validated = $this->validate($request, [ 'name' => ['required', 'max:150'], 'endpoint' => ['required', 'url', 'max:500'], 'events' => ['required', 'array'], 'active' => ['required'], 'timeout' => ['required', 'integer', 'min:1', 'max:600'], ]); /** @var Webhook $webhook */ $webhook = Webhook::query()->findOrFail($id); $webhook->active = $validated['active'] === 'true'; $webhook->fill($validated)->save(); $webhook->updateTrackedEvents($validated['events']); $this->logActivity(ActivityType::WEBHOOK_UPDATE, $webhook); return redirect('/settings/webhooks'); } /** * Show the view to delete a webhook. */ public function delete(string $id) { /** @var Webhook $webhook */ $webhook = Webhook::query()->findOrFail($id); $this->setPageTitle(trans('settings.webhooks_delete')); return view('settings.webhooks.delete', ['webhook' => $webhook]); } /** * Destroy a webhook from the system. */ public function destroy(string $id) { /** @var Webhook $webhook */ $webhook = Webhook::query()->findOrFail($id); $webhook->trackedEvents()->delete(); $webhook->delete(); $this->logActivity(ActivityType::WEBHOOK_DELETE, $webhook); return redirect('/settings/webhooks'); } } ================================================ FILE: app/Activity/DispatchWebhookJob.php ================================================ webhook = $webhook; $this->initiator = user(); $this->initiatedTime = time(); $themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $event, $this->webhook, $detail, $this->initiator, $this->initiatedTime); $this->webhookData = $themeResponse ?? WebhookFormatter::getDefault($event, $this->webhook, $detail, $this->initiator, $this->initiatedTime)->format(); } /** * Execute the job. * * @return void */ public function handle(HttpRequestService $http) { $lastError = null; try { (new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint); $client = $http->buildClient($this->webhook->timeout, [ 'connect_timeout' => 10, 'allow_redirects' => ['strict' => true], ]); $response = $client->sendRequest($http->jsonRequest('POST', $this->webhook->endpoint, $this->webhookData)); $statusCode = $response->getStatusCode(); if ($statusCode >= 400) { $lastError = "Response status from endpoint was {$statusCode}"; Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$statusCode}"); } } catch (\Exception $error) { $lastError = $error->getMessage(); Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\""); } $this->webhook->last_called_at = now(); if ($lastError) { $this->webhook->last_errored_at = now(); $this->webhook->last_error = $lastError; } $this->webhook->save(); } } ================================================ FILE: app/Activity/Models/Activity.php ================================================ morphTo('loggable'); } /** * Get the user this activity relates to. */ public function user(): BelongsTo { return $this->belongsTo(User::class); } public function jointPermissions(): HasMany { return $this->hasMany(JointPermission::class, 'entity_id', 'loggable_id') ->whereColumn('activities.loggable_type', '=', 'joint_permissions.entity_type'); } /** * Returns text from the language files, Looks up by using the activity key. */ public function getText(): string { return trans('activities.' . $this->type); } /** * Check if this activity is intended to be for an entity. */ public function isForEntity(): bool { return Str::startsWith($this->type, [ 'page_', 'chapter_', 'book_', 'bookshelf_', ]); } /** * Checks if another Activity matches the general information of another. */ public function isSimilarTo(self $activityB): bool { return [$this->type, $this->loggable_type, $this->loggable_id] === [$activityB->type, $activityB->loggable_type, $activityB->loggable_id]; } } ================================================ FILE: app/Activity/Models/Comment.php ================================================ 'boolean', ]; /** * Get the entity that this comment belongs to. */ public function entity(): MorphTo { // We specifically define null here to avoid the different name (commentable) // being used by Laravel eager loading instead of the method name, which it was doing // in some scenarios like when deserialized when going through the queue system. // So we instead specify the type and id column names to use. // Related to: // https://github.com/laravel/framework/pull/24815 // https://github.com/laravel/framework/issues/27342 // https://github.com/laravel/framework/issues/47953 // (and probably more) // Ultimately, we could just align the method name to 'commentable' but that would be a potential // breaking change and not really worthwhile in a patch due to the risk of creating extra problems. return $this->morphTo(null, 'commentable_type', 'commentable_id'); } /** * Get the parent comment this is in reply to (if existing). * @return BelongsTo */ public function parent(): BelongsTo { return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent') ->where('commentable_type', '=', $this->commentable_type) ->where('commentable_id', '=', $this->commentable_id); } /** * Check if a comment has been updated since creation. */ public function isUpdated(): bool { return $this->updated_at->timestamp > $this->created_at->timestamp; } public function logDescriptor(): string { return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})"; } public function safeHtml(): string { $filter = new HtmlContentFilter(new HtmlContentFilterConfig()); return $filter->filterString($this->html ?? ''); } public function jointPermissions(): HasMany { return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id') ->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type'); } /** * Scope the query to just the comments visible to the user based upon the * user visibility of what has been commented on. */ public function scopeVisible(Builder $query): Builder { return app()->make(PermissionApplicator::class) ->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type'); } } ================================================ FILE: app/Activity/Models/Favouritable.php ================================================ morphTo(); } public function jointPermissions(): HasMany { return $this->hasMany(JointPermission::class, 'entity_id', 'favouritable_id') ->whereColumn('favourites.favouritable_type', '=', 'joint_permissions.entity_type'); } } ================================================ FILE: app/Activity/Models/Loggable.php ================================================ morphTo('entity'); } public function jointPermissions(): HasMany { return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id') ->whereColumn('tags.entity_type', '=', 'joint_permissions.entity_type'); } /** * Get a full URL to start a tag name search for this tag name. */ public function nameUrl(): string { return url('/search?term=%5B' . urlencode($this->name) . '%5D'); } /** * Get a full URL to start a tag name and value search for this tag's values. */ public function valueUrl(): string { return url('/search?term=%5B' . urlencode($this->name) . '%3D' . urlencode($this->value) . '%5D'); } } ================================================ FILE: app/Activity/Models/View.php ================================================ morphTo(); } public function jointPermissions(): HasMany { return $this->hasMany(JointPermission::class, 'entity_id', 'viewable_id') ->whereColumn('views.viewable_type', '=', 'joint_permissions.entity_type'); } /** * Increment the current user's view count for the given viewable model. */ public static function incrementFor(Viewable $viewable): int { $user = user(); if ($user->isGuest()) { return 0; } /** @var View $view */ $view = $viewable->views()->firstOrNew([ 'user_id' => $user->id, ], ['views' => 0]); $view->forceFill(['views' => $view->views + 1])->save(); return $view->views; } } ================================================ FILE: app/Activity/Models/Viewable.php ================================================ morphTo(); } public function jointPermissions(): HasMany { return $this->hasMany(JointPermission::class, 'entity_id', 'watchable_id') ->whereColumn('watches.watchable_type', '=', 'joint_permissions.entity_type'); } public function getLevelName(): string { return WatchLevels::levelValueToName($this->level); } public function ignoring(): bool { return $this->level === WatchLevels::IGNORE; } } ================================================ FILE: app/Activity/Models/Webhook.php ================================================ 'datetime', 'last_errored_at' => 'datetime', ]; /** * Define the tracked event relation a webhook. */ public function trackedEvents(): HasMany { return $this->hasMany(WebhookTrackedEvent::class); } /** * Update the tracked events for a webhook from the given list of event types. */ public function updateTrackedEvents(array $events): void { $this->trackedEvents()->delete(); $eventsToStore = array_intersect($events, array_values(ActivityType::all())); if (in_array('all', $events)) { $eventsToStore = ['all']; } $trackedEvents = []; foreach ($eventsToStore as $event) { $trackedEvents[] = new WebhookTrackedEvent(['event' => $event]); } $this->trackedEvents()->saveMany($trackedEvents); } /** * Check if this webhook tracks the given event. */ public function tracksEvent(string $event): bool { return $this->trackedEvents->pluck('event')->contains($event); } /** * Get a URL for this webhook within the settings interface. */ public function getUrl(string $path = ''): string { return url('/settings/webhooks/' . $this->id . '/' . ltrim($path, '/')); } /** * Get the string descriptor for this item. */ public function logDescriptor(): string { return "({$this->id}) {$this->name}"; } } ================================================ FILE: app/Activity/Models/WebhookTrackedEvent.php ================================================ $notification * @param int[] $userIds */ protected function sendNotificationToUserIds(string $notification, array $userIds, User $initiator, string|Loggable $detail, Entity $relatedModel): void { $users = User::query()->whereIn('id', array_unique($userIds))->get(); /** @var User $user */ foreach ($users as $user) { // Prevent sending to the user that initiated the activity if ($user->id === $initiator->id) { continue; } // Prevent sending of the user does not have notification permissions if (!$user->can(Permission::ReceiveNotifications)) { continue; } // Prevent sending if the user does not have access to the related content $permissions = new PermissionApplicator($user); if (!$permissions->checkOwnableUserAccess($relatedModel, 'view')) { continue; } // Send the notification try { $user->notify(new $notification($detail, $initiator)); } catch (\Exception $exception) { Log::error("Failed to send email notification to user [id:{$user->id}] with error: {$exception->getMessage()}"); } } } } ================================================ FILE: app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php ================================================ entity; $watchers = new EntityWatchers($page, WatchLevels::COMMENTS); $watcherIds = $watchers->getWatcherUserIds(); // Page owner if user preferences allow if ($page->owned_by && !$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) { $userNotificationPrefs = new UserNotificationPreferences($page->ownedBy); if ($userNotificationPrefs->notifyOnOwnPageComments()) { $watcherIds[] = $page->owned_by; } } // Parent comment creator if preferences allow $parentComment = $detail->parent()->first(); if ($parentComment && $parentComment->created_by && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) { $parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy); if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) { $watcherIds[] = $parentComment->created_by; } } $this->sendNotificationToUserIds(CommentCreationNotification::class, $watcherIds, $user, $detail, $page); } } ================================================ FILE: app/Activity/Notifications/Handlers/CommentMentionNotificationHandler.php ================================================ entity instanceof Page)) { throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page"); } /** @var Page $page */ $page = $detail->entity; $parser = new MentionParser(); $mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html); $realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get(); $receivingNotifications = $realMentionedUsers->filter(function (User $user) { $prefs = new UserNotificationPreferences($user); return $prefs->notifyOnCommentMentions(); }); $receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray(); $userMentionsToLog = $realMentionedUsers; // When an edit, we check our history to see if we've already notified the user about this comment before // so that we can filter them out to avoid double notifications. if ($activity->type === ActivityType::COMMENT_UPDATE) { $previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail); $receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds)); $userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) { return !in_array($user->id, $previouslyNotifiedUserIds); }); } $this->logMentions($userMentionsToLog, $detail, $user); $this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page); } /** * @param Collection $mentionedUsers */ protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void { $mentions = []; $now = Carbon::now(); foreach ($mentionedUsers as $mentionedUser) { $mentions[] = [ 'mentionable_type' => $comment->getMorphClass(), 'mentionable_id' => $comment->id, 'from_user_id' => $fromUser->id, 'to_user_id' => $mentionedUser->id, 'created_at' => $now, 'updated_at' => $now, ]; } MentionHistory::query()->insert($mentions); } protected function getPreviouslyNotifiedUserIds(Comment $comment): array { return MentionHistory::query() ->where('mentionable_id', $comment->id) ->where('mentionable_type', $comment->getMorphClass()) ->pluck('to_user_id') ->toArray(); } } ================================================ FILE: app/Activity/Notifications/Handlers/NotificationHandler.php ================================================ sendNotificationToUserIds(PageCreationNotification::class, $watchers->getWatcherUserIds(), $user, $detail, $detail); } } ================================================ FILE: app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php ================================================ activity() ->where('type', '=', ActivityType::PAGE_UPDATE) ->where('id', '!=', $activity->id) ->latest('created_at') ->first(); // Return if the same user has already updated the page in the last 15 mins if ($lastUpdate && $lastUpdate->user_id === $user->id) { if ($lastUpdate->created_at->gt(now()->subMinutes(15))) { return; } } // Get active watchers $watchers = new EntityWatchers($detail, WatchLevels::UPDATES); $watcherIds = $watchers->getWatcherUserIds(); // Add the page owner if preferences allow if ($detail->owned_by && !$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) { $userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy); if ($userNotificationPrefs->notifyOnOwnPageChanges()) { $watcherIds[] = $detail->owned_by; } } $this->sendNotificationToUserIds(PageUpdateNotification::class, $watcherIds, $user, $detail, $detail); } } ================================================ FILE: app/Activity/Notifications/MessageParts/EntityLinkMessageLine.php ================================================ entity->getUrl()) . '">' . e($this->entity->getShortName($this->nameLength)) . ''; } public function __toString(): string { return "{$this->entity->getShortName($this->nameLength)} ({$this->entity->getUrl()})"; } } ================================================ FILE: app/Activity/Notifications/MessageParts/EntityPathMessageLine.php ================================================ entityLinks = array_map(fn (Entity $entity) => new EntityLinkMessageLine($entity, 24), $this->entities); } public function toHtml(): string { $entityHtmls = array_map(fn (EntityLinkMessageLine $line) => $line->toHtml(), $this->entityLinks); return implode(' > ', $entityHtmls); } public function __toString(): string { return implode(' > ', $this->entityLinks); } } ================================================ FILE: app/Activity/Notifications/MessageParts/LinkedMailMessageLine.php ================================================ url) . '">' . e($this->linkText) . ''; return str_replace(':link', $link, e($this->line)); } public function __toString(): string { $link = "{$this->linkText} ({$this->url})"; return str_replace(':link', $link, $this->line); } } ================================================ FILE: app/Activity/Notifications/MessageParts/ListMessageLine.php ================================================ list as $header => $content) { $list[] = '' . e($header) . ' ' . e($content); } return implode("
\n", $list); } public function __toString(): string { $list = []; foreach ($this->list as $header => $content) { $list[] = $header . ' ' . $content; } return implode("\n", $list); } } ================================================ FILE: app/Activity/Notifications/Messages/BaseActivityNotification.php ================================================ $this->detail, 'activity_creator' => $this->user, ]; } /** * Build the common reason footer line used in mail messages. */ protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine { return new LinkedMailMessageLine( url('/my-account/notifications'), $locale->trans('notifications.footer_reason'), $locale->trans('notifications.footer_reason_link'), ); } /** * Build a line which provides the book > chapter path to a page. * Takes into account visibility of these parent items. * Returns null if no path items can be used. */ protected function buildPagePathLine(Page $page, User $notifiable): ?EntityPathMessageLine { $permissions = new PermissionApplicator($notifiable); $path = array_filter([$page->book, $page->chapter], function (?Entity $entity) use ($permissions) { return !is_null($entity) && $permissions->checkOwnableUserAccess($entity, 'view'); }); return empty($path) ? null : new EntityPathMessageLine($path); } } ================================================ FILE: app/Activity/Notifications/Messages/CommentCreationNotification.php ================================================ detail; /** @var Page $page */ $page = $comment->entity; $locale = $notifiable->getLocale(); $listLines = array_filter([ $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), ]); return $this->newMailMessage($locale) ->subject($locale->trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()])) ->line($locale->trans('notifications.new_comment_intro', ['appName' => setting('app-name')])) ->line(new ListMessageLine($listLines)) ->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id)) ->line($this->buildReasonFooterLine($locale)); } } ================================================ FILE: app/Activity/Notifications/Messages/CommentMentionNotification.php ================================================ detail; /** @var Page $page */ $page = $comment->entity; $locale = $notifiable->getLocale(); $listLines = array_filter([ $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), ]); return $this->newMailMessage($locale) ->subject($locale->trans('notifications.comment_mention_subject', ['pageName' => $page->getShortName()])) ->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')])) ->line(new ListMessageLine($listLines)) ->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id)) ->line($this->buildReasonFooterLine($locale)); } } ================================================ FILE: app/Activity/Notifications/Messages/PageCreationNotification.php ================================================ detail; $locale = $notifiable->getLocale(); $listLines = array_filter([ $locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page), $locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable), $locale->trans('notifications.detail_created_by') => $this->user->name, ]); return $this->newMailMessage($locale) ->subject($locale->trans('notifications.new_page_subject', ['pageName' => $page->getShortName()])) ->line($locale->trans('notifications.new_page_intro', ['appName' => setting('app-name')])) ->line(new ListMessageLine($listLines)) ->action($locale->trans('notifications.action_view_page'), $page->getUrl()) ->line($this->buildReasonFooterLine($locale)); } } ================================================ FILE: app/Activity/Notifications/Messages/PageUpdateNotification.php ================================================ detail; $locale = $notifiable->getLocale(); $listLines = array_filter([ $locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page), $locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable), $locale->trans('notifications.detail_updated_by') => $this->user->name, ]); return $this->newMailMessage($locale) ->subject($locale->trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()])) ->line($locale->trans('notifications.updated_page_intro', ['appName' => setting('app-name')])) ->line(new ListMessageLine($listLines)) ->line($locale->trans('notifications.updated_page_debounce')) ->action($locale->trans('notifications.action_view_page'), $page->getUrl()) ->line($this->buildReasonFooterLine($locale)); } } ================================================ FILE: app/Activity/Notifications/NotificationManager.php ================================================ [] */ protected array $handlers = []; public function handle(Activity $activity, string|Loggable $detail, User $user): void { $activityType = $activity->type; $handlersToRun = $this->handlers[$activityType] ?? []; foreach ($handlersToRun as $handlerClass) { /** @var NotificationHandler $handler */ $handler = new $handlerClass(); $handler->handle($activity, $detail, $user); } } /** * @param class-string $handlerClass */ public function registerHandler(string $activityType, string $handlerClass): void { if (!isset($this->handlers[$activityType])) { $this->handlers[$activityType] = []; } if (!in_array($handlerClass, $this->handlers[$activityType])) { $this->handlers[$activityType][] = $handlerClass; } } public function loadDefaultHandlers(): void { $this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class); $this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class); $this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class); $this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class); $this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class); } } ================================================ FILE: app/Activity/Queries/WebhooksAllPaginatedAndSorted.php ================================================ select(['*']) ->withCount(['trackedEvents']) ->orderBy($listOptions->getSort(), $listOptions->getOrder()); if ($listOptions->getSearch()) { $term = '%' . $listOptions->getSearch() . '%'; $query->where(function ($query) use ($term) { $query->where('name', 'like', $term) ->orWhere('endpoint', 'like', $term); }); } return $query->paginate($count); } } ================================================ FILE: app/Activity/TagRepo.php ================================================ getSearch(); $sort = $listOptions->getSort(); if ($sort === 'name' && $nameFilter) { $sort = 'value'; } $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'), ]) ->orderBy($sort, $listOptions->getOrder()) ->whereHas('entity'); if ($nameFilter) { $query->where('name', '=', $nameFilter); $query->groupBy('value'); } elseif ($searchTerm) { $query->groupBy('name', 'value'); } else { $query->groupBy('name'); } if ($searchTerm) { $query->where(function (Builder $query) use ($searchTerm) { $query->where('name', 'like', '%' . $searchTerm . '%') ->orWhere('value', 'like', '%' . $searchTerm . '%'); }); } return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); } /** * Get tag name suggestions from scanning existing tag names. * If no search term is given the 50 most popular tag names are provided. */ public function getNameSuggestions(string $searchTerm): Collection { $query = Tag::query() ->select('*', DB::raw('count(*) as count')) ->groupBy('name'); if ($searchTerm) { $query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'asc'); } else { $query = $query->orderBy('count', 'desc')->take(50); } $query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); return $query->pluck('name'); } /** * Get tag value suggestions from scanning existing tag values. * If no search is given the 50 most popular values are provided. * Passing a tagName will only find values for a tags with a particular name. */ public function getValueSuggestions(string $searchTerm, string $tagName): Collection { $query = Tag::query() ->select('*', DB::raw('count(*) as count')) ->where('value', '!=', '') ->groupBy('value'); if ($searchTerm) { $query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc'); } else { $query = $query->orderBy('count', 'desc')->take(50); } if ($tagName) { $query = $query->where('name', '=', $tagName); } $query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); return $query->pluck('value'); } /** * Save an array of tags to an entity. */ public function saveTagsToEntity(Entity $entity, array $tags = []): iterable { $entity->tags()->delete(); $newTags = collect($tags)->filter(function ($tag) { return boolval(trim($tag['name'])); })->map(function ($tag) { return $this->newInstanceFromInput($tag); })->all(); return $entity->tags()->saveMany($newTags); } /** * Create a new Tag instance from user input. * Input must be an array with a 'name' and an optional 'value' key. */ protected function newInstanceFromInput(array $input): Tag { return new Tag([ 'name' => trim($input['name']), 'value' => trim($input['value'] ?? ''), ]); } } ================================================ FILE: app/Activity/Tools/ActivityLogger.php ================================================ notifications->loadDefaultHandlers(); } /** * Add a generic activity event to the database. */ public function add(string $type, string|Loggable $detail = ''): void { $detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail; $activity = $this->newActivityForUser($type); $activity->detail = $detailToStore; if ($detail instanceof Entity) { $activity->loggable_id = $detail->id; $activity->loggable_type = $detail->getMorphClass(); } $activity->save(); $this->setNotification($type); $this->dispatchWebhooks($type, $detail); $this->notifications->handle($activity, $detail, user()); Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail); } /** * Get a new activity instance for the current user. */ protected function newActivityForUser(string $type): Activity { return (new Activity())->forceFill([ 'type' => strtolower($type), 'user_id' => user()->id, 'ip' => IpFormatter::fromCurrentRequest()->format(), ]); } /** * Removes the entity attachment from each of its activities * and instead uses the 'extra' field with the entities name. * Used when an entity is deleted. */ public function removeEntity(Entity $entity): void { $entity->activity()->update([ 'detail' => $entity->name, 'loggable_id' => null, 'loggable_type' => null, ]); } /** * Flashes a notification message to the session if an appropriate message is available. */ protected function setNotification(string $type): void { $notificationTextKey = 'activities.' . $type . '_notification'; if (trans()->has($notificationTextKey)) { $message = trans($notificationTextKey); session()->flash('success', $message); } } protected function dispatchWebhooks(string $type, string|Loggable $detail): void { $webhooks = Webhook::query() ->whereHas('trackedEvents', function (Builder $query) use ($type) { $query->where('event', '=', $type) ->orWhere('event', '=', 'all'); }) ->where('active', '=', true) ->get(); foreach ($webhooks as $webhook) { dispatch(new DispatchWebhookJob($webhook, $type, $detail)); } } /** * Log out a failed login attempt, Providing the given username * as part of the message if the '%u' string is used. */ public function logFailedLogin(string $username): void { $message = config('logging.failed_login.message'); if (!$message) { return; } $message = str_replace('%u', $username, $message); $channel = config('logging.failed_login.channel'); Log::channel($channel)->warning($message); } } ================================================ FILE: app/Activity/Tools/CommentTree.php ================================================ comments = $this->loadComments(); $this->tree = $this->createTree($this->comments); } public function enabled(): bool { return !setting('app-disable-comments'); } public function empty(): bool { return count($this->getActive()) === 0; } public function count(): int { return count($this->comments); } public function getActive(): array { return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived)); } public function activeThreadCount(): int { return count($this->getActive()); } public function getArchived(): array { return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived)); } public function archivedThreadCount(): int { return count($this->getArchived()); } public function getCommentNodeForId(int $commentId): ?CommentTreeNode { foreach ($this->tree as $node) { if ($node->comment->id === $commentId) { return $node; } } return null; } public function canUpdateAny(): bool { foreach ($this->comments as $comment) { if (userCan(Permission::CommentUpdate, $comment)) { return true; } } return false; } public function loadVisibleHtml(): void { foreach ($this->comments as $comment) { $comment->setAttribute('html', $comment->safeHtml()); $comment->makeVisible('html'); } } /** * @param Comment[] $comments * @return CommentTreeNode[] */ protected function createTree(array $comments): array { $byId = []; foreach ($comments as $comment) { $byId[$comment->local_id] = $comment; } $childMap = []; foreach ($comments as $comment) { $parent = $comment->parent_id; if (is_null($parent) || !isset($byId[$parent])) { $parent = 0; } if (!isset($childMap[$parent])) { $childMap[$parent] = []; } $childMap[$parent][] = $comment->local_id; } $tree = []; foreach ($childMap[0] ?? [] as $childId) { $tree[] = $this->createTreeNodeForId($childId, 0, $byId, $childMap); } return $tree; } protected function createTreeNodeForId(int $id, int $depth, array &$byId, array &$childMap): CommentTreeNode { $childIds = $childMap[$id] ?? []; $children = []; foreach ($childIds as $childId) { $children[] = $this->createTreeNodeForId($childId, $depth + 1, $byId, $childMap); } return new CommentTreeNode($byId[$id], $depth, $children); } /** * @return Comment[] */ protected function loadComments(): array { if (!$this->enabled()) { return []; } return $this->page->comments() ->with('createdBy') ->get() ->all(); } } ================================================ FILE: app/Activity/Tools/CommentTreeNode.php ================================================ comment = $comment; $this->depth = $depth; $this->children = $children; } } ================================================ FILE: app/Activity/Tools/EntityWatchers.php ================================================ build(); } public function getWatcherUserIds(): array { return $this->watchers; } public function isUserIgnoring(int $userId): bool { return in_array($userId, $this->ignorers); } protected function build(): void { $watches = $this->getRelevantWatches(); // Sort before de-duping, so that the order looped below follows book -> chapter -> page ordering usort($watches, function (Watch $watchA, Watch $watchB) { $entityTypeDiff = $watchA->watchable_type <=> $watchB->watchable_type; return $entityTypeDiff === 0 ? ($watchA->user_id <=> $watchB->user_id) : $entityTypeDiff; }); // De-dupe by user id to get their most relevant level $levelByUserId = []; foreach ($watches as $watch) { $levelByUserId[$watch->user_id] = $watch->level; } // Populate the class arrays $this->watchers = array_keys(array_filter($levelByUserId, fn(int $level) => $level >= $this->watchLevel)); $this->ignorers = array_keys(array_filter($levelByUserId, fn(int $level) => $level === 0)); } /** * @return Watch[] */ protected function getRelevantWatches(): array { /** @var Entity[] $entitiesInvolved */ $entitiesInvolved = array_filter([ $this->entity, $this->entity instanceof BookChild ? $this->entity->book : null, $this->entity instanceof Page ? $this->entity->chapter : null, ]); $query = Watch::query()->where(function (Builder $query) use ($entitiesInvolved) { foreach ($entitiesInvolved as $entity) { $query->orWhere(function (Builder $query) use ($entity) { $query->where('watchable_type', '=', $entity->getMorphClass()) ->where('watchable_id', '=', $entity->id); }); } }); return $query->get([ 'level', 'watchable_id', 'watchable_type', 'user_id' ])->all(); } } ================================================ FILE: app/Activity/Tools/IpFormatter.php ================================================ ip = trim($ip); $this->precision = max(0, min($precision, 4)); } public function format(): string { if (empty($this->ip) || $this->precision === 4) { return $this->ip; } return $this->isIpv6() ? $this->maskIpv6() : $this->maskIpv4(); } protected function maskIpv4(): string { $exploded = $this->explodeAndExpandIp('.', 4); $maskGroupCount = min(4 - $this->precision, count($exploded)); for ($i = 0; $i < $maskGroupCount; $i++) { $exploded[3 - $i] = 'x'; } return implode('.', $exploded); } protected function maskIpv6(): string { $exploded = $this->explodeAndExpandIp(':', 8); $maskGroupCount = min(8 - ($this->precision * 2), count($exploded)); for ($i = 0; $i < $maskGroupCount; $i++) { $exploded[7 - $i] = 'x'; } return implode(':', $exploded); } protected function isIpv6(): bool { return strpos($this->ip, ':') !== false; } protected function explodeAndExpandIp(string $separator, int $targetLength): array { $exploded = explode($separator, $this->ip); while (count($exploded) < $targetLength) { $emptyIndex = array_search('', $exploded) ?: count($exploded) - 1; array_splice($exploded, $emptyIndex, 0, '0'); } $emptyIndex = array_search('', $exploded); if ($emptyIndex !== false) { $exploded[$emptyIndex] = '0'; } return $exploded; } public static function fromCurrentRequest(): self { $ip = request()->ip() ?? ''; if (config('app.env') === 'demo') { $ip = '127.0.0.1'; } return new self($ip, config('app.ip_address_precision')); } } ================================================ FILE: app/Activity/Tools/MentionParser.php ================================================ queryXPath('//a[@data-mention-user-id]'); foreach ($mentionLinks as $link) { if ($link instanceof DOMElement) { $id = intval($link->getAttribute('data-mention-user-id')); if ($id > 0) { $ids[] = $id; } } } return array_values(array_unique($ids)); } } ================================================ FILE: app/Activity/Tools/TagClassGenerator.php ================================================ entity->tags->all(); foreach ($tags as $tag) { array_push($classes, ...$this->generateClassesForTag($tag)); } if ($this->entity instanceof BookChild && userCan(Permission::BookView, $this->entity->book)) { $bookTags = $this->entity->book->tags; foreach ($bookTags as $bookTag) { array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-')); } } if ($this->entity instanceof Page && $this->entity->chapter && userCan(Permission::ChapterView, $this->entity->chapter)) { $chapterTags = $this->entity->chapter->tags; foreach ($chapterTags as $chapterTag) { array_push($classes, ...$this->generateClassesForTag($chapterTag, 'chapter-')); } } return array_unique($classes); } public function generateAsString(): string { return implode(' ', $this->generate()); } /** * @return string[] */ protected function generateClassesForTag(Tag $tag, string $prefix = ''): array { $classes = []; $name = $this->normalizeTagClassString($tag->name); $value = $this->normalizeTagClassString($tag->value); $classes[] = "{$prefix}tag-name-{$name}"; if ($value) { $classes[] = "{$prefix}tag-value-{$value}"; $classes[] = "{$prefix}tag-pair-{$name}-{$value}"; } return $classes; } protected function normalizeTagClassString(string $value): string { $value = str_replace(' ', '', strtolower($value)); $value = str_replace('-', '', strtolower($value)); return $value; } } ================================================ FILE: app/Activity/Tools/UserEntityWatchOptions.php ================================================ user->can(Permission::ReceiveNotifications) && !$this->user->isGuest(); } public function getWatchLevel(): string { return WatchLevels::levelValueToName($this->getWatchLevelValue()); } public function isWatching(): bool { return $this->getWatchLevelValue() !== WatchLevels::DEFAULT; } public function getWatchedParent(): ?WatchedParentDetails { $watchMap = $this->getWatchMap(); unset($watchMap[$this->entity->getMorphClass()]); if (isset($watchMap['chapter'])) { return new WatchedParentDetails('chapter', $watchMap['chapter']); } if (isset($watchMap['book'])) { return new WatchedParentDetails('book', $watchMap['book']); } return null; } public function updateLevelByName(string $level): void { $levelValue = WatchLevels::levelNameToValue($level); $this->updateLevelByValue($levelValue); } public function updateLevelByValue(int $level): void { if ($level < 0) { $this->remove(); return; } $this->updateLevel($level); } public function getWatchMap(): array { if (!is_null($this->watchMap)) { return $this->watchMap; } $entities = [$this->entity]; if ($this->entity instanceof BookChild) { $entities[] = $this->entity->book; } if ($this->entity instanceof Page && $this->entity->chapter) { $entities[] = $this->entity->chapter; } $query = Watch::query() ->where('user_id', '=', $this->user->id) ->where(function (Builder $subQuery) use ($entities) { foreach ($entities as $entity) { $subQuery->orWhere(function (Builder $whereQuery) use ($entity) { $whereQuery->where('watchable_type', '=', $entity->getMorphClass()) ->where('watchable_id', '=', $entity->id); }); } }); $this->watchMap = $query->get(['watchable_type', 'level']) ->pluck('level', 'watchable_type') ->toArray(); return $this->watchMap; } protected function getWatchLevelValue() { return $this->getWatchMap()[$this->entity->getMorphClass()] ?? WatchLevels::DEFAULT; } protected function updateLevel(int $levelValue): void { Watch::query()->updateOrCreate([ 'watchable_id' => $this->entity->id, 'watchable_type' => $this->entity->getMorphClass(), 'user_id' => $this->user->id, ], [ 'level' => $levelValue, ]); $this->watchMap = null; } protected function remove(): void { $this->entityQuery()->delete(); $this->watchMap = null; } protected function entityQuery(): Builder { return Watch::query()->where('watchable_id', '=', $this->entity->id) ->where('watchable_type', '=', $this->entity->getMorphClass()) ->where('user_id', '=', $this->user->id); } } ================================================ FILE: app/Activity/Tools/WatchedParentDetails.php ================================================ level === WatchLevels::IGNORE; } } ================================================ FILE: app/Activity/Tools/WebhookFormatter.php ================================================ webhook = $webhook; $this->event = $event; $this->initiator = $initiator; $this->initiatedTime = $initiatedTime; $this->detail = is_object($detail) ? clone $detail : $detail; } public function format(): array { $data = [ 'event' => $this->event, 'text' => $this->formatText(), 'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(), 'triggered_by' => $this->initiator->attributesToArray(), 'triggered_by_profile_url' => $this->initiator->getProfileUrl(), 'webhook_id' => $this->webhook->id, 'webhook_name' => $this->webhook->name, ]; if (method_exists($this->detail, 'getUrl')) { $data['url'] = $this->detail->getUrl(); } if ($this->detail instanceof Model) { $data['related_item'] = $this->formatModel($this->detail); } return $data; } /** * @param callable(string, Model):bool $condition * @param callable(Model):void $format */ public function addModelFormatter(callable $condition, callable $format): void { $this->modelFormatters[] = [ 'condition' => $condition, 'format' => $format, ]; } public function addDefaultModelFormatters(): void { // Load entity owner, creator, updater details $this->addModelFormatter( fn ($event, $model) => ($model instanceof Entity), fn ($model) => $model->load(['ownedBy', 'createdBy', 'updatedBy']) ); // Load revision detail for page update and create events $this->addModelFormatter( fn ($event, $model) => ($model instanceof Page && ($event === ActivityType::PAGE_CREATE || $event === ActivityType::PAGE_UPDATE)), fn ($model) => $model->load('currentRevision') ); } protected function formatModel(Model $model): array { $model->unsetRelations(); foreach ($this->modelFormatters as $formatter) { if ($formatter['condition']($this->event, $model)) { $formatter['format']($model); } } return $model->toArray(); } protected function formatText(): string { $textParts = [ $this->initiator->name, trans('activities.' . $this->event), ]; if ($this->detail instanceof Entity) { $textParts[] = '"' . $this->detail->name . '"'; } return implode(' ', $textParts); } public static function getDefault(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime): self { $instance = new self($event, $webhook, $detail, $initiator, $initiatedTime); $instance->addDefaultModelFormatters(); return $instance; } } ================================================ FILE: app/Activity/WatchLevels.php ================================================ value array. * @return array */ public static function all(): array { $options = []; foreach ((new \ReflectionClass(static::class))->getConstants() as $name => $value) { $options[strtolower($name)] = $value; } return $options; } /** * Get the watch options suited for the given entity. * @return array */ public static function allSuitedFor(Entity $entity): array { $options = static::all(); if ($entity instanceof Page) { unset($options['new']); } elseif ($entity instanceof Bookshelf) { return []; } return $options; } /** * Convert the given name to a level value. * Defaults to default value if the level does not exist. */ public static function levelNameToValue(string $level): int { return static::all()[$level] ?? static::DEFAULT; } /** * Convert the given int level value to a level name. * Defaults to 'default' level name if not existing. */ public static function levelValueToName(int $level): string { foreach (static::all() as $name => $value) { if ($level === $value) { return $name; } } return 'default'; } } ================================================ FILE: app/Api/ApiDocsController.php ================================================ setPageTitle(trans('settings.users_api_tokens_docs')); return view('api-docs.index', [ 'docs' => $docs, ]); } /** * Show a JSON view of the API docs data. */ public function json() { $docs = ApiDocsGenerator::generateConsideringCache(); return response()->json($docs); } /** * Redirect to the API docs page. * Required as a controller method, instead of the Route::redirect helper, * to ensure the URL is generated correctly. */ public function redirect() { return redirect('/api/docs'); } } ================================================ FILE: app/Api/ApiDocsGenerator.php ================================================ generate(); Cache::put($cacheKey, $docs, 60 * 24); return $docs; } /** * Generate API documentation. */ protected function generate(): Collection { $apiRoutes = $this->getFlatApiRoutes(); $apiRoutes = $this->loadDetailsFromControllers($apiRoutes); $apiRoutes = $this->loadDetailsFromFiles($apiRoutes); $apiRoutes = $apiRoutes->groupBy('base_model'); return $apiRoutes; } /** * Load any API details stored in static files. */ protected function loadDetailsFromFiles(Collection $routes): Collection { return $routes->map(function (array $route) { $exampleTypes = ['request', 'response']; $fileTypes = ['json', 'http']; foreach ($exampleTypes as $exampleType) { foreach ($fileTypes as $fileType) { $exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}." . $fileType); if (file_exists($exampleFile)) { $route["example_{$exampleType}"] = file_get_contents($exampleFile); continue 2; } } $route["example_{$exampleType}"] = null; } return $route; }); } /** * Load any details we can fetch from the controller and its methods. */ protected function loadDetailsFromControllers(Collection $routes): Collection { return $routes->map(function (array $route) { $class = $this->getReflectionClass($route['controller']); $method = $this->getReflectionMethod($route['controller'], $route['controller_method']); $comment = $method->getDocComment(); $route['description'] = $comment ? $this->parseDescriptionFromDocBlockComment($comment) : null; $route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']); // Load class description for the model // Not ideal to have it here on each route, but adding it in a more structured manner would break // docs resulting JSON format and therefore be an API break. // Save refactoring for a more significant set of changes. $classComment = $class->getDocComment(); $route['model_description'] = $classComment ? $this->parseDescriptionFromDocBlockComment($classComment) : null; return $route; }); } /** * Load body params and their rules by inspecting the given class and method name. * * @throws BindingResolutionException */ protected function getBodyParamsFromClass(string $className, string $methodName): ?array { /** @var ApiController $class */ $class = $this->controllerClasses[$className] ?? null; if ($class === null) { $class = app()->make($className); $this->controllerClasses[$className] = $class; } $rules = collect($class->getValidationRules()[$methodName] ?? [])->map(function ($validations) { return array_map(function ($validation) { return $this->getValidationAsString($validation); }, $validations); })->toArray(); return empty($rules) ? null : $rules; } /** * Convert the given validation message to a readable string. */ protected function getValidationAsString($validation): string { if (is_string($validation)) { return $validation; } if (is_object($validation) && method_exists($validation, '__toString')) { return strval($validation); } if ($validation instanceof Password) { return 'min:8'; } $class = get_class($validation); throw new Exception("Cannot provide string representation of rule for class: {$class}"); } /** * Parse out the description text from a class method comment. */ protected function parseDescriptionFromDocBlockComment(string $comment): string { $matches = []; preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches); $text = implode(' ', $matches[1] ?? []); return str_replace(' ', "\n", $text); } /** * Get a reflection method from the given class name and method name. * * @throws ReflectionException */ protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod { return $this->getReflectionClass($className)->getMethod($methodName); } /** * Get a reflection class from the given class name. * * @throws ReflectionException */ protected function getReflectionClass(string $className): ReflectionClass { $class = $this->reflectionClasses[$className] ?? null; if ($class === null) { $class = new ReflectionClass($className); $this->reflectionClasses[$className] = $class; } return $class; } /** * Get the system API routes, formatted into a flat collection. */ protected function getFlatApiRoutes(): Collection { return collect(Route::getRoutes()->getRoutes())->filter(function ($route) { return strpos($route->uri, 'api/') === 0; })->map(function ($route) { [$controller, $controllerMethod] = explode('@', $route->action['uses']); $baseModelName = explode('.', explode('/', $route->uri)[1])[0]; $shortName = $baseModelName . '-' . $controllerMethod; return [ 'name' => $shortName, 'uri' => $route->uri, 'method' => $route->methods[0], 'controller' => $controller, 'controller_method' => $controllerMethod, 'controller_method_kebab' => Str::kebab($controllerMethod), 'base_model' => $baseModelName, ]; }); } } ================================================ FILE: app/Api/ApiEntityListFormatter.php ================================================ */ protected array $fields = [ 'id', 'name', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'priority', 'created_at', 'updated_at', ]; public function __construct(array $list) { $this->list = $list; // Default dynamic fields $this->withField('url', fn(Entity $entity) => $entity->getUrl()); } /** * Add a field to be used in the formatter, with the property using the given * name and value being the return type of the given callback. */ public function withField(string $property, callable $callback): self { $this->fields[$property] = $callback; return $this; } /** * Show the 'type' property in the response reflecting the entity type. * EG: page, chapter, bookshelf, book * To be included in results with non-pre-determined types. */ public function withType(): self { $this->withField('type', fn(Entity $entity) => $entity->getType()); return $this; } /** * Include tags in the formatted data. */ public function withTags(): self { $this->withField('tags', fn(Entity $entity) => $entity->tags); return $this; } /** * Include parent book/chapter info in the formatted data. */ public function withParents(): self { $this->withField('book', function (Entity $entity) { if ($entity instanceof BookChild && $entity->book) { return $entity->book->only(['id', 'name', 'slug']); } return null; }); $this->withField('chapter', function (Entity $entity) { if ($entity instanceof Page && $entity->chapter) { return $entity->chapter->only(['id', 'name', 'slug']); } return null; }); return $this; } /** * Format the data and return an array of formatted content. * @return array[] */ public function format(): array { $results = []; foreach ($this->list as $item) { $results[] = $this->formatSingle($item); } return $results; } /** * Format a single entity item to a plain array. */ protected function formatSingle(Entity $entity): array { $result = []; $values = (clone $entity)->toArray(); foreach ($this->fields as $field => $callback) { if (is_string($callback)) { $field = $callback; if (!isset($values[$field])) { continue; } $value = $values[$field]; } else { $value = $callback($entity); if (is_null($value)) { continue; } } $result[$field] = $value; } return $result; } } ================================================ FILE: app/Api/ApiToken.php ================================================ 'date:Y-m-d', ]; /** * Get the user that this token belongs to. */ public function user(): BelongsTo { return $this->belongsTo(User::class); } /** * Get the default expiry value for an API token. * Set to 100 years from now. */ public static function defaultExpiry(): string { return Carbon::now()->addYears(100)->format('Y-m-d'); } /** * {@inheritdoc} */ public function logDescriptor(): string { return "({$this->id}) {$this->name}; User: {$this->user->logDescriptor()}"; } /** * Get the URL for managing this token. */ public function getUrl(string $path = ''): string { return url("/api-tokens/{$this->user_id}/{$this->id}/" . trim($path, '/')); } } ================================================ FILE: app/Api/ApiTokenGuard.php ================================================ request = $request; $this->loginService = $loginService; } /** * {@inheritdoc} */ public function user() { // Return the user if we've already retrieved them. // Effectively a request-instance cache for this method. if (!is_null($this->user)) { return $this->user; } $user = null; try { $user = $this->getAuthorisedUserFromRequest(); } catch (ApiAuthException $exception) { $this->lastAuthException = $exception; } $this->user = $user; return $user; } /** * Determine if current user is authenticated. If not, throw an exception. * * @throws ApiAuthException * * @return \Illuminate\Contracts\Auth\Authenticatable */ public function authenticate() { if (!is_null($user = $this->user())) { return $user; } if ($this->lastAuthException) { throw $this->lastAuthException; } throw new ApiAuthException('Unauthorized'); } /** * Check the API token in the request and fetch a valid authorised user. * * @throws ApiAuthException */ protected function getAuthorisedUserFromRequest(): Authenticatable { $authToken = trim($this->request->headers->get('Authorization', '')); $this->validateTokenHeaderValue($authToken); [$id, $secret] = explode(':', str_replace('Token ', '', $authToken)); $token = ApiToken::query() ->where('token_id', '=', $id) ->with(['user'])->first(); $this->validateToken($token, $secret); if ($this->loginService->awaitingEmailConfirmation($token->user)) { throw new ApiAuthException(trans('errors.email_confirmation_awaiting')); } return $token->user; } /** * Validate the format of the token header value string. * * @throws ApiAuthException */ protected function validateTokenHeaderValue(string $authToken): void { if (empty($authToken)) { throw new ApiAuthException(trans('errors.api_no_authorization_found')); } if (strpos($authToken, ':') === false || strpos($authToken, 'Token ') !== 0) { throw new ApiAuthException(trans('errors.api_bad_authorization_format')); } } /** * Validate the given secret against the given token and ensure the token * currently has access to the instance API. * * @throws ApiAuthException */ protected function validateToken(?ApiToken $token, string $secret): void { if ($token === null) { throw new ApiAuthException(trans('errors.api_user_token_not_found')); } if (!Hash::check($secret, $token->secret)) { throw new ApiAuthException(trans('errors.api_incorrect_token_secret')); } $now = Carbon::now(); if ($token->expires_at <= $now) { throw new ApiAuthException(trans('errors.api_user_token_expired'), 403); } if (!$token->user->can(Permission::AccessApi)) { throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403); } } /** * {@inheritdoc} */ public function validate(array $credentials = []) { if (empty($credentials['id']) || empty($credentials['secret'])) { return false; } $token = ApiToken::query() ->where('token_id', '=', $credentials['id']) ->with(['user'])->first(); if ($token === null) { return false; } return Hash::check($credentials['secret'], $token->secret); } /** * "Log out" the currently authenticated user. */ public function logout() { $this->user = null; } } ================================================ FILE: app/Api/ListingResponseBuilder.php ================================================ */ protected array $resultModifiers = []; /** * @var array */ protected array $filterOperators = [ 'eq' => '=', 'ne' => '!=', 'gt' => '>', 'lt' => '<', 'gte' => '>=', 'lte' => '<=', 'like' => 'like', ]; /** * ListingResponseBuilder constructor. * The given fields will be forced visible within the model results. */ public function __construct(Builder $query, Request $request, array $fields) { $this->query = $query; $this->request = $request; $this->fields = $fields; } /** * Get the response from this builder. */ public function toResponse(): JsonResponse { $filteredQuery = $this->filterQuery($this->query); $total = $filteredQuery->count(); $data = $this->fetchData($filteredQuery)->each(function ($model) { foreach ($this->resultModifiers as $modifier) { $modifier($model); } }); return response()->json([ 'data' => $data, 'total' => $total, ]); } /** * Add a callback to modify each element of the results. * * @param (callable(Model): void) $modifier */ public function modifyResults(callable $modifier): void { $this->resultModifiers[] = $modifier; } /** * Fetch the data to return within the response. */ protected function fetchData(Builder $query): Collection { $query = $this->countAndOffsetQuery($query); $query = $this->sortQuery($query); return $query->get($this->fields); } /** * Apply any filtering operations found in the request. */ protected function filterQuery(Builder $query): Builder { $query = clone $query; $requestFilters = $this->request->get('filter', []); if (!is_array($requestFilters)) { return $query; } $queryFilters = collect($requestFilters)->map(function ($value, $key) { return $this->requestFilterToQueryFilter($key, $value); })->filter(function ($value) { return !is_null($value); })->values()->toArray(); return $query->where($queryFilters); } /** * Convert a request filter query key/value pair into a [field, op, value] where condition. */ protected function requestFilterToQueryFilter($fieldKey, $value): ?array { $splitKey = explode(':', $fieldKey); $field = $splitKey[0]; $filterOperator = $splitKey[1] ?? 'eq'; if (!in_array($field, $this->fields)) { return null; } if (!in_array($filterOperator, array_keys($this->filterOperators))) { $filterOperator = 'eq'; } $queryOperator = $this->filterOperators[$filterOperator]; return [$field, $queryOperator, $value]; } /** * Apply sorting operations to the query from given parameters * otherwise falling back to the first given field, ascending. */ protected function sortQuery(Builder $query): Builder { $query = clone $query; $defaultSortName = $this->fields[0]; $direction = 'asc'; $sort = $this->request->get('sort', ''); if (strpos($sort, '-') === 0) { $direction = 'desc'; } $sortName = ltrim($sort, '+- '); if (!in_array($sortName, $this->fields)) { $sortName = $defaultSortName; } return $query->orderBy($sortName, $direction); } /** * Apply count and offset for paging, based on params from the request while falling * back to system defined default, taking the max limit into account. */ protected function countAndOffsetQuery(Builder $query): Builder { $query = clone $query; $offset = max(0, $this->request->get('offset', 0)); $maxCount = config('api.max_item_count'); $count = $this->request->get('count', config('api.default_item_count')); $count = max(min($maxCount, $count), 1); return $query->skip($offset)->take($count); } } ================================================ FILE: app/Api/UserApiTokenController.php ================================================ checkPermission(Permission::AccessApi); $this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId); $this->updateContext($request); $user = User::query()->findOrFail($userId); $this->setPageTitle(trans('settings.user_api_token_create')); return view('users.api-tokens.create', [ 'user' => $user, 'back' => $this->getRedirectPath($user), ]); } /** * Store a new API token in the system. */ public function store(Request $request, int $userId) { $this->checkPermission(Permission::AccessApi); $this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId); $this->validate($request, [ 'name' => ['required', 'max:250'], 'expires_at' => ['date_format:Y-m-d'], ]); $user = User::query()->findOrFail($userId); $secret = Str::random(32); $token = (new ApiToken())->forceFill([ 'name' => $request->get('name'), 'token_id' => Str::random(32), 'secret' => Hash::make($secret), 'user_id' => $user->id, 'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(), ]); while (ApiToken::query()->where('token_id', '=', $token->token_id)->exists()) { $token->token_id = Str::random(32); } $token->save(); session()->flash('api-token-secret:' . $token->id, $secret); $this->logActivity(ActivityType::API_TOKEN_CREATE, $token); return redirect($token->getUrl()); } /** * Show the details for a user API token, with access to edit. */ public function edit(Request $request, int $userId, int $tokenId) { $this->updateContext($request); [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); $secret = session()->pull('api-token-secret:' . $token->id, null); $this->setPageTitle(trans('settings.user_api_token')); return view('users.api-tokens.edit', [ 'user' => $user, 'token' => $token, 'model' => $token, 'secret' => $secret, 'back' => $this->getRedirectPath($user), ]); } /** * Update the API token. */ public function update(Request $request, int $userId, int $tokenId) { $this->validate($request, [ 'name' => ['required', 'max:250'], 'expires_at' => ['date_format:Y-m-d'], ]); [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); $token->fill([ 'name' => $request->get('name'), 'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(), ])->save(); $this->logActivity(ActivityType::API_TOKEN_UPDATE, $token); return redirect($token->getUrl()); } /** * Show the delete view for this token. */ public function delete(int $userId, int $tokenId) { [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); $this->setPageTitle(trans('settings.user_api_token_delete')); return view('users.api-tokens.delete', [ 'user' => $user, 'token' => $token, ]); } /** * Destroy a token from the system. */ public function destroy(int $userId, int $tokenId) { [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); $token->delete(); $this->logActivity(ActivityType::API_TOKEN_DELETE, $token); return redirect($this->getRedirectPath($user)); } /** * Check the permission for the current user and return an array * where the first item is the user in context and the second item is their * API token in context. */ protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array { $this->checkPermissionOr(Permission::UsersManage, function () use ($userId) { return $userId === user()->id && userCan(Permission::AccessApi); }); $user = User::query()->findOrFail($userId); $token = ApiToken::query()->where('user_id', '=', $user->id)->where('id', '=', $tokenId)->firstOrFail(); return [$user, $token]; } /** * Update the context for where the user is coming from to manage API tokens. * (Track of location for correct return redirects) */ protected function updateContext(Request $request): void { $context = $request->query('context'); if ($context) { session()->put('api-token-context', $context); } } /** * Get the redirect path for the current api token editing session. * Attempts to recall the context of where the user is editing from. */ protected function getRedirectPath(User $relatedUser): string { $context = session()->get('api-token-context'); if ($context === 'settings' || user()->id !== $relatedUser->id) { return $relatedUser->getEditUrl('#api_tokens'); } return url('/my-account/auth#api_tokens'); } } ================================================ FILE: app/App/AppVersion.php ================================================ basePath . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'Config' . ($path ? DIRECTORY_SEPARATOR . $path : $path); } } ================================================ FILE: app/App/HomeController.php ================================================ latest(10); $draftPages = []; if ($this->isSignedIn()) { $draftPages = $this->queries->pages->currentUserDraftsForList() ->orderBy('updated_at', 'desc') ->with('book') ->take(6) ->get(); } $recentFactor = count($draftPages) > 0 ? 0.5 : 1; $recents = $this->isSignedIn() ? $recentlyViewed->run(12 * $recentFactor, 1) : $this->queries->books->visibleForList()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get(); $favourites = $topFavourites->run(6); $recentlyUpdatedPages = $this->queries->pages->visibleForList() ->where('draft', false) ->orderBy('updated_at', 'desc') ->take($favourites->count() > 0 ? 5 : 10) ->get(); $homepageOptions = ['default', 'books', 'bookshelves', 'page']; $homepageOption = setting('app-homepage-type', 'default'); if (!in_array($homepageOption, $homepageOptions)) { $homepageOption = 'default'; } $commonData = [ 'activity' => $activity, 'recents' => $recents, 'recentlyUpdatedPages' => $recentlyUpdatedPages, 'draftPages' => $draftPages, 'favourites' => $favourites, ]; // Add required list ordering & sorting for books & shelves views. if ($homepageOption === 'bookshelves' || $homepageOption === 'books') { $key = $homepageOption; $view = setting()->getForCurrentUser($key . '_view_type'); $listOptions = SimpleListOptions::fromRequest($request, $key)->withSortOptions([ 'name' => trans('common.sort_name'), 'created_at' => trans('common.sort_created_at'), 'updated_at' => trans('common.sort_updated_at'), ]); $commonData = array_merge($commonData, [ 'view' => $view, 'listOptions' => $listOptions, ]); } if ($homepageOption === 'bookshelves') { $shelves = $this->queries->shelves->visibleForListWithCover() ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()) ->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000)); $data = array_merge($commonData, ['shelves' => $shelves]); return view('home.shelves', $data); } if ($homepageOption === 'books') { $books = $this->queries->books->visibleForListWithCover() ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()) ->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); $data = array_merge($commonData, ['books' => $books]); return view('home.books', $data); } if ($homepageOption === 'page') { $homepageSetting = setting('app-homepage', '0:'); $id = intval(explode(':', $homepageSetting)[0]); /** @var Page $customHomepage */ $customHomepage = $this->queries->pages->start()->where('draft', '=', false)->findOrFail($id); $pageContent = new PageContent($customHomepage); $customHomepage->html = $pageContent->render(false); return view('home.specific-page', array_merge($commonData, ['customHomepage' => $customHomepage])); } return view('home.default', $commonData); } } ================================================ FILE: app/App/MailNotification.php ================================================ $locale ?? user()->getLocale()]; return (new MailMessage())->view([ 'html' => 'vendor.notifications.email', 'text' => 'vendor.notifications.email-plain', ], $data); } } ================================================ FILE: app/App/MetaController.php ================================================ view('misc.robots', ['allowRobots' => $allowRobots]) ->header('Content-Type', 'text/plain'); } /** * Show the route for 404 responses. */ public function notFound() { return response()->view('errors.404', [], 404); } /** * Serve the application favicon. * Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served * directly by the webserver in the future. */ public function favicon(FaviconHandler $favicons) { $exists = $favicons->restoreOriginalIfNotExists(); return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath()); } /** * Serve a PWA application manifest. */ public function pwaManifest(PwaManifestBuilder $manifestBuilder) { return response()->json($manifestBuilder->build()); } /** * Show license information for the application. */ public function licenses() { $this->setPageTitle(trans('settings.licenses')); return view('help.licenses', [ 'license' => file_get_contents(base_path('LICENSE')), 'phpLibData' => file_get_contents(base_path('dev/licensing/php-library-licenses.txt')), 'jsLibData' => file_get_contents(base_path('dev/licensing/js-library-licenses.txt')), ]); } /** * Show the view for /opensearch.xml. */ public function opensearch() { return response() ->view('misc.opensearch') ->header('Content-Type', 'application/opensearchdescription+xml'); } } ================================================ FILE: app/App/Model.php ================================================ BookStackExceptionHandlerPage::class, ]; /** * Custom singleton bindings to register. * @var string[] */ public array $singletons = [ 'activity' => ActivityLogger::class, SettingService::class => SettingService::class, SocialDriverManager::class => SocialDriverManager::class, CspService::class => CspService::class, HttpRequestService::class => HttpRequestService::class, ]; /** * Register any application services. */ public function register(): void { $this->app->singleton(PermissionApplicator::class, function ($app) { return new PermissionApplicator(null); }); } /** * Bootstrap any application services. */ public function boot(): void { // Set root URL $appUrl = config('app.url'); if ($appUrl) { $isHttps = str_starts_with($appUrl, 'https://'); URL::forceRootUrl($appUrl); URL::forceScheme($isHttps ? 'https' : 'http'); } // Set SMTP mail driver to use a local domain matching the app domain, // which helps avoid defaulting to a 127.0.0.1 domain if ($appUrl) { $hostName = parse_url($appUrl, PHP_URL_HOST) ?: null; config()->set('mail.mailers.smtp.local_domain', $hostName); } // Allow longer string lengths after upgrade to utf8mb4 Schema::defaultStringLength(191); // Set morph-map for our relations to friendlier aliases Relation::enforceMorphMap([ 'bookshelf' => Bookshelf::class, 'book' => Book::class, 'chapter' => Chapter::class, 'page' => Page::class, 'comment' => Comment::class, ]); } } ================================================ FILE: app/App/Providers/AuthServiceProvider.php ================================================ Password::min(8)); // Custom guards Auth::extend('api-token', function ($app, $name, array $config) { return new ApiTokenGuard($app['request'], $app->make(LoginService::class)); }); Auth::extend('ldap-session', function ($app, $name, array $config) { $provider = Auth::createUserProvider($config['provider']); return new LdapSessionGuard( $name, $provider, $app['session.store'], $app[LdapService::class], $app[RegistrationService::class] ); }); Auth::extend('async-external-session', function ($app, $name, array $config) { $provider = Auth::createUserProvider($config['provider']); return new AsyncExternalBaseSessionGuard( $name, $provider, $app['session.store'], $app[RegistrationService::class] ); }); } /** * Register the application services. */ public function register(): void { Auth::provider('external-users', function () { return new ExternalBaseUserProvider(); }); // Bind and provide the default system user as a singleton to the app instance when needed. // This effectively "caches" fetching the user at an app-instance level. $this->app->singleton('users.default', function () { return User::query()->where('system_name', '=', 'public')->first(); }); } } ================================================ FILE: app/App/Providers/EventServiceProvider.php ================================================ > */ protected $listen = [ SocialiteWasCalled::class => [ AzureExtendSocialite::class . '@handle', OktaExtendSocialite::class . '@handle', GitLabExtendSocialite::class . '@handle', TwitchExtendSocialite::class . '@handle', DiscordExtendSocialite::class . '@handle', ], ]; /** * Register any events for your application. */ public function boot(): void { // } /** * Determine if events and listeners should be automatically discovered. */ public function shouldDiscoverEvents(): bool { return false; } /** * Overrides the registration of Laravel's default email verification system */ protected function configureEmailVerification(): void { // } } ================================================ FILE: app/App/Providers/RouteServiceProvider.php ================================================ configureRateLimiting(); $this->routes(function () { $this->mapWebRoutes(); $this->mapApiRoutes(); }); } /** * Define the "web" routes for the application. * * These routes all receive session state, CSRF protection, etc. */ protected function mapWebRoutes(): void { Route::group([ 'middleware' => 'web', 'namespace' => $this->namespace, ], function (Router $router) { require base_path('routes/web.php'); Theme::dispatch(ThemeEvents::ROUTES_REGISTER_WEB, $router); }); Route::group([ 'middleware' => ['web', 'auth'], ], function (Router $router) { Theme::dispatch(ThemeEvents::ROUTES_REGISTER_WEB_AUTH, $router); }); } /** * Define the "api" routes for the application. * * These routes are typically stateless. */ protected function mapApiRoutes(): void { Route::group([ 'middleware' => 'api', 'namespace' => $this->namespace . '\Api', 'prefix' => 'api', ], function ($router) { require base_path('routes/api.php'); }); } /** * Configure the rate limiters for the application. */ protected function configureRateLimiting(): void { RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); }); RateLimiter::for('public', function (Request $request) { return Limit::perMinute(10)->by($request->ip()); }); RateLimiter::for('exports', function (Request $request) { $user = user(); $attempts = $user->isGuest() ? 4 : 10; $key = $user->isGuest() ? $request->ip() : $user->id; return Limit::perMinute($attempts)->by($key); }); } } ================================================ FILE: app/App/Providers/ThemeServiceProvider.php ================================================ app->singleton(ThemeService::class, fn ($app) => new ThemeService()); } /** * Bootstrap services. */ public function boot(): void { // Boot up the theme system $themeService = $this->app->make(ThemeService::class); $viewFactory = $this->app->make('view'); $themeViews = new ThemeViews($viewFactory->getFinder()); // Use a custom include so that we can insert theme views before/after includes. // This is done, even if no theme is active, so that view caching does not create problems // when switching between themes or when switching a theme on/off. $viewFactory->share('__themeViews', $themeViews); Blade::directive('include', function ($expression) { return "handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>"; }); if (!$themeService->getTheme()) { return; } $themeService->loadModules(); $themeService->readThemeActions(); $themeService->dispatch(ThemeEvents::APP_BOOT, $this->app); $themeViews->registerViewPathsForTheme($themeService->getModules()); $themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews); } } ================================================ FILE: app/App/Providers/TranslationServiceProvider.php ================================================ registerLoader(); // This is a tweak upon Laravel's based translation service registration to allow // usage of a custom MessageSelector class $this->app->singleton('translator', function ($app) { $loader = $app['translation.loader']; // When registering the translator component, we'll need to set the default // locale as well as the fallback locale. So, we'll grab the application // configuration so we can easily get both of these values from there. $locale = $app['config']['app.locale']; $trans = new Translator($loader, $locale); $trans->setFallback($app['config']['app.fallback_locale']); $trans->setSelector(new MessageSelector()); return $trans; }); } /** * Register the translation line loader. * Overrides the default register action from Laravel so a custom loader can be used. */ protected function registerLoader(): void { $this->app->singleton('translation.loader', function ($app) { return new FileLoader($app['files'], $app['path.lang']); }); } } ================================================ FILE: app/App/Providers/ValidationRuleServiceProvider.php ================================================ getClientOriginalExtension()); return ImageService::isExtensionSupported($extension); }); Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) { $cleanLinkName = strtolower(trim($value)); $isJs = str_starts_with($cleanLinkName, 'javascript:'); $isData = str_starts_with($cleanLinkName, 'data:'); return !$isJs && !$isData; }); } } ================================================ FILE: app/App/Providers/ViewTweaksServiceProvider.php ================================================ app->singleton(DateFormatter::class, function ($app) { return new DateFormatter( $app['config']->get('app.display_timezone'), ); }); } /** * Bootstrap services. */ public function boot(): void { // Set paginator to use bootstrap-style pagination Paginator::useBootstrap(); // View Composers View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class); // View Globals View::share('dates', $this->app->make(DateFormatter::class)); // Custom blade view directives Blade::directive('icon', function ($expression) { return "toHtml(); ?>"; }); } } ================================================ FILE: app/App/PwaManifestBuilder.php ================================================ getForCurrentUser('dark-mode-enabled'); $appName = setting('app-name'); return [ "name" => $appName, "short_name" => $appName, "start_url" => "./", "scope" => "/", "display" => "standalone", "background_color" => $darkMode ? '#111111' : '#F2F2F2', "description" => $appName, "theme_color" => ($darkMode ? setting('app-color-dark') : setting('app-color')), "launch_handler" => [ "client_mode" => "focus-existing" ], "orientation" => "any", "icons" => [ [ "src" => setting('app-icon-32') ?: url('/icon-32.png'), "sizes" => "32x32", "type" => "image/png" ], [ "src" => setting('app-icon-64') ?: url('/icon-64.png'), "sizes" => "64x64", "type" => "image/png" ], [ "src" => setting('app-icon-128') ?: url('/icon-128.png'), "sizes" => "128x128", "type" => "image/png" ], [ "src" => setting('app-icon-180') ?: url('/icon-180.png'), "sizes" => "180x180", "type" => "image/png" ], [ "src" => setting('app-icon') ?: url('/icon.png'), "sizes" => "256x256", "type" => "image/png" ], [ "src" => url('favicon.ico'), "sizes" => "48x48", "type" => "image/vnd.microsoft.icon" ], ], ]; } } ================================================ FILE: app/App/SluggableInterface.php ================================================ json([ 'version' => AppVersion::get(), 'instance_id' => setting('instance-id'), 'app_name' => setting('app-name'), 'app_logo' => $logo, 'base_url' => url('/'), ]); } } ================================================ FILE: app/App/helpers.php ================================================ user() ?: User::getGuest(); } /** * Check if the current user has a permission. If an ownable element * is passed in the jointPermissions are checked against that particular item. */ function userCan(string|Permission $permission, ?Model $ownable = null): bool { if (is_null($ownable)) { return user()->can($permission); } // Check permission on ownable item $permissions = app()->make(PermissionApplicator::class); return $permissions->checkOwnableUserAccess($ownable, $permission); } /** * Check if the current user can perform the given action on any items in the system. * Can be provided the class name of an entity to filter ability to that specific entity type. */ function userCanOnAny(string|Permission $action, string $entityClass = ''): bool { $permissions = app()->make(PermissionApplicator::class); return $permissions->checkUserHasEntityPermissionOnAny($action, $entityClass); } /** * Helper to access system settings. * * @return mixed|SettingService */ function setting(?string $key = null, mixed $default = null): mixed { $settingService = app()->make(SettingService::class); if (is_null($key)) { return $settingService; } return $settingService->get($key, $default); } /** * Get a path to a theme resource. * Returns null if a theme is not configured, and therefore a full path is not available for use. */ function theme_path(string $path = ''): ?string { $theme = Theme::getTheme(); if (!$theme) { return null; } return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path)); } ================================================ FILE: app/Config/api.php ================================================ env('API_DEFAULT_ITEM_COUNT', 100), // The maximum number of items that can be returned in a listing API request. 'max_item_count' => env('API_MAX_ITEM_COUNT', 500), // The number of API requests that can be made per minute by a single user. 'requests_per_minute' => env('API_REQUESTS_PER_MIN', 180), ]; ================================================ FILE: app/Config/app.php ================================================ env('APP_ENV', 'production'), // Enter the application in debug mode. // Shows much more verbose error messages. Has potential to show // private configuration variables so should remain disabled in public. 'debug' => env('APP_DEBUG', false), // The number of revisions to keep in the database. // Once this limit is reached older revisions will be deleted. // If set to false then a limit will not be enforced. 'revision_limit' => env('REVISION_LIMIT', 100), // The number of days that content will remain in the recycle bin before // being considered for auto-removal. It is not a guarantee that content will // be removed after this time. // Set to 0 for no recycle bin functionality. // Set to -1 for unlimited recycle bin lifetime. 'recycle_bin_lifetime' => env('RECYCLE_BIN_LIFETIME', 30), // The limit for all uploaded files, including images and attachments in MB. 'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50), // Control the behaviour of content filtering, primarily used for page content. // This setting is a string of characters which represent different available filters: // - j - Filter out JavaScript and unknown binary data based content // - h - Filter out unexpected, and potentially dangerous, HTML elements // - f - Filter out unexpected form elements // - a - Run content through a more complex allowlist filter // This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used. // Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures. 'content_filtering' => env('APP_CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jhfa'), // Allow server-side fetches to be performed to potentially unknown // and user-provided locations. Primarily used in exports when loading // in externally referenced assets. 'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false), // Override the default behaviour for allowing crawlers to crawl the instance. // May be ignored if the underlying view has been overridden or modified. // Defaults to null in which case the 'app-public' status is used instead. 'allow_robots' => env('ALLOW_ROBOTS', null), // Application Base URL, Used by laravel in development commands // and used by BookStack in URL generation. 'url' => env('APP_URL', '') === 'http://bookstack.dev' ? '' : env('APP_URL', ''), // A list of hosts that BookStack can be iframed within. // Space separated if multiple. BookStack host domain is auto-inferred. 'iframe_hosts' => env('ALLOWED_IFRAME_HOSTS', null), // A list of sources/hostnames that can be loaded within iframes within BookStack. // Space separated if multiple. BookStack host domain is auto-inferred. // Can be set to a lone "*" to allow all sources for iframe content (Not advised). // Defaults to a set of common services. // Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured. 'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'), // A list of the sources/hostnames that can be reached by application SSR calls. // This is used wherever users can provide URLs/hosts in-platform, like for webhooks. // Host-specific functionality (usually controlled via other options) like auth // or user avatars, for example, won't use this list. // Space separated if multiple. Can use '*' as a wildcard. // Values will be compared prefix-matched, case-insensitive, against called SSR urls. // Defaults to allow all hosts. 'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'), // Alter the precision of IP addresses stored by BookStack. // Integer value between 0 (IP hidden) to 4 (Full IP usage) 'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4), // Application timezone for stored date/time values. 'timezone' => env('APP_TIMEZONE', 'UTC'), // Application timezone for displayed date/time values in the UI. 'display_timezone' => env('APP_DISPLAY_TIMEZONE', env('APP_TIMEZONE', 'UTC')), // Default locale to use // A default variant is also stored since Laravel can overwrite // app.locale when dynamically setting the locale in-app. 'locale' => env('APP_LANG', 'en'), 'default_locale' => env('APP_LANG', 'en'), // Application Fallback Locale 'fallback_locale' => 'en', // Faker Locale 'faker_locale' => 'en_GB', // Auto-detect the locale for public users // For public users their locale can be guessed by headers sent by their // browser. This is usually set by users in their browser settings. // If not found the default app locale will be used. 'auto_detect_locale' => env('APP_AUTO_LANG_PUBLIC', true), // Encryption key 'key' => env('APP_KEY', 'AbAZchsay4uBTU33RubBzLKw203yqSqr'), // Encryption cipher 'cipher' => 'AES-256-CBC', // Maintenance Mode Driver 'maintenance' => [ 'driver' => 'file', // 'store' => 'redis', ], // Application Service Providers 'providers' => ServiceProvider::defaultProviders()->merge([ // Third party service providers SocialiteProviders\Manager\ServiceProvider::class, // BookStack custom service providers BookStack\App\Providers\ThemeServiceProvider::class, BookStack\App\Providers\AppServiceProvider::class, BookStack\App\Providers\AuthServiceProvider::class, BookStack\App\Providers\EventServiceProvider::class, BookStack\App\Providers\RouteServiceProvider::class, BookStack\App\Providers\TranslationServiceProvider::class, BookStack\App\Providers\ValidationRuleServiceProvider::class, BookStack\App\Providers\ViewTweaksServiceProvider::class, ])->toArray(), // Class Aliases // This array of class aliases to be registered on application start. 'aliases' => Facade::defaultAliases()->merge([ // Laravel Packages 'Socialite' => Laravel\Socialite\Facades\Socialite::class, // Custom BookStack 'Activity' => BookStack\Facades\Activity::class, 'Theme' => BookStack\Facades\Theme::class, ])->toArray(), // Proxy configuration 'proxies' => env('APP_PROXIES', ''), ]; ================================================ FILE: app/Config/auth.php ================================================ env('AUTH_METHOD', 'standard'), // Automatically initiate login via external auth system if it's the sole auth method. // Works with saml2 or oidc auth methods. 'auto_initiate' => env('AUTH_AUTO_INITIATE', false), // Authentication Defaults // This option controls the default authentication "guard" and password // reset options for your application. 'defaults' => [ 'guard' => env('AUTH_METHOD', 'standard'), 'passwords' => 'users', ], // Authentication Guards // All authentication drivers have a user provider. This defines how the // users are actually retrieved out of your database or other storage // mechanisms used by this application to persist your user's data. // Supported drivers: "session", "api-token", "ldap-session", "async-external-session" 'guards' => [ 'standard' => [ 'driver' => 'session', 'provider' => 'users', ], 'ldap' => [ 'driver' => 'ldap-session', 'provider' => 'external', ], 'saml2' => [ 'driver' => 'async-external-session', 'provider' => 'external', ], 'oidc' => [ 'driver' => 'async-external-session', 'provider' => 'external', ], 'api' => [ 'driver' => 'api-token', ], ], // User Providers // All authentication drivers have a user provider. This defines how the // users are actually retrieved out of your database or other storage // mechanisms used by this application to persist your user's data. 'providers' => [ 'users' => [ 'driver' => 'eloquent', 'model' => \BookStack\Users\Models\User::class, ], 'external' => [ 'driver' => 'external-users', 'model' => \BookStack\Users\Models\User::class, ], // 'users' => [ // 'driver' => 'database', // 'table' => 'users', // ], ], // Resetting Passwords // The expire time is the number of minutes that the reset token should be // considered valid. This security feature keeps tokens short-lived so // they have less time to be guessed. You may change this as needed. 'passwords' => [ 'users' => [ 'provider' => 'users', 'email' => 'emails.password', 'table' => 'password_resets', 'expire' => 60, 'throttle' => 60, ], ], // Password Confirmation Timeout // Here you may define the amount of seconds before a password confirmation // times out and the user is prompted to re-enter their password via the // confirmation screen. By default, the timeout lasts for three hours. 'password_timeout' => 10800, ]; ================================================ FILE: app/Config/cache.php ================================================ $memcachedServer) { $memcachedServerDetails = explode(':', $memcachedServer); if (count($memcachedServerDetails) < 2) { $memcachedServerDetails[] = '11211'; } if (count($memcachedServerDetails) < 3) { $memcachedServerDetails[] = '100'; } $memcachedServers[$index] = array_combine($memcachedServerKeys, $memcachedServerDetails); } } return [ // Default cache store to use // Can be overridden at cache call-time 'default' => env('CACHE_DRIVER', 'file'), // Available caches stores 'stores' => [ 'array' => [ 'driver' => 'array', 'serialize' => false, ], 'database' => [ 'driver' => 'database', 'table' => 'cache', 'connection' => null, 'lock_connection' => null, 'lock_table' => null, ], 'file' => [ 'driver' => 'file', 'path' => storage_path('framework/cache'), 'lock_path' => storage_path('framework/cache'), ], 'memcached' => [ 'driver' => 'memcached', 'options' => [ // Memcached::OPT_CONNECT_TIMEOUT => 2000, ], 'servers' => $memcachedServers ?? [], ], 'redis' => [ 'driver' => 'redis', 'connection' => 'default', 'lock_connection' => 'default', ], 'octane' => [ 'driver' => 'octane', ], ], /* |-------------------------------------------------------------------------- | Cache Key Prefix |-------------------------------------------------------------------------- | | When utilizing a RAM based store such as APC or Memcached, there might | be other applications utilizing the same cache. So, we'll specify a | value to get prefixed to all our keys so we can avoid collisions. | */ 'prefix' => env('CACHE_PREFIX', 'bookstack_cache_'), ]; ================================================ FILE: app/Config/clockwork.php ================================================ env('CLOCKWORK_ENABLE', false), /* |------------------------------------------------------------------------------------------------------------------ | Features |------------------------------------------------------------------------------------------------------------------ | | You can enable or disable various Clockwork features here. Some features have additional settings (eg. slow query | threshold for database queries). | */ 'features' => [ // Cache usage stats and cache queries including results 'cache' => [ 'enabled' => true, // Collect cache queries 'collect_queries' => true, // Collect values from cache queries (high performance impact with a very high number of queries) 'collect_values' => false, ], // Database usage stats and queries 'database' => [ 'enabled' => true, // Collect database queries (high performance impact with a very high number of queries) 'collect_queries' => true, // Collect details of models updates (high performance impact with a lot of model updates) 'collect_models_actions' => true, // Collect details of retrieved models (very high performance impact with a lot of models retrieved) 'collect_models_retrieved' => false, // Query execution time threshold in miliseconds after which the query will be marked as slow 'slow_threshold' => null, // Collect only slow database queries 'slow_only' => false, // Detect and report duplicate (N+1) queries 'detect_duplicate_queries' => false, ], // Dispatched events 'events' => [ 'enabled' => true, // Ignored events (framework events are ignored by default) 'ignored_events' => [ // App\Events\UserRegistered::class, // 'user.registered' ], ], // Laravel log (you can still log directly to Clockwork with laravel log disabled) 'log' => [ 'enabled' => true, ], // Sent notifications 'notifications' => [ 'enabled' => true, ], // Performance metrics 'performance' => [ // Allow collecting of client metrics. Requires separate clockwork-browser npm package. 'client_metrics' => true, ], // Dispatched queue jobs 'queue' => [ 'enabled' => true, ], // Redis commands 'redis' => [ 'enabled' => true, ], // Routes list 'routes' => [ 'enabled' => false, // Collect only routes from particular namespaces (only application routes by default) 'only_namespaces' => ['App'], ], // Rendered views 'views' => [ 'enabled' => true, // Collect views including view data (high performance impact with a high number of views) 'collect_data' => false, // Use Twig profiler instead of Laravel events for apps using laravel-twigbridge (more precise, but does // not support collecting view data) 'use_twig_profiler' => false, ], ], /* |------------------------------------------------------------------------------------------------------------------ | Enable web UI |------------------------------------------------------------------------------------------------------------------ | | Clockwork comes with a web UI accessibla via http://your.app/clockwork. Here you can enable or disable this | feature. You can also set a custom path for the web UI. | */ 'web' => true, /* |------------------------------------------------------------------------------------------------------------------ | Enable toolbar |------------------------------------------------------------------------------------------------------------------ | | Clockwork can show a toolbar with basic metrics on all responses. Here you can enable or disable this feature. | Requires a separate clockwork-browser npm library. | For installation instructions see https://underground.works/clockwork/#docs-viewing-data | */ 'toolbar' => true, /* |------------------------------------------------------------------------------------------------------------------ | HTTP requests collection |------------------------------------------------------------------------------------------------------------------ | | Clockwork collects data about HTTP requests to your app. Here you can choose which requests should be collected. | */ 'requests' => [ // With on-demand mode enabled, Clockwork will only profile requests when the browser extension is open or you // manually pass a "clockwork-profile" cookie or get/post data key. // Optionally you can specify a "secret" that has to be passed as the value to enable profiling. 'on_demand' => false, // Collect only errors (requests with HTTP 4xx and 5xx responses) 'errors_only' => false, // Response time threshold in miliseconds after which the request will be marked as slow 'slow_threshold' => null, // Collect only slow requests 'slow_only' => false, // Sample the collected requests (eg. set to 100 to collect only 1 in 100 requests) 'sample' => false, // List of URIs that should not be collected 'except' => [ '/uploads/images/.*', // BookStack image requests '/horizon/.*', // Laravel Horizon requests '/telescope/.*', // Laravel Telescope requests '/_debugbar/.*', // Laravel DebugBar requests ], // List of URIs that should be collected, any other URI will not be collected if not empty 'only' => [ // '/api/.*' ], // Don't collect OPTIONS requests, mostly used in the CSRF pre-flight requests and are rarely of interest 'except_preflight' => true, ], /* |------------------------------------------------------------------------------------------------------------------ | Artisan commands collection |------------------------------------------------------------------------------------------------------------------ | | Clockwork can collect data about executed artisan commands. Here you can enable and configure which commands | should be collected. | */ 'artisan' => [ // Enable or disable collection of executed Artisan commands 'collect' => false, // List of commands that should not be collected (built-in commands are not collected by default) 'except' => [ // 'inspire' ], // List of commands that should be collected, any other command will not be collected if not empty 'only' => [ // 'inspire' ], // Enable or disable collection of command output 'collect_output' => false, // Enable or disable collection of built-in Laravel commands 'except_laravel_commands' => true, ], /* |------------------------------------------------------------------------------------------------------------------ | Queue jobs collection |------------------------------------------------------------------------------------------------------------------ | | Clockwork can collect data about executed queue jobs. Here you can enable and configure which queue jobs should | be collected. | */ 'queue' => [ // Enable or disable collection of executed queue jobs 'collect' => false, // List of queue jobs that should not be collected 'except' => [ // App\Jobs\ExpensiveJob::class ], // List of queue jobs that should be collected, any other queue job will not be collected if not empty 'only' => [ // App\Jobs\BuggyJob::class ], ], /* |------------------------------------------------------------------------------------------------------------------ | Tests collection |------------------------------------------------------------------------------------------------------------------ | | Clockwork can collect data about executed tests. Here you can enable and configure which tests should be | collected. | */ 'tests' => [ // Enable or disable collection of ran tests 'collect' => false, // List of tests that should not be collected 'except' => [ // Tests\Unit\ExampleTest::class ], ], /* |------------------------------------------------------------------------------------------------------------------ | Enable data collection when Clockwork is disabled |------------------------------------------------------------------------------------------------------------------ | | You can enable this setting to collect data even when Clockwork is disabled. Eg. for future analysis. | */ 'collect_data_always' => false, /* |------------------------------------------------------------------------------------------------------------------ | Metadata storage |------------------------------------------------------------------------------------------------------------------ | | Configure how is the metadata collected by Clockwork stored. Two options are available: | - files - A simple fast storage implementation storing data in one-per-request files. | - sql - Stores requests in a sql database. Supports MySQL, Postgresql, Sqlite and requires PDO. | */ 'storage' => 'files', // Path where the Clockwork metadata is stored 'storage_files_path' => storage_path('clockwork'), // Compress the metadata files using gzip, trading a little bit of performance for lower disk usage 'storage_files_compress' => false, // SQL database to use, can be a name of database configured in database.php or a path to a sqlite file 'storage_sql_database' => storage_path('clockwork.sqlite'), // SQL table name to use, the table is automatically created and udpated when needed 'storage_sql_table' => 'clockwork', // Maximum lifetime of collected metadata in minutes, older requests will automatically be deleted, false to disable 'storage_expiration' => 60 * 24 * 7, /* |------------------------------------------------------------------------------------------------------------------ | Authentication |------------------------------------------------------------------------------------------------------------------ | | Clockwork can be configured to require authentication before allowing access to the collected data. This might be | useful when the application is publicly accessible. Setting to true will enable a simple authentication with a | pre-configured password. You can also pass a class name of a custom implementation. | */ 'authentication' => false, // Password for the simple authentication 'authentication_password' => 'VerySecretPassword', /* |------------------------------------------------------------------------------------------------------------------ | Stack traces collection |------------------------------------------------------------------------------------------------------------------ | | Clockwork can collect stack traces for log messages and certain data like database queries. Here you can set | whether to collect stack traces, limit the number of collected frames and set further configuration. Collecting | long stack traces considerably increases metadata size. | */ 'stack_traces' => [ // Enable or disable collecting of stack traces 'enabled' => true, // Limit the number of frames to be collected 'limit' => 10, // List of vendor names to skip when determining caller, common vendors are automatically added 'skip_vendors' => [ // 'phpunit' ], // List of namespaces to skip when determining caller 'skip_namespaces' => [ // 'Laravel' ], // List of class names to skip when determining caller 'skip_classes' => [ // App\CustomLog::class ], ], /* |------------------------------------------------------------------------------------------------------------------ | Serialization |------------------------------------------------------------------------------------------------------------------ | | Clockwork serializes the collected data to json for storage and transfer. Here you can configure certain aspects | of serialization. Serialization has a large effect on the cpu time and memory usage. | */ // Maximum depth of serialized multi-level arrays and objects 'serialization_depth' => 10, // A list of classes that will never be serialized (eg. a common service container class) 'serialization_blackbox' => [ \Illuminate\Container\Container::class, \Illuminate\Foundation\Application::class, ], /* |------------------------------------------------------------------------------------------------------------------ | Register helpers |------------------------------------------------------------------------------------------------------------------ | | Clockwork comes with a "clock" global helper function. You can use this helper to quickly log something and to | access the Clockwork instance. | */ 'register_helpers' => true, /* |------------------------------------------------------------------------------------------------------------------ | Send Headers for AJAX request |------------------------------------------------------------------------------------------------------------------ | | When trying to collect data the AJAX method can sometimes fail if it is missing required headers. For example, an | API might require a version number using Accept headers to route the HTTP request to the correct codebase. | */ 'headers' => [ // 'Accept' => 'application/vnd.com.whatever.v1+json', ], /* |------------------------------------------------------------------------------------------------------------------ | Server-Timing |------------------------------------------------------------------------------------------------------------------ | | Clockwork supports the W3C Server Timing specification, which allows for collecting a simple performance metrics | in a cross-browser way. Eg. in Chrome, your app, database and timeline event timings will be shown in the Dev | Tools network tab. This setting specifies the max number of timeline events that will be sent. Setting to false | will disable the feature. | */ 'server_timing' => 10, ]; ================================================ FILE: app/Config/database.php ================================================ '127.0.0.1', 'port' => '6379', 'database' => '0', 'password' => null]; $redisServers = explode(',', trim(env('REDIS_SERVERS', '127.0.0.1:6379:0'), ',')); $redisConfig = ['client' => 'predis']; $cluster = count($redisServers) > 1; if ($cluster) { $redisConfig['clusters'] = ['default' => []]; } foreach ($redisServers as $index => $redisServer) { $redisServerDetails = explode(':', $redisServer); $serverConfig = []; $configIndex = 0; foreach ($redisDefaults as $configKey => $configDefault) { $serverConfig[$configKey] = ($redisServerDetails[$configIndex] ?? $configDefault); $configIndex++; } if ($cluster) { $redisConfig['clusters']['default'][] = $serverConfig; } else { $redisConfig['default'] = $serverConfig; } } } // MYSQL // Split out port from host if set $mysqlHost = env('DB_HOST', 'localhost'); $mysqlHostExploded = explode(':', $mysqlHost); $mysqlPort = env('DB_PORT', 3306); $mysqlHostIpv6 = str_starts_with($mysqlHost, '['); if ($mysqlHostIpv6 && str_contains($mysqlHost, ']:')) { $mysqlHost = implode(':', array_slice($mysqlHostExploded, 0, -1)); $mysqlPort = intval(end($mysqlHostExploded)); } else if (!$mysqlHostIpv6 && count($mysqlHostExploded) > 1) { $mysqlHost = $mysqlHostExploded[0]; $mysqlPort = intval($mysqlHostExploded[1]); } return [ // Default database connection name. // Options: mysql, mysql_testing 'default' => env('DB_CONNECTION', 'mysql'), // Available database connections // Many of those shown here are unsupported by BookStack. 'connections' => [ 'mysql' => [ 'driver' => 'mysql', 'url' => env('DATABASE_URL'), 'host' => $mysqlHost, 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), 'port' => $mysqlPort, 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', // Prefixes are only semi-supported and may be unstable // since they are not tested as part of our automated test suite. // If used, the prefix should not be changed; otherwise you will likely receive errors. 'prefix' => env('DB_TABLE_PREFIX', ''), 'prefix_indexes' => true, 'strict' => false, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ // @phpstan-ignore class.notFound (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], 'mysql_testing' => [ 'driver' => 'mysql', 'url' => env('TEST_DATABASE_URL'), 'host' => '127.0.0.1', 'database' => 'bookstack-test', 'username' => env('MYSQL_USER', 'bookstack-test'), 'password' => env('MYSQL_PASSWORD', 'bookstack-test'), 'port' => $mysqlPort, 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', 'prefix_indexes' => true, 'strict' => false, ], ], // Migration Repository Table // This table keeps track of all the migrations that have already run for the application. 'migrations' => 'migrations', // Redis configuration to use if set 'redis' => $redisConfig ?? [], ]; ================================================ FILE: app/Config/debugbar.php ================================================ env('DEBUGBAR_ENABLED', false), 'except' => [ 'telescope*', ], // DebugBar stores data for session/ajax requests. // You can disable this, so the debugbar stores data in headers/session, // but this can cause problems with large data collectors. // By default, file storage (in the storage folder) is used. Redis and PDO // can also be used. For PDO, run the package migrations first. 'storage' => [ 'enabled' => true, 'driver' => 'file', // redis, file, pdo, custom 'path' => storage_path('debugbar'), // For file driver 'connection' => null, // Leave null for default connection (Redis/PDO) 'provider' => '', // Instance of StorageInterface for custom driver ], // Vendor files are included by default, but can be set to false. // This can also be set to 'js' or 'css', to only include javascript or css vendor files. // Vendor files are for css: font-awesome (including fonts) and highlight.js (css files) // and for js: jquery and and highlight.js // So if you want syntax highlighting, set it to true. // jQuery is set to not conflict with existing jQuery scripts. 'include_vendors' => true, // The Debugbar can capture Ajax requests and display them. If you don't want this (ie. because of errors), // you can use this option to disable sending the data through the headers. // Optionally, you can also send ServerTiming headers on ajax requests for the Chrome DevTools. 'capture_ajax' => true, 'add_ajax_timing' => false, // When enabled, the Debugbar shows deprecated warnings for Symfony components // in the Messages tab. 'error_handler' => false, // The Debugbar can emulate the Clockwork headers, so you can use the Chrome // Extension, without the server-side code. It uses Debugbar collectors instead. 'clockwork' => false, // Enable/disable DataCollectors 'collectors' => [ 'phpinfo' => true, // Php version 'messages' => true, // Messages 'time' => true, // Time Datalogger 'memory' => true, // Memory usage 'exceptions' => true, // Exception displayer 'log' => true, // Logs from Monolog (merged in messages if enabled) 'db' => true, // Show database (PDO) queries and bindings 'views' => true, // Views with their data 'route' => true, // Current route information 'auth' => true, // Display Laravel authentication status 'gate' => true, // Display Laravel Gate checks 'session' => true, // Display session data 'symfony_request' => true, // Only one can be enabled.. 'mail' => true, // Catch mail messages 'laravel' => false, // Laravel version and environment 'events' => false, // All events fired 'default_request' => false, // Regular or special Symfony request logger 'logs' => false, // Add the latest log messages 'files' => false, // Show the included files 'config' => false, // Display config settings 'cache' => false, // Display cache events 'models' => true, // Display models ], // Configure some DataCollectors 'options' => [ 'auth' => [ 'show_name' => true, // Also show the users name/email in the debugbar ], 'db' => [ 'with_params' => true, // Render SQL with the parameters substituted 'backtrace' => true, // Use a backtrace to find the origin of the query in your files. 'timeline' => false, // Add the queries to the timeline 'explain' => [ // Show EXPLAIN output on queries 'enabled' => false, 'types' => ['SELECT'], // ['SELECT', 'INSERT', 'UPDATE', 'DELETE']; for MySQL 5.6.3+ ], 'hints' => true, // Show hints for common mistakes ], 'mail' => [ 'full_log' => false, ], 'views' => [ 'data' => false, //Note: Can slow down the application, because the data can be quite large.. ], 'route' => [ 'label' => true, // show complete route on bar ], 'logs' => [ 'file' => null, ], 'cache' => [ 'values' => true, // collect cache values ], ], // Inject Debugbar into the response // Usually, the debugbar is added just before , by listening to the // Response after the App is done. If you disable this, you have to add them // in your template yourself. See http://phpdebugbar.com/docs/rendering.html 'inject' => true, // DebugBar route prefix // Sometimes you want to set route prefix to be used by DebugBar to load // its resources from. Usually the need comes from misconfigured web server or // from trying to overcome bugs like this: http://trac.nginx.org/nginx/ticket/97 'route_prefix' => '_debugbar', // DebugBar route domain // By default DebugBar route served from the same domain that request served. // To override default domain, specify it as a non-empty value. 'route_domain' => env('APP_URL', '') === 'http://bookstack.dev' ? '' : env('APP_URL', ''), ]; ================================================ FILE: app/Config/exports.php ================================================ 'A4', 'letter' => 'Letter', ]; $dompdfPaperSizeMap = [ 'a4' => 'a4', 'letter' => 'letter', ]; $exportPageSize = env('EXPORT_PAGE_SIZE', 'a4'); return [ // Set a command which can be used to convert a HTML file into a PDF file. // When false this will not be used. // String values represent the command to be called for conversion. // Supports '{input_html_path}' and '{output_pdf_path}' placeholder values. // Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}" 'pdf_command' => env('EXPORT_PDF_COMMAND', false), // The amount of time allowed for PDF generation command to run // before the process times out and is stopped. 'pdf_command_timeout' => env('EXPORT_PDF_COMMAND_TIMEOUT', 15), // 2024-04: Snappy/WKHTMLtoPDF now considered deprecated in regard to BookStack support. 'snappy' => [ 'pdf_binary' => env('WKHTMLTOPDF', false), 'options' => [ 'print-media-type' => true, 'outline' => true, 'page-size' => $snappyPaperSizeMap[$exportPageSize] ?? 'A4', ], ], 'dompdf' => [ /** * The location of the DOMPDF font directory. * * The location of the directory where DOMPDF will store fonts and font metrics * Note: This directory must exist and be writable by the webserver process. * *Please note the trailing slash.* * * Notes regarding fonts: * Additional .afm font metrics can be added by executing load_font.php from command line. * * Only the original "Base 14 fonts" are present on all pdf viewers. Additional fonts must * be embedded in the pdf file or the PDF may not display correctly. This can significantly * increase file size unless font subsetting is enabled. Before embedding a font please * review your rights under the font license. * * Any font specification in the source HTML is translated to the closest font available * in the font directory. * * The pdf standard "Base 14 fonts" are: * Courier, Courier-Bold, Courier-BoldOblique, Courier-Oblique, * Helvetica, Helvetica-Bold, Helvetica-BoldOblique, Helvetica-Oblique, * 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) /** * The location of the DOMPDF font cache directory. * * This directory contains the cached font metrics for the fonts used by DOMPDF. * This directory can be the same as DOMPDF_FONT_DIR * * Note: This directory must exist and be writable by the webserver process. */ 'font_cache' => storage_path('fonts/'), /** * The location of a temporary directory. * * The directory specified must be writeable by the webserver process. * The temporary directory is required to download remote images and when * using the PFDLib back end. */ 'temp_dir' => sys_get_temp_dir(), /** * ==== IMPORTANT ====. * * dompdf's "chroot": Prevents dompdf from accessing system files or other * files on the webserver. All local files opened by dompdf must be in a * subdirectory of this directory. DO NOT set it to '/' since this could * allow an attacker to use dompdf to read any files on the server. This * should be an absolute path. * This is only checked on command line call by dompdf.php, but not by * direct class use like: * $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output(); */ 'chroot' => realpath(public_path()), /** * Protocol whitelist. * * Protocols and PHP wrappers allowed in URIs, and the validation rules * that determine if a resouce may be loaded. Full support is not guaranteed * for the protocols/wrappers specified * by this array. * * @var array */ 'allowed_protocols' => [ "data://" => ["rules" => []], 'file://' => ['rules' => []], 'http://' => ['rules' => []], 'https://' => ['rules' => []], ], /** * @var string */ 'log_output_file' => null, /** * Whether to enable font subsetting or not. */ 'enable_font_subsetting' => false, /** * The PDF rendering backend to use. * * Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and * 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will * fall back on CPDF. 'GD' renders PDFs to graphic files. {@link * Canvas_Factory} ultimately determines which rendering class to instantiate * based on this setting. * * Both PDFLib & CPDF rendering backends provide sufficient rendering * capabilities for dompdf, however additional features (e.g. object, * image and font support, etc.) differ between backends. Please see * {@link PDFLib_Adapter} for more information on the PDFLib backend * and {@link CPDF_Adapter} and lib/class.pdf.php for more information * on CPDF. Also see the documentation for each backend at the links * below. * * The GD rendering backend is a little different than PDFLib and * CPDF. Several features of CPDF and PDFLib are not supported or do * not make any sense when creating image files. For example, * multiple pages are not supported, nor are PDF 'objects'. Have a * look at {@link GD_Adapter} for more information. GD support is * experimental, so use it at your own risk. * * @link http://www.pdflib.com * @link http://www.ros.co.nz/pdf * @link http://www.php.net/image */ 'pdf_backend' => 'CPDF', /** * PDFlib license key. * * If you are using a licensed, commercial version of PDFlib, specify * your license key here. If you are using PDFlib-Lite or are evaluating * the commercial version of PDFlib, comment out this setting. * * @link http://www.pdflib.com * * If pdflib present in web server and auto or selected explicitely above, * a real license code must exist! */ //"DOMPDF_PDFLIB_LICENSE" => "your license key here", /** * html target media view which should be rendered into pdf. * List of types and parsing rules for future extensions: * http://www.w3.org/TR/REC-html40/types.html * screen, tty, tv, projection, handheld, print, braille, aural, all * Note: aural is deprecated in CSS 2.1 because it is replaced by speech in CSS 3. * Note, even though the generated pdf file is intended for print output, * the desired content might be different (e.g. screen or projection view of html file). * Therefore allow specification of content here. */ 'default_media_type' => 'print', /** * The default paper size. * * North America standard is "letter"; other countries generally "a4" * * @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.) */ 'default_paper_size' => $dompdfPaperSizeMap[$exportPageSize] ?? 'a4', /** * The default paper orientation. * * The orientation of the page (portrait or landscape). * * @var string */ 'default_paper_orientation' => 'portrait', /** * The default font family. * * Used if no suitable fonts can be found. This must exist in the font folder. * * @var string */ 'default_font' => 'dejavu sans', /** * Image DPI setting. * * This setting determines the default DPI setting for images and fonts. The * DPI may be overridden for inline images by explictly setting the * image's width & height style attributes (i.e. if the image's native * width is 600 pixels and you specify the image's width as 72 points, * the image will have a DPI of 600 in the rendered PDF. The DPI of * background images can not be overridden and is controlled entirely * via this parameter. * * For the purposes of DOMPDF, pixels per inch (PPI) = dots per inch (DPI). * If a size in html is given as px (or without unit as image size), * this tells the corresponding size in pt. * This adjusts the relative sizes to be similar to the rendering of the * html page in a reference browser. * * In pdf, always 1 pt = 1/72 inch * * Rendering resolution of various browsers in px per inch: * Windows Firefox and Internet Explorer: * SystemControl->Display properties->FontResolution: Default:96, largefonts:120, custom:? * Linux Firefox: * about:config *resolution: Default:96 * (xorg screen dimension in mm and Desktop font dpi settings are ignored) * * Take care about extra font/image zoom factor of browser. * * In images, size in pixel attribute, img css style, are overriding * the real image dimension in px for rendering. * * @var int */ 'dpi' => 96, /** * Enable inline PHP. * * If this setting is set to true then DOMPDF will automatically evaluate * inline PHP contained within tags. * * Enabling this for documents you do not trust (e.g. arbitrary remote html * pages) is a security risk. Set this option to false if you wish to process * untrusted documents. * * @var bool */ 'enable_php' => false, /** * Enable inline Javascript. * * If this setting is set to true then DOMPDF will automatically insert * JavaScript code contained within tags. * * @var bool */ 'enable_javascript' => false, /** * Enable remote file access. * * If this setting is set to true, DOMPDF will access remote sites for * images and CSS files as required. * This is required for part of test case www/test/image_variants.html through www/examples.php * * Attention! * This can be a security risk, in particular in combination with DOMPDF_ENABLE_PHP and * allowing remote access to dompdf.php or on allowing remote html code to be passed to * $dompdf = new DOMPDF(, $dompdf->load_html(..., * This allows anonymous users to download legally doubtful internet content which on * tracing back appears to being downloaded by your server, or allows malicious php code * in remote html pages to be executed by your server with your account privileges. * * @var bool */ 'enable_remote' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false), /** * A ratio applied to the fonts height to be more like browsers' line height. */ 'font_height_ratio' => 1.1, /** * Use the HTML5 Lib parser. * * @deprecated This feature is now always on in dompdf 2.x * * @var bool */ 'enable_html5_parser' => true, ], ]; ================================================ FILE: app/Config/filesystems.php ================================================ env('STORAGE_TYPE', 'local'), // Filesystem to use specifically for image uploads. 'images' => env('STORAGE_IMAGE_TYPE', env('STORAGE_TYPE', 'local')), // Filesystem to use specifically for file attachments. 'attachments' => env('STORAGE_ATTACHMENT_TYPE', env('STORAGE_TYPE', 'local')), // Storage URL // This is the url to where the storage is located for when using an external // file storage service, such as s3, to store publicly accessible assets. 'url' => env('STORAGE_URL', false), // Available filesystem disks // Only local, local_secure & s3 are supported by BookStack 'disks' => [ 'local' => [ 'driver' => 'local', 'root' => public_path(), 'serve' => false, 'throw' => true, 'directory_visibility' => 'public', ], 'local_secure_attachments' => [ 'driver' => 'local', 'root' => storage_path('uploads/files/'), 'serve' => false, 'throw' => true, ], 'local_secure_images' => [ 'driver' => 'local', 'root' => storage_path('uploads/images/'), 'serve' => false, 'throw' => true, ], 's3' => [ 'driver' => 's3', 'key' => env('STORAGE_S3_KEY', 'your-key'), 'secret' => env('STORAGE_S3_SECRET', 'your-secret'), 'region' => env('STORAGE_S3_REGION', 'your-region'), 'bucket' => env('STORAGE_S3_BUCKET', 'your-bucket'), 'endpoint' => env('STORAGE_S3_ENDPOINT', null), 'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null, 'throw' => true, 'stream_reads' => false, ], ], // Symbolic Links // Here you may configure the symbolic links that will be created when the // `storage:link` Artisan command is executed. The array keys should be // the locations of the links and the values should be their targets. 'links' => [ public_path('storage') => storage_path('app/public'), ], ]; ================================================ FILE: app/Config/hashing.php ================================================ 'bcrypt', // Bcrypt Options // Here you may specify the configuration options that should be used when // passwords are hashed using the Bcrypt algorithm. This will allow you // to control the amount of time it takes to hash the given password. 'bcrypt' => [ 'rounds' => env('BCRYPT_ROUNDS', 12), 'verify' => true, ], // Argon Options // Here you may specify the configuration options that should be used when // passwords are hashed using the Argon algorithm. These will allow you // to control the amount of time it takes to hash the given password. 'argon' => [ 'memory' => 1024, 'threads' => 2, 'time' => 2, ], ]; ================================================ FILE: app/Config/logging.php ================================================ env('LOG_CHANNEL', 'single'), // Deprecations Log Channel // This option controls the log channel that should be used to log warnings // regarding deprecated PHP and library features. This allows you to get // your application ready for upcoming major versions of dependencies. 'deprecations' => [ 'channel' => 'null', 'trace' => false, ], // Log Channels // Here you may configure the log channels for your application. Out of // the box, Laravel uses the Monolog PHP logging library. This gives // you a variety of powerful log handlers / formatters to utilize. // Available Drivers: "single", "daily", "slack", "syslog", // "errorlog", "monolog", // "custom", "stack" 'channels' => [ 'stack' => [ 'driver' => 'stack', 'channels' => ['daily'], 'ignore_exceptions' => false, ], 'single' => [ 'driver' => 'single', 'path' => storage_path('logs/laravel.log'), 'level' => 'debug', 'days' => 14, 'replace_placeholders' => true, ], 'daily' => [ 'driver' => 'daily', 'path' => storage_path('logs/laravel.log'), 'level' => 'debug', 'days' => 7, 'replace_placeholders' => true, ], 'stderr' => [ 'driver' => 'monolog', 'level' => 'debug', 'handler' => StreamHandler::class, 'with' => [ 'stream' => 'php://stderr', ], 'processors' => [PsrLogMessageProcessor::class], ], 'syslog' => [ 'driver' => 'syslog', 'level' => 'debug', 'facility' => LOG_USER, 'replace_placeholders' => true, ], 'errorlog' => [ 'driver' => 'errorlog', 'level' => 'debug', 'replace_placeholders' => true, ], // Custom errorlog implementation that logs out a plain, // non-formatted message intended for the webserver log. 'errorlog_plain_webserver' => [ 'driver' => 'monolog', 'level' => 'debug', 'handler' => ErrorLogHandler::class, 'handler_with' => [4], 'formatter' => LineFormatter::class, 'formatter_with' => [ 'format' => '%message%', ], 'replace_placeholders' => true, ], 'null' => [ 'driver' => 'monolog', 'handler' => NullHandler::class, ], // Testing channel // Uses a shared testing instance during tests // so that logs can be checked against. 'testing' => [ 'driver' => 'testing', ], 'emergency' => [ 'path' => storage_path('logs/laravel.log'), ], ], // Failed Login Message // Allows a configurable message to be logged when a login request fails. 'failed_login' => [ 'message' => env('LOG_FAILED_LOGIN_MESSAGE', null), 'channel' => env('LOG_FAILED_LOGIN_CHANNEL', 'errorlog_plain_webserver'), ], ]; ================================================ FILE: app/Config/mail.php ================================================ env('MAIL_DRIVER', 'smtp'), // Global "From" address & name 'from' => [ 'address' => env('MAIL_FROM', 'bookstack@example.com'), 'name' => env('MAIL_FROM_NAME', 'BookStack'), ], // Mailer Configurations // Available mailing methods and their settings. 'mailers' => [ 'smtp' => [ 'transport' => 'smtp', 'scheme' => null, 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), 'port' => $mailPort, 'username' => env('MAIL_USERNAME'), 'password' => env('MAIL_PASSWORD'), 'verify_peer' => env('MAIL_VERIFY_SSL', true), 'timeout' => null, 'local_domain' => null, 'require_tls' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl' || $mailPort === 465), ], 'sendmail' => [ 'transport' => 'sendmail', 'path' => env('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'), ], 'log' => [ 'transport' => 'log', 'channel' => env('MAIL_LOG_CHANNEL'), ], 'array' => [ 'transport' => 'array', ], 'failover' => [ 'transport' => 'failover', 'mailers' => [ 'smtp', 'log', ], ], ], ]; ================================================ FILE: app/Config/oidc.php ================================================ env('OIDC_NAME', 'SSO'), // Dump user details after a login request for debugging purposes 'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false), // Claim, within an OpenId token, to find the user's display name 'display_name_claims' => env('OIDC_DISPLAY_NAME_CLAIMS', 'name'), // Claim, within an OpenID token, to use to connect a BookStack user to the OIDC user. 'external_id_claim' => env('OIDC_EXTERNAL_ID_CLAIM', 'sub'), // OAuth2/OpenId client id, as configured in your Authorization server. 'client_id' => env('OIDC_CLIENT_ID', null), // OAuth2/OpenId client secret, as configured in your Authorization server. 'client_secret' => env('OIDC_CLIENT_SECRET', null), // The issuer of the identity token (id_token) this will be compared with // what is returned in the token. 'issuer' => env('OIDC_ISSUER', null), // Auto-discover the relevant endpoints and keys from the issuer. // Fetched details are cached for 15 minutes. 'discover' => env('OIDC_ISSUER_DISCOVER', false), // Public key that's used to verify the JWT token with. // Can be the key value itself or a local 'file://public.key' reference. 'jwt_public_key' => env('OIDC_PUBLIC_KEY', null), // OAuth2 endpoints. 'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null), 'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null), 'userinfo_endpoint' => env('OIDC_USERINFO_ENDPOINT', null), // OIDC RP-Initiated Logout endpoint URL. // A false value force-disables RP-Initiated Logout. // A true value gets the URL from discovery, if active. // A string value is used as the URL. 'end_session_endpoint' => env('OIDC_END_SESSION_ENDPOINT', false), // Add extra scopes, upon those required, to the OIDC authentication request // Multiple values can be provided comma seperated. 'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null), // Enable fetching of the user's avatar from the 'picture' claim on login. // Will only be fetched if the user doesn't already have an avatar image assigned. // This can be a security risk due to performing server-side fetching (with up to 3 redirects) of // data from external URLs. Only enable if you trust the OIDC auth provider to provide safe URLs for user images. 'fetch_avatar' => env('OIDC_FETCH_AVATAR', false), // Group sync options // Enable syncing, upon login, of OIDC groups to BookStack roles 'user_to_groups' => env('OIDC_USER_TO_GROUPS', false), // Attribute, within a OIDC ID token, to find group names within 'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'), // When syncing groups, remove any groups that no longer match. Otherwise, sync only adds new groups. 'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false), ]; ================================================ FILE: app/Config/queue.php ================================================ env('QUEUE_CONNECTION', 'sync'), // Queue connection configuration 'connections' => [ 'sync' => [ 'driver' => 'sync', ], 'database' => [ 'driver' => 'database', 'connection' => null, 'table' => 'jobs', 'queue' => 'default', 'retry_after' => 90, 'after_commit' => false, ], 'redis' => [ 'driver' => 'redis', 'connection' => 'default', 'queue' => env('REDIS_QUEUE', 'default'), 'retry_after' => 90, 'block_for' => null, 'after_commit' => false, ], ], // Job batching 'batching' => [ 'database' => 'mysql', 'table' => 'job_batches', ], // Failed queue job logging 'failed' => [ 'driver' => 'database-uuids', 'database' => 'mysql', 'table' => 'failed_jobs', ], ]; ================================================ FILE: app/Config/saml2.php ================================================ env('SAML2_NAME', 'SSO'), // Dump user details after a login request for debugging purposes 'dump_user_details' => env('SAML2_DUMP_USER_DETAILS', false), // Attribute, within a SAML response, to find the user's email address 'email_attribute' => env('SAML2_EMAIL_ATTRIBUTE', 'email'), // Attribute, within a SAML response, to find the user's display name 'display_name_attributes' => explode('|', env('SAML2_DISPLAY_NAME_ATTRIBUTES', 'username')), // Attribute, within a SAML response, to use to connect a BookStack user to the SAML user. 'external_id_attribute' => env('SAML2_EXTERNAL_ID_ATTRIBUTE', null), // Group sync options // Enable syncing, upon login, of SAML2 groups to BookStack groups 'user_to_groups' => env('SAML2_USER_TO_GROUPS', false), // Attribute, within a SAML response, to find group names on 'group_attribute' => env('SAML2_GROUP_ATTRIBUTE', 'group'), // When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups. 'remove_from_groups' => env('SAML2_REMOVE_FROM_GROUPS', false), // Autoload IDP details from the metadata endpoint 'autoload_from_metadata' => env('SAML2_AUTOLOAD_METADATA', false), // Overrides, in JSON format, to the configuration passed to underlying onelogin library. 'onelogin_overrides' => env('SAML2_ONELOGIN_OVERRIDES', null), 'onelogin' => [ // If 'strict' is True, then the PHP Toolkit will reject unsigned // or unencrypted messages if it expects them signed or encrypted // Also will reject the messages if not strictly follow the SAML // standard: Destination, NameId, Conditions ... are validated too. 'strict' => true, // Enable debug mode (to print errors) 'debug' => env('APP_DEBUG', false), // Set a BaseURL to be used instead of try to guess // the BaseURL of the view that process the SAML Message. // Ex. http://sp.example.com/ // http://example.com/sp/ 'baseurl' => null, // Service Provider Data that we are deploying 'sp' => [ // Identifier of the SP entity (must be a URI) 'entityId' => '', // Specifies info about where and how the message MUST be // returned to the requester, in this case our SP. 'assertionConsumerService' => [ // URL Location where the from the IdP will be returned 'url' => '', // SAML protocol binding to be used when returning the // message. Onelogin Toolkit supports for this endpoint the // HTTP-POST binding only 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', ], // Specifies info about where and how the message MUST be // returned to the requester, in this case our SP. 'singleLogoutService' => [ // URL Location where the from the IdP will be returned 'url' => '', // SAML protocol binding to be used when returning the // message. Onelogin Toolkit supports for this endpoint the // HTTP-Redirect binding only 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', ], // Specifies constraints on the name identifier to be used to // represent the requested subject. // Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported 'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', // Usually x509cert and privateKey of the SP are provided by files placed at // the certs folder. But we can also provide them with the following parameters 'x509cert' => $SAML2_SP_x509 ?: '', 'privateKey' => env('SAML2_SP_x509_KEY', ''), ], // Identity Provider Data that we want connect with our SP 'idp' => [ // Identifier of the IdP entity (must be a URI) 'entityId' => env('SAML2_IDP_ENTITYID', null), // SSO endpoint info of the IdP. (Authentication Request protocol) 'singleSignOnService' => [ // URL Target of the IdP where the SP will send the Authentication Request Message 'url' => env('SAML2_IDP_SSO', null), // SAML protocol binding to be used when returning the // message. Onelogin Toolkit supports for this endpoint the // HTTP-Redirect binding only 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', ], // SLO endpoint info of the IdP. 'singleLogoutService' => [ // URL Location of the IdP where the SP will send the SLO Request 'url' => env('SAML2_IDP_SLO', null), // URL location of the IdP where the SP will send the SLO Response (ResponseLocation) // if not set, url for the SLO Request will be used 'responseUrl' => null, // SAML protocol binding to be used when returning the // message. Onelogin Toolkit supports for this endpoint the // HTTP-Redirect binding only 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', ], // Public x509 certificate of the IdP 'x509cert' => env('SAML2_IDP_x509', null), /* * Instead of use the whole x509cert you can use a fingerprint in * order to validate the SAMLResponse, but we don't recommend to use * that method on production since is exploitable by a collision * attack. * (openssl x509 -noout -fingerprint -in "idp.crt" to generate it, * or add for example the -sha256 , -sha384 or -sha512 parameter) * * If a fingerprint is provided, then the certFingerprintAlgorithm is required in order to * let the toolkit know which Algorithm was used. Possible values: sha1, sha256, sha384 or sha512 * 'sha1' is the default value. */ // 'certFingerprint' => '', // 'certFingerprintAlgorithm' => 'sha1', /* In some scenarios the IdP uses different certificates for * signing/encryption, or is under key rollover phase and more * than one certificate is published on IdP metadata. * In order to handle that the toolkit offers that parameter. * (when used, 'x509cert' and 'certFingerprint' values are * ignored). */ // 'x509certMulti' => array( // 'signing' => array( // 0 => '', // ), // 'encryption' => array( // 0 => '', // ) // ), ], 'security' => [ // SAML2 Authn context // When set to false no AuthContext will be sent in the AuthNRequest, // When set to true (Default) you will get an AuthContext 'exact' 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'. // Multiple forced values can be passed via a space separated array, For example: // SAML2_IDP_AUTHNCONTEXT="urn:federation:authentication:windows urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" 'requestedAuthnContext' => is_string($SAML2_IDP_AUTHNCONTEXT) ? explode(' ', $SAML2_IDP_AUTHNCONTEXT) : $SAML2_IDP_AUTHNCONTEXT, // Sign requests and responses if a certificate is in use 'logoutRequestSigned' => (bool) $SAML2_SP_x509, 'logoutResponseSigned' => (bool) $SAML2_SP_x509, 'authnRequestsSigned' => (bool) $SAML2_SP_x509, 'lowercaseUrlencoding' => false, ], ], ]; ================================================ FILE: app/Config/services.php ================================================ env('DISABLE_EXTERNAL_SERVICES', false), // Draw.io integration active 'drawio' => env('DRAWIO', !env('DISABLE_EXTERNAL_SERVICES', false)), // URL for fetching avatars 'avatar_url' => env('AVATAR_URL', ''), // Callback URL for social authentication methods 'callback_url' => env('APP_URL', false), 'github' => [ 'client_id' => env('GITHUB_APP_ID', false), 'client_secret' => env('GITHUB_APP_SECRET', false), 'redirect' => env('APP_URL') . '/login/service/github/callback', 'name' => 'GitHub', 'auto_register' => env('GITHUB_AUTO_REGISTER', false), 'auto_confirm' => env('GITHUB_AUTO_CONFIRM_EMAIL', false), ], 'google' => [ 'client_id' => env('GOOGLE_APP_ID', false), 'client_secret' => env('GOOGLE_APP_SECRET', false), 'redirect' => env('APP_URL') . '/login/service/google/callback', 'name' => 'Google', 'auto_register' => env('GOOGLE_AUTO_REGISTER', false), 'auto_confirm' => env('GOOGLE_AUTO_CONFIRM_EMAIL', false), 'select_account' => env('GOOGLE_SELECT_ACCOUNT', false), ], 'slack' => [ 'client_id' => env('SLACK_APP_ID', false), 'client_secret' => env('SLACK_APP_SECRET', false), 'redirect' => env('APP_URL') . '/login/service/slack/callback', 'name' => 'Slack', 'auto_register' => env('SLACK_AUTO_REGISTER', false), 'auto_confirm' => env('SLACK_AUTO_CONFIRM_EMAIL', false), ], 'facebook' => [ 'client_id' => env('FACEBOOK_APP_ID', false), 'client_secret' => env('FACEBOOK_APP_SECRET', false), 'redirect' => env('APP_URL') . '/login/service/facebook/callback', 'name' => 'Facebook', 'auto_register' => env('FACEBOOK_AUTO_REGISTER', false), 'auto_confirm' => env('FACEBOOK_AUTO_CONFIRM_EMAIL', false), ], 'twitter' => [ 'client_id' => env('TWITTER_APP_ID', false), 'client_secret' => env('TWITTER_APP_SECRET', false), 'redirect' => env('APP_URL') . '/login/service/twitter/callback', 'name' => 'Twitter', 'auto_register' => env('TWITTER_AUTO_REGISTER', false), 'auto_confirm' => env('TWITTER_AUTO_CONFIRM_EMAIL', false), ], 'azure' => [ 'client_id' => env('AZURE_APP_ID', false), 'client_secret' => env('AZURE_APP_SECRET', false), 'tenant' => env('AZURE_TENANT', false), 'redirect' => env('APP_URL') . '/login/service/azure/callback', 'name' => 'Microsoft Azure', 'auto_register' => env('AZURE_AUTO_REGISTER', false), 'auto_confirm' => env('AZURE_AUTO_CONFIRM_EMAIL', false), ], 'okta' => [ 'client_id' => env('OKTA_APP_ID'), 'client_secret' => env('OKTA_APP_SECRET'), 'redirect' => env('APP_URL') . '/login/service/okta/callback', 'base_url' => env('OKTA_BASE_URL'), 'name' => 'Okta', 'auto_register' => env('OKTA_AUTO_REGISTER', false), 'auto_confirm' => env('OKTA_AUTO_CONFIRM_EMAIL', false), ], 'gitlab' => [ 'client_id' => env('GITLAB_APP_ID'), 'client_secret' => env('GITLAB_APP_SECRET'), 'redirect' => env('APP_URL') . '/login/service/gitlab/callback', 'instance_uri' => env('GITLAB_BASE_URI'), // Needed only for self hosted instances 'name' => 'GitLab', 'auto_register' => env('GITLAB_AUTO_REGISTER', false), 'auto_confirm' => env('GITLAB_AUTO_CONFIRM_EMAIL', false), ], 'twitch' => [ 'client_id' => env('TWITCH_APP_ID'), 'client_secret' => env('TWITCH_APP_SECRET'), 'redirect' => env('APP_URL') . '/login/service/twitch/callback', 'name' => 'Twitch', 'auto_register' => env('TWITCH_AUTO_REGISTER', false), 'auto_confirm' => env('TWITCH_AUTO_CONFIRM_EMAIL', false), ], 'discord' => [ 'client_id' => env('DISCORD_APP_ID'), 'client_secret' => env('DISCORD_APP_SECRET'), 'redirect' => env('APP_URL') . '/login/service/discord/callback', 'name' => 'Discord', 'auto_register' => env('DISCORD_AUTO_REGISTER', false), 'auto_confirm' => env('DISCORD_AUTO_CONFIRM_EMAIL', false), ], 'ldap' => [ 'server' => env('LDAP_SERVER', false), 'dump_user_details' => env('LDAP_DUMP_USER_DETAILS', false), 'dump_user_groups' => env('LDAP_DUMP_USER_GROUPS', false), 'dn' => env('LDAP_DN', false), 'pass' => env('LDAP_PASS', false), 'base_dn' => env('LDAP_BASE_DN', false), 'user_filter' => env('LDAP_USER_FILTER', '(&(uid={user}))'), 'version' => env('LDAP_VERSION', false), 'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'), 'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'), 'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'), 'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false), 'user_to_groups' => env('LDAP_USER_TO_GROUPS', false), 'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'), 'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false), 'tls_insecure' => env('LDAP_TLS_INSECURE', false), 'tls_ca_cert' => env('LDAP_TLS_CA_CERT', false), 'start_tls' => env('LDAP_START_TLS', false), 'thumbnail_attribute' => env('LDAP_THUMBNAIL_ATTRIBUTE', null), ], ]; ================================================ FILE: app/Config/session.php ================================================ env('SESSION_DRIVER', 'file'), // Session lifetime, in minutes 'lifetime' => env('SESSION_LIFETIME', 120), // Expire session on browser close 'expire_on_close' => false, // Encrypt session data 'encrypt' => false, // Location to store session files 'files' => storage_path('framework/sessions'), // Session Database Connection // When using the "database" or "redis" session drivers, you can specify a // connection that should be used to manage these sessions. This should // correspond to a connection in your database configuration options. 'connection' => null, // Session database table, if database driver is in use 'table' => 'sessions', // Session Cache Store // When using the "apc" or "memcached" session drivers, you may specify a // cache store that should be used for these sessions. This value must // correspond with one of the application's configured cache stores. 'store' => null, // Session Sweeping Lottery // Some session drivers must manually sweep their storage location to get // rid of old sessions from storage. Here are the chances that it will // happen on a given request. By default, the odds are 2 out of 100. 'lottery' => [2, 100], // Session Cookie Name // Here you may change the name of the cookie used to identify a session // instance by ID. The name specified here will get used every time a // new session cookie is created by the framework for every driver. 'cookie' => env('SESSION_COOKIE_NAME', 'bookstack_session'), // Session Cookie Path // The session cookie path determines the path for which the cookie will // be regarded as available. Typically, this will be the root path of // your application but you are free to change this when necessary. 'path' => '/' . (explode('/', env('APP_URL', ''), 4)[3] ?? ''), // Session Cookie Domain // Here you may change the domain of the cookie used to identify a session // in your application. This will determine which domains the cookie is // available to in your application. A sensible default has been set. 'domain' => env('SESSION_DOMAIN', null), // HTTPS Only Cookies // By setting this option to true, session cookies will only be sent back // to the server if the browser has a HTTPS connection. This will keep // the cookie from being sent to you if it can not be done securely. 'secure' => env('SESSION_SECURE_COOKIE', null) ?? Str::startsWith(env('APP_URL', ''), 'https:'), // HTTP Access Only // Setting this value to true will prevent JavaScript from accessing the // value of the cookie and the cookie will only be accessible through the HTTP protocol. 'http_only' => true, // Same-Site Cookies // This option determines how your cookies behave when cross-site requests // take place, and can be used to mitigate CSRF attacks. By default, we // do not enable this as other CSRF protection services are in place. // Options: lax, strict, none 'same_site' => 'lax', // Partitioned Cookies // Setting this value to true will tie the cookie to the top-level site for // a cross-site context. Partitioned cookies are accepted by the browser // when flagged "secure" and the Same-Site attribute is set to "none". 'partitioned' => false, ]; ================================================ FILE: app/Config/setting-defaults.php ================================================ 'BookStack', 'app-logo' => '', 'app-name-header' => true, 'app-editor' => 'wysiwyg', 'app-color' => '#206ea7', 'app-color-light' => 'rgba(32,110,167,0.15)', 'link-color' => '#206ea7', 'bookshelf-color' => '#a94747', 'book-color' => '#077b70', 'chapter-color' => '#af4d0d', 'page-color' => '#206ea7', 'page-draft-color' => '#7e50b1', 'app-color-dark' => '#195785', 'app-color-light-dark' => 'rgba(32,110,167,0.15)', 'link-color-dark' => '#429fe3', 'bookshelf-color-dark' => '#ff5454', 'book-color-dark' => '#389f60', 'chapter-color-dark' => '#ee7a2d', 'page-color-dark' => '#429fe3', 'page-draft-color-dark' => '#a66ce8', 'app-custom-head' => false, 'registration-enabled' => false, // User-level default settings 'user' => [ 'ui-shortcuts' => '{}', 'ui-shortcuts-enabled' => false, 'dark-mode-enabled' => env('APP_DEFAULT_DARK_MODE', false), 'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'), 'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'), 'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'), 'notifications#comment-mentions' => true, ], ]; ================================================ FILE: app/Config/view.php ================================================ ` folder to hold the // custom theme overrides. 'theme' => env('APP_THEME', false), // View Storage Paths // Most templating systems load templates from disk. Here you may specify // an array of paths that should be checked for your views. Of course // the usual Laravel view path has already been registered for you. 'paths' => [realpath(base_path('resources/views'))], // Compiled View Path // This option determines where all the compiled Blade templates will be // stored for your application. Typically, this is within the storage // directory. However, as usual, you are free to change this value. 'compiled' => realpath(storage_path('framework/views')), ]; ================================================ FILE: app/Console/Commands/AssignSortRuleCommand.php ================================================ argument('sort-rule')) ?? 0; if ($sortRuleId === 0) { return $this->listSortRules(); } $rule = SortRule::query()->find($sortRuleId); if ($this->option('all-books')) { $query = Book::query(); } else if ($this->option('books-without-sort')) { $query = Book::query()->whereNull('sort_rule_id'); } else if ($this->option('books-with-sort')) { $sortId = intval($this->option('books-with-sort')) ?: 0; if (!$sortId) { $this->error("Provided --books-with-sort option value is invalid"); return 1; } $query = Book::query()->where('sort_rule_id', $sortId); } else { $this->error("No option provided to specify target. Run with the -h option to see all available options."); return 1; } if (!$rule) { $this->error("Sort rule of provided id {$sortRuleId} not found!"); return 1; } $count = $query->clone()->count(); $this->warn("This will apply sort rule [{$rule->id}: {$rule->name}] to {$count} book(s) and run the sort on each."); $confirmed = $this->confirm("Are you sure you want to continue?"); if (!$confirmed) { return 1; } $processed = 0; $query->chunkById(10, function ($books) use ($rule, $sorter, $count, &$processed) { $max = min($count, ($processed + 10)); $this->info("Applying to {$processed}-{$max} of {$count} books"); foreach ($books as $book) { $book->sort_rule_id = $rule->id; $book->save(); $sorter->runBookAutoSort($book); } $processed = $max; }); $this->info("Sort applied to {$processed} book(s)!"); return 0; } protected function listSortRules(): int { $rules = SortRule::query()->orderBy('id', 'asc')->get(); $this->error("Sort rule ID required!"); $this->warn("\nAvailable sort rules:"); foreach ($rules as $rule) { $this->info("{$rule->id}: {$rule->name}"); } return 1; } } ================================================ FILE: app/Console/Commands/CleanupImagesCommand.php ================================================ option('all'); $dryRun = !$this->option('force'); if (!$dryRun) { $this->warn("This operation is destructive and is not guaranteed to be fully accurate.\nEnsure you have a backup of your images.\n"); $proceed = !$this->input->isInteractive() || $this->confirm("Are you sure you want to proceed?"); if (!$proceed) { return 0; } } $deleted = $imageService->deleteUnusedImages($checkRevisions, $dryRun); $deleteCount = count($deleted); if ($dryRun) { $this->comment('Dry run, no images have been deleted'); $this->comment($deleteCount . ' image(s) found that would have been deleted'); $this->showDeletedImages($deleted); $this->comment('Run with -f or --force to perform deletions'); return 0; } $this->showDeletedImages($deleted); $this->comment("{$deleteCount} image(s) deleted"); return 0; } protected function showDeletedImages($paths): void { if ($this->getOutput()->getVerbosity() <= OutputInterface::VERBOSITY_NORMAL) { return; } if (count($paths) > 0) { $this->line('Image(s) to delete:'); } foreach ($paths as $path) { $this->line($path); } } } ================================================ FILE: app/Console/Commands/ClearActivityCommand.php ================================================ truncate(); $this->comment('System activity cleared'); return 0; } } ================================================ FILE: app/Console/Commands/ClearRevisionsCommand.php ================================================ option('all') ? ['version', 'update_draft'] : ['version']; PageRevision::query()->whereIn('type', $deleteTypes)->delete(); $this->comment('Revisions deleted'); return 0; } } ================================================ FILE: app/Console/Commands/ClearViewsCommand.php ================================================ truncate(); $this->comment('Views cleared'); return 0; } } ================================================ FILE: app/Console/Commands/CopyShelfPermissionsCommand.php ================================================ option('slug'); $cascadeAll = $this->option('all'); $shelves = null; if (!$cascadeAll && !$shelfSlug) { $this->error('Either a --slug or --all option must be provided.'); return 1; } if ($cascadeAll) { $continue = $this->confirm( 'Permission settings for all shelves will be cascaded. ' . 'Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. ' . 'Are you sure you want to proceed?' ); if (!$continue && !$this->hasOption('no-interaction')) { return 0; } $shelves = $queries->start()->get(['id']); } if ($shelfSlug) { $shelves = $queries->start()->where('slug', '=', $shelfSlug)->get(['id']); if ($shelves->count() === 0) { $this->info('No shelves found with the given slug.'); } } foreach ($shelves as $shelf) { $permissionsUpdater->updateBookPermissionsFromShelf($shelf, false); $this->info('Copied permissions for shelf [' . $shelf->id . ']'); } $this->info('Permissions copied for ' . $shelves->count() . ' shelves.'); return 0; } } ================================================ FILE: app/Console/Commands/CreateAdminCommand.php ================================================ option('initial'); $shouldGeneratePassword = $this->option('generate-password'); $details = $this->gatherDetails($shouldGeneratePassword, $initialAdminOnly); $validator = Validator::make($details, [ 'email' => ['required', 'email', 'min:5'], 'name' => ['required', 'min:2'], 'password' => ['required_without:external_auth_id', Password::default()], 'external_auth_id' => ['required_without:password'], ]); if ($validator->fails()) { foreach ($validator->errors()->all() as $error) { $this->error($error); } return 1; } $adminRole = Role::getSystemRole('admin'); if ($initialAdminOnly) { $handled = $this->handleInitialAdminIfExists($userRepo, $details, $shouldGeneratePassword, $adminRole); if ($handled !== null) { return $handled; } } $emailUsed = $userRepo->getByEmail($details['email']) !== null; if ($emailUsed) { $this->error("Could not create admin account."); $this->error("An account with the email address \"{$details['email']}\" already exists."); return 1; } $user = $userRepo->createWithoutActivity($validator->validated()); $user->attachRole($adminRole); $user->email_confirmed = true; $user->save(); if ($shouldGeneratePassword) { $this->line($details['password']); } else { $this->info("Admin account with email \"{$user->email}\" successfully created!"); } return 0; } /** * Handle updates to the original admin account if it exists. * Returns an int return status if handled, otherwise returns null if not handled (new user to be created). */ protected function handleInitialAdminIfExists(UserRepo $userRepo, array $data, bool $generatePassword, Role $adminRole): int|null { $defaultAdmin = $userRepo->getByEmail('admin@admin.com'); if ($defaultAdmin && $defaultAdmin->hasSystemRole('admin')) { if ($defaultAdmin->email !== $data['email'] && $userRepo->getByEmail($data['email']) !== null) { $this->error("Could not create admin account."); $this->error("An account with the email address \"{$data['email']}\" already exists."); return 1; } $userRepo->updateWithoutActivity($defaultAdmin, $data, true); if ($generatePassword) { $this->line($data['password']); } else { $this->info("The default admin user has been updated with the provided details!"); } return 0; } else if ($adminRole->users()->count() > 0) { $this->warn('Non-default admin user already exists. Skipping creation of new admin user.'); return 2; } return null; } protected function gatherDetails(bool $generatePassword, bool $initialAdmin): array { $details = $this->snakeCaseOptions(); if (empty($details['email'])) { if ($initialAdmin) { $details['email'] = 'admin@example.com'; } else { $details['email'] = $this->ask('Please specify an email address for the new admin user'); } } if (empty($details['name'])) { if ($initialAdmin) { $details['name'] = 'Admin'; } else { $details['name'] = $this->ask('Please specify a name for the new admin user'); } } if (empty($details['password'])) { if (empty($details['external_auth_id'])) { if ($generatePassword) { $details['password'] = Str::random(32); } else { $details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)'); } } else { $details['password'] = Str::random(32); } } return $details; } protected function snakeCaseOptions(): array { $returnOpts = []; foreach ($this->options() as $key => $value) { $returnOpts[str_replace('-', '_', $key)] = $value; } return $returnOpts; } } ================================================ FILE: app/Console/Commands/DeleteUsersCommand.php ================================================ warn('This will delete all users from the system that are not "admin" or system users.'); $confirm = $this->confirm('Are you sure you want to continue?'); if (!$confirm) { return 0; } $totalUsers = User::query()->count(); $numDeleted = 0; $users = User::query()->whereNull('system_name')->with('roles')->get(); foreach ($users as $user) { if ($user->hasSystemRole('admin')) { // don't delete users with "admin" role continue; } $userRepo->destroy($user); $numDeleted++; } $this->info("Deleted $numDeleted of $totalUsers total users."); return 0; } } ================================================ FILE: app/Console/Commands/HandlesSingleUser.php ================================================ option('id'); $email = $this->option('email'); if (!$id && !$email) { throw new Exception("Either a --id= or --email= option must be provided.\nRun this command with `--help` to show more options."); } $field = $id ? 'id' : 'email'; $value = $id ?: $email; $user = User::query() ->where($field, '=', $value) ->first(); if (!$user) { throw new Exception("A user where {$field}={$value} could not be found."); } return $user; } } ================================================ FILE: app/Console/Commands/InstallModuleCommand.php ================================================ argument('location'); // Get the ZIP file containing the module files $zipPath = $this->getPathToZip($location); if (!$zipPath) { $this->cleanup(); return 1; } // Validate module zip file (metadata, size, etc...) and get module instance $zip = new ThemeModuleZip($zipPath); $themeModule = $this->validateAndGetModuleInfoFromZip($zip); if (!$themeModule) { $this->cleanup(); return 1; } // Get the theme folder in use, attempting to create one if no active theme in use $themeFolder = $this->getThemeFolder(); if (!$themeFolder) { $this->cleanup(); return 1; } // Get the modules folder of the theme, attempting to create it if not existing, // and create a new module manager instance. $moduleFolder = $this->getModuleFolder($themeFolder); if (!$moduleFolder) { $this->cleanup(); return 1; } $manager = new ThemeModuleManager($moduleFolder); // Handle existing modules with the same name $exitingModulesWithName = $manager->getByName($themeModule->name); $shouldContinue = $this->handleExistingModulesWithSameName($exitingModulesWithName, $manager); if (!$shouldContinue) { $this->cleanup(); return 1; } // Extract module ZIP into the theme modules folder try { $newModule = $manager->addFromZip($themeModule->name, $zip); } catch (ThemeModuleException $exception) { $this->error("ERROR: Failed to install module with error: {$exception->getMessage()}"); $this->cleanup(); return 1; } $this->info("Module \"{$newModule->name}\" ({$newModule->getVersion()}) successfully installed!"); $this->info("Install location: {$moduleFolder}/{$newModule->folderName}"); $this->cleanup(); return 0; } /** * @param ThemeModule[] $existingModules */ protected function handleExistingModulesWithSameName(array $existingModules, ThemeModuleManager $manager): bool { if (count($existingModules) === 0) { return true; } $this->warn("The following modules already exist with the same name:"); foreach ($existingModules as $folder => $module) { $this->line("{$module->name} ({$folder}:{$module->getVersion()}) - {$module->description}"); } $this->line(''); $choices = ['Cancel module install', 'Add alongside existing module']; if (count($existingModules) === 1) { $choices[] = 'Replace existing module'; } $choice = $this->choice("What would you like to do?", $choices, 0, null, false); if ($choice === 'Cancel module install') { return false; } if ($choice === 'Replace existing module') { $existingModuleFolder = array_key_first($existingModules); $this->info("Replacing existing module in {$existingModuleFolder} folder"); $manager->deleteModuleFolder($existingModuleFolder); } return true; } protected function getModuleFolder(string $themeFolder): string|null { $path = $themeFolder . DIRECTORY_SEPARATOR . 'modules'; if (file_exists($path) && !is_dir($path)) { $this->error("ERROR: Cannot create a modules folder, file already exists at {$path}"); return null; } if (!file_exists($path)) { $created = mkdir($path, 0755, true); if (!$created) { $this->error("ERROR: Failed to create a modules folder at {$path}"); return null; } } return $path; } protected function getThemeFolder(): string|null { $path = theme_path(''); if (!$path || !is_dir($path)) { $shouldCreate = $this->confirm('No active theme folder found, would you like to create one?'); if (!$shouldCreate) { return null; } $folder = 'custom'; while (file_exists(base_path("themes" . DIRECTORY_SEPARATOR . $folder))) { $folder = 'custom-' . Str::random(4); } $path = base_path("themes/{$folder}"); $created = mkdir($path, 0755, true); if (!$created) { $this->error('Failed to create a theme folder to use. This may be a permissions issue. Try manually configuring an active theme'); return null; } $this->info("Created theme folder at {$path}"); $this->warn("You will need to set APP_THEME={$folder} in your BookStack env configuration to enable this theme!"); } return $path; } protected function validateAndGetModuleInfoFromZip(ThemeModuleZip $zip): ThemeModule|null { if (!$zip->exists()) { $this->error("ERROR: Cannot open ZIP file at {$zip->getPath()}"); return null; } if ($zip->getContentsSize() > (50 * 1024 * 1024)) { $this->error("ERROR: Module ZIP file contents are too large. Maximum size is 50MB"); return null; } try { $themeModule = $zip->getModuleInstance(); } catch (ThemeModuleException $exception) { $this->error("ERROR: Failed to read module metadata with error: {$exception->getMessage()}"); return null; } return $themeModule; } protected function downloadModuleFile(string $location): string|null { $httpRequests = app()->make(HttpRequestService::class); $client = $httpRequests->buildClient(30, ['stream' => true]); $originalUrl = parse_url($location); $currentLocation = $location; $maxRedirects = 3; $redirectCount = 0; // Follow redirects up to 3 times for the same hostname do { $resp = $client->sendRequest(new Request('GET', $currentLocation)); $statusCode = $resp->getStatusCode(); if ($statusCode >= 300 && $statusCode < 400 && $redirectCount < $maxRedirects) { $redirectLocation = $resp->getHeaderLine('Location'); if ($redirectLocation) { $redirectUrl = parse_url($redirectLocation); if ( ($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '') && ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '') && ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '') ) { $currentLocation = $redirectLocation; $redirectCount++; continue; } } } break; } while (true); if ($resp->getStatusCode() >= 300) { $this->error("ERROR: Failed to download module from {$location}"); $this->error("Download failed with status code {$resp->getStatusCode()}"); return null; } $tempFile = tempnam(sys_get_temp_dir(), 'bookstack_module_'); $fileHandle = fopen($tempFile, 'w'); $respBody = $resp->getBody(); $size = 0; $maxSize = 50 * 1024 * 1024; while (!$respBody->eof()) { fwrite($fileHandle, $respBody->read(1024)); $size += 1024; if ($size > $maxSize) { fclose($fileHandle); unlink($tempFile); $this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB"); return ''; } } fclose($fileHandle); $this->cleanupActions[] = function () use ($tempFile) { unlink($tempFile); }; return $tempFile; } protected function getPathToZip(string $location): string|null { $lowerLocation = strtolower($location); $isRemote = str_starts_with($lowerLocation, 'http://') || str_starts_with($lowerLocation, 'https://'); if ($isRemote) { // Warning about fetching from source $host = parse_url($location, PHP_URL_HOST); $this->warn("\nThis will download a module from: {$host}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources."); $trustHost = $this->confirm('Are you sure you trust this source?'); if (!$trustHost) { return null; } // Check if the connection is http. If so, warn the user. if (str_starts_with($lowerLocation, 'http://')) { $this->warn("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks."); if (!$this->confirm('Are you sure you want to continue without HTTPS?')) { return null; } } // Download ZIP and get its location return $this->downloadModuleFile($location); } // Validate the file and get the full location $zipPath = realpath($location); if (!$zipPath || !is_file($zipPath)) { $this->error("ERROR: Module file not found at {$location}"); return null; } $this->warn("\nThis will install a module from: {$zipPath}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources."); $trustHost = $this->confirm('Are you sure you want to install this module?'); if (!$trustHost) { return null; } return $zipPath; } protected function cleanup(): void { foreach ($this->cleanupActions as $action) { $action(); } } } ================================================ FILE: app/Console/Commands/RefreshAvatarCommand.php ================================================ avatarFetchEnabled()) { $this->error("Avatar fetching is disabled on this instance."); return self::FAILURE; } if ($this->option('users-without-avatars')) { return $this->processUsers(User::query()->whereDoesntHave('avatar')->get()->all(), $userAvatar); } if ($this->option('all')) { return $this->processUsers(User::query()->get()->all(), $userAvatar); } try { $user = $this->fetchProvidedUser(); return $this->processUsers([$user], $userAvatar); } catch (Exception $exception) { $this->error($exception->getMessage()); return self::FAILURE; } } /** * @param User[] $users */ private function processUsers(array $users, UserAvatars $userAvatar): int { $dryRun = !$this->option('force'); $this->info(count($users) . " user(s) found to update avatars for."); if (count($users) === 0) { return self::SUCCESS; } if (!$dryRun) { $fetchHost = parse_url($userAvatar->getAvatarUrl(), PHP_URL_HOST); $this->warn("This will destroy any existing avatar images these users have, and attempt to fetch new avatar images from {$fetchHost}."); $proceed = !$this->input->isInteractive() || $this->confirm('Are you sure you want to proceed?'); if (!$proceed) { return self::SUCCESS; } } $this->info(""); $exitCode = self::SUCCESS; foreach ($users as $user) { $linePrefix = "[ID: {$user->id}] $user->email -"; if ($dryRun) { $this->warn("{$linePrefix} Not updated"); continue; } if ($this->fetchAvatar($userAvatar, $user)) { $this->info("{$linePrefix} Updated"); } else { $this->error("{$linePrefix} Not updated"); $exitCode = self::FAILURE; } } if ($dryRun) { $this->comment(""); $this->comment("Dry run, no avatars were updated."); $this->comment('Run with -f or --force to perform the update.'); } return $exitCode; } private function fetchAvatar(UserAvatars $userAvatar, User $user): bool { $oldId = $user->avatar->id ?? 0; $userAvatar->fetchAndAssignToUser($user); $user->refresh(); $newId = $user->avatar->id ?? $oldId; return $oldId !== $newId; } } ================================================ FILE: app/Console/Commands/RegeneratePermissionsCommand.php ================================================ option('database')) { DB::setDefaultConnection($this->option('database')); } $permissionBuilder->rebuildForAll(); DB::setDefaultConnection($connection); $this->comment('Permissions regenerated'); return 0; } } ================================================ FILE: app/Console/Commands/RegenerateReferencesCommand.php ================================================ option('database')) { DB::setDefaultConnection($this->option('database')); } $references->updateForAll(); DB::setDefaultConnection($connection); $this->comment('References have been regenerated'); return 0; } } ================================================ FILE: app/Console/Commands/RegenerateSearchCommand.php ================================================ option('database') !== null) { DB::setDefaultConnection($this->option('database')); } $searchIndex->indexAllEntities(function (Entity $model, int $processed, int $total): void { $this->info('Indexed ' . class_basename($model) . ' entries (' . $processed . '/' . $total . ')'); }); DB::setDefaultConnection($connection); $this->line('Search index regenerated!'); return static::SUCCESS; } } ================================================ FILE: app/Console/Commands/ResetMfaCommand.php ================================================ fetchProvidedUser(); } catch (Exception $exception) { $this->error($exception->getMessage()); return 1; } $this->info("This will delete any configure multi-factor authentication methods for user: \n- ID: {$user->id}\n- Name: {$user->name}\n- Email: {$user->email}\n"); $this->info('If multi-factor authentication is required for this user they will be asked to reconfigure their methods on next login.'); $confirm = $this->confirm('Are you sure you want to proceed?'); if (!$confirm) { return 1; } $user->mfaValues()->delete(); $this->info('User MFA methods have been reset.'); return 0; } } ================================================ FILE: app/Console/Commands/UpdateUrlCommand.php ================================================ argument('oldUrl')); $newUrl = str_replace("'", '', $this->argument('newUrl')); $urlPattern = '/https?:\/\/(.+)/'; if (!preg_match($urlPattern, $oldUrl) || !preg_match($urlPattern, $newUrl)) { $this->error('The given urls are expected to be full urls starting with http:// or https://'); return 1; } if (!$this->checkUserOkayToProceed($oldUrl, $newUrl)) { return 1; } $columnsToUpdateByTable = [ 'attachments' => ['path'], 'entity_page_data' => ['html', 'text', 'markdown'], 'entity_container_data' => ['description_html'], 'page_revisions' => ['html', 'text', 'markdown'], 'images' => ['url'], 'settings' => ['value'], 'comments' => ['html'], ]; foreach ($columnsToUpdateByTable as $table => $columns) { foreach ($columns as $column) { $changeCount = $this->replaceValueInTable($db, $table, $column, $oldUrl, $newUrl); $this->info("Updated {$changeCount} rows in {$table}->{$column}"); } } $jsonColumnsToUpdateByTable = [ 'settings' => ['value'], ]; foreach ($jsonColumnsToUpdateByTable as $table => $columns) { foreach ($columns as $column) { $oldJson = trim(json_encode($oldUrl), '"'); $newJson = trim(json_encode($newUrl), '"'); $changeCount = $this->replaceValueInTable($db, $table, $column, $oldJson, $newJson); $this->info("Updated {$changeCount} JSON encoded rows in {$table}->{$column}"); } } $this->info('URL update procedure complete.'); $this->info('============================================================================'); $this->info('Be sure to run "php artisan cache:clear" to clear any old URLs in the cache.'); if (!str_starts_with($newUrl, url('/'))) { $this->warn('You still need to update your APP_URL env value. This is currently set to:'); $this->warn(url('/')); } $this->info('============================================================================'); return 0; } /** * Perform a find+replace operations in the provided table and column. * Returns the count of rows changed. */ protected function replaceValueInTable( Connection $db, string $table, string $column, string $oldUrl, string $newUrl ): int { $oldQuoted = $db->getPdo()->quote($oldUrl); $newQuoted = $db->getPdo()->quote($newUrl); return $db->table($table)->update([ $column => $db->raw("REPLACE({$column}, {$oldQuoted}, {$newQuoted})"), ]); } /** * Warn the user of the dangers of this operation. * Returns a boolean indicating if they've accepted the warnings. */ protected function checkUserOkayToProceed(string $oldUrl, string $newUrl): bool { if ($this->option('force')) { return true; } $dangerWarning = "This will search for \"{$oldUrl}\" in your database and replace it with \"{$newUrl}\".\n"; $dangerWarning .= 'Are you sure you want to proceed?'; $backupConfirmation = 'This operation could cause issues if used incorrectly. Have you made a backup of your existing database?'; return $this->confirm($dangerWarning) && $this->confirm($backupConfirmation); } } ================================================ FILE: app/Console/Commands/UpgradeDatabaseEncodingCommand.php ================================================ option('database') !== null) { DB::setDefaultConnection($this->option('database')); } $database = DB::getDatabaseName(); $tables = DB::select('SHOW TABLES'); $this->line('ALTER DATABASE `' . $database . '` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'); $this->line('USE `' . $database . '`;'); $key = 'Tables_in_' . $database; foreach ($tables as $table) { $tableName = $table->$key; $this->line("ALTER TABLE `{$tableName}` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"); } DB::setDefaultConnection($connection); return 0; } } ================================================ FILE: app/Console/Kernel.php ================================================ load(__DIR__ . '/Commands'); } } ================================================ FILE: app/Entities/BreadcrumbsViewComposer.php ================================================ getData()['crumbs']; $firstCrumb = $crumbs[0] ?? null; if ($firstCrumb instanceof Book) { $shelf = $this->shelfContext->getContextualShelfForBook($firstCrumb); if ($shelf) { array_unshift($crumbs, $shelf); $view->with('crumbs', $crumbs); } } } } ================================================ FILE: app/Entities/Controllers/BookApiController.php ================================================ queries ->visibleForList() ->with(['cover:id,name,url']) ->addSelect(['created_by', 'updated_by']); return $this->apiListingResponse($books, [ 'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', ]); } /** * Create a new book in the system. * The cover image of a book can be set by sending a file via an 'image' property within a 'multipart/form-data' request. * If the 'image' property is null then the book cover image will be removed. * * @throws ValidationException */ public function create(Request $request) { $this->checkPermission(Permission::BookCreateAll); $requestData = $this->validate($request, $this->rules()['create']); $book = $this->bookRepo->create($requestData); return response()->json($this->forJsonDisplay($book)); } /** * View the details of a single book. * The response data will contain a 'content' property listing the chapter and pages directly within, in * the same structure as you'd see within the BookStack interface when viewing a book. Top-level * contents will have a 'type' property to distinguish between pages and chapters. */ public function read(string $id) { $book = $this->queries->findVisibleByIdOrFail(intval($id)); $book = $this->forJsonDisplay($book); $book->load([ 'createdBy', 'updatedBy', 'ownedBy', 'shelves' => function (BelongsToMany $query) { $query->select(['id', 'name', 'slug'])->scopes('visible'); } ]); $contents = (new BookContents($book))->getTree(true, false)->all(); $contentsApiData = (new ApiEntityListFormatter($contents)) ->withType() ->withField('pages', function (Entity $entity) { if ($entity instanceof Chapter) { $pages = $this->pageQueries->visibleForChapterList($entity->id)->get()->all(); return (new ApiEntityListFormatter($pages))->format(); } return null; })->format(); $book->setAttribute('contents', $contentsApiData); return response()->json($book); } /** * Update the details of a single book. * The cover image of a book can be set by sending a file via an 'image' property within a 'multipart/form-data' request. * If the 'image' property is null then the book cover image will be removed. * * @throws ValidationException */ public function update(Request $request, string $id) { $book = $this->queries->findVisibleByIdOrFail(intval($id)); $this->checkOwnablePermission(Permission::BookUpdate, $book); $requestData = $this->validate($request, $this->rules()['update']); $book = $this->bookRepo->update($book, $requestData); return response()->json($this->forJsonDisplay($book)); } /** * Delete a single book. * This will typically send the book to the recycle bin. * * @throws \Exception */ public function delete(string $id) { $book = $this->queries->findVisibleByIdOrFail(intval($id)); $this->checkOwnablePermission(Permission::BookDelete, $book); $this->bookRepo->destroy($book); return response('', 204); } protected function forJsonDisplay(Book $book): Book { $book = clone $book; $book->unsetRelations()->refresh(); $book->load(['tags']); $book->makeVisible(['cover', 'description_html']) ->setAttribute('description_html', $book->descriptionInfo()->getHtml()) ->setAttribute('cover', $book->coverInfo()->getImage()); return $book; } protected function rules(): array { return [ 'create' => [ 'name' => ['required', 'string', 'max:255'], 'description' => ['string', 'max:1900'], 'description_html' => ['string', 'max:2000'], 'tags' => ['array'], 'image' => array_merge(['nullable'], $this->getImageValidationRules()), 'default_template_id' => ['nullable', 'integer'], ], 'update' => [ 'name' => ['string', 'min:1', 'max:255'], 'description' => ['string', 'max:1900'], 'description_html' => ['string', 'max:2000'], 'tags' => ['array'], 'image' => array_merge(['nullable'], $this->getImageValidationRules()), 'default_template_id' => ['nullable', 'integer'], ], ]; } } ================================================ FILE: app/Entities/Controllers/BookController.php ================================================ getForCurrentUser('books_view_type'); $listOptions = SimpleListOptions::fromRequest($request, 'books')->withSortOptions([ 'name' => trans('common.sort_name'), 'created_at' => trans('common.sort_created_at'), 'updated_at' => trans('common.sort_updated_at'), ]); $books = $this->queries->visibleForListWithCover() ->orderBy($listOptions->getSort(), $listOptions->getOrder()) ->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false; $popular = $this->queries->popularForList()->take(4)->get(); $new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get(); $this->shelfContext->clearShelfContext(); $this->setPageTitle(trans('entities.books')); return view('books.index', [ 'books' => $books, 'recents' => $recents, 'popular' => $popular, 'new' => $new, 'view' => $view, 'listOptions' => $listOptions, ]); } /** * Show the form for creating a new book. */ public function create(?string $shelfSlug = null) { $this->checkPermission(Permission::BookCreateAll); $bookshelf = null; if ($shelfSlug !== null) { $bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug); $this->checkOwnablePermission(Permission::BookshelfUpdate, $bookshelf); } $this->setPageTitle(trans('entities.books_create')); return view('books.create', [ 'bookshelf' => $bookshelf, ]); } /** * Store a newly created book in storage. * * @throws ImageUploadException * @throws ValidationException */ public function store(Request $request, ?string $shelfSlug = null) { $this->checkPermission(Permission::BookCreateAll); $validated = $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], 'description_html' => ['string', 'max:2000'], 'image' => array_merge(['nullable'], $this->getImageValidationRules()), 'tags' => ['array'], 'default_template_id' => ['nullable', 'integer'], ]); $bookshelf = null; if ($shelfSlug !== null) { $bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug); $this->checkOwnablePermission(Permission::BookshelfUpdate, $bookshelf); } $book = $this->bookRepo->create($validated); if ($bookshelf) { $bookshelf->appendBook($book); Activity::add(ActivityType::BOOKSHELF_UPDATE, $bookshelf); } return redirect($book->getUrl()); } /** * Display the specified book. */ public function show(Request $request, ActivityQueries $activities, string $slug) { try { $book = $this->queries->findVisibleBySlugOrFail($slug); } catch (NotFoundException $exception) { $book = $this->entityQueries->findVisibleByOldSlugs('book', $slug); if (is_null($book)) { throw $exception; } return redirect($book->getUrl()); } $bookChildren = (new BookContents($book))->getTree(true); $bookParentShelves = $book->shelves()->scopes('visible')->get(); View::incrementFor($book); if ($request->has('shelf')) { $this->shelfContext->setShelfContext(intval($request->get('shelf'))); } $this->setPageTitle($book->getShortName()); return view('books.show', [ 'book' => $book, 'current' => $book, 'bookChildren' => $bookChildren, 'bookParentShelves' => $bookParentShelves, 'watchOptions' => new UserEntityWatchOptions(user(), $book), 'activity' => $activities->entityActivity($book, 20, 1), 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($book), ]); } /** * Show the form for editing the specified book. */ public function edit(string $slug) { $book = $this->queries->findVisibleBySlugOrFail($slug); $this->checkOwnablePermission(Permission::BookUpdate, $book); $this->setPageTitle(trans('entities.books_edit_named', ['bookName' => $book->getShortName()])); return view('books.edit', ['book' => $book, 'current' => $book]); } /** * Update the specified book in storage. * * @throws ImageUploadException * @throws ValidationException * @throws Throwable */ public function update(Request $request, string $slug) { $book = $this->queries->findVisibleBySlugOrFail($slug); $this->checkOwnablePermission(Permission::BookUpdate, $book); $validated = $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], 'description_html' => ['string', 'max:2000'], 'image' => array_merge(['nullable'], $this->getImageValidationRules()), 'tags' => ['array'], 'default_template_id' => ['nullable', 'integer'], ]); if ($request->has('image_reset')) { $validated['image'] = null; } elseif (array_key_exists('image', $validated) && is_null($validated['image'])) { unset($validated['image']); } $book = $this->bookRepo->update($book, $validated); return redirect($book->getUrl()); } /** * Shows the page to confirm deletion. */ public function showDelete(string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission(Permission::BookDelete, $book); $this->setPageTitle(trans('entities.books_delete_named', ['bookName' => $book->getShortName()])); return view('books.delete', ['book' => $book, 'current' => $book]); } /** * Remove the specified book from the system. * * @throws Throwable */ public function destroy(string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission(Permission::BookDelete, $book); $contextShelf = $this->shelfContext->getContextualShelfForBook($book); $this->bookRepo->destroy($book); if ($contextShelf) { return redirect($contextShelf->getUrl()); } return redirect('/books'); } /** * Show the view to copy a book. * * @throws NotFoundException */ public function showCopy(string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission(Permission::BookView, $book); session()->flashInput(['name' => $book->name]); return view('books.copy', [ 'book' => $book, ]); } /** * Create a copy of a book within the requested target destination. * * @throws NotFoundException */ public function copy(Request $request, Cloner $cloner, string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission(Permission::BookView, $book); $this->checkPermission(Permission::BookCreateAll); $newName = $request->get('name') ?: $book->name; $bookCopy = $cloner->cloneBook($book, $newName); $this->showSuccessNotification(trans('entities.books_copy_success')); return redirect($bookCopy->getUrl()); } /** * Convert the chapter to a book. */ public function convertToShelf(HierarchyTransformer $transformer, string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission(Permission::BookUpdate, $book); $this->checkOwnablePermission(Permission::BookDelete, $book); $this->checkPermission(Permission::BookshelfCreateAll); $this->checkPermission(Permission::BookCreateAll); $shelf = (new DatabaseTransaction(function () use ($book, $transformer) { return $transformer->transformBookToShelf($book); }))->run(); return redirect($shelf->getUrl()); } } ================================================ FILE: app/Entities/Controllers/BookshelfApiController.php ================================================ queries ->visibleForList() ->with(['cover:id,name,url']) ->addSelect(['created_by', 'updated_by']); return $this->apiListingResponse($shelves, [ 'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', ]); } /** * Create a new shelf in the system. * An array of books IDs can be provided in the request. These * will be added to the shelf in the same order as provided. * The cover image of a shelf can be set by sending a file via an 'image' property within a 'multipart/form-data' request. * If the 'image' property is null then the shelf cover image will be removed. * * @throws ValidationException */ public function create(Request $request) { $this->checkPermission(Permission::BookshelfCreateAll); $requestData = $this->validate($request, $this->rules()['create']); $bookIds = $request->get('books', []); $shelf = $this->bookshelfRepo->create($requestData, $bookIds); return response()->json($this->forJsonDisplay($shelf)); } /** * View the details of a single shelf. */ public function read(string $id) { $shelf = $this->queries->findVisibleByIdOrFail(intval($id)); $shelf = $this->forJsonDisplay($shelf); $shelf->load([ 'createdBy', 'updatedBy', 'ownedBy', 'books' => function (BelongsToMany $query) { $query->scopes('visible')->get(['id', 'name', 'slug']); }, ]); return response()->json($shelf); } /** * Update the details of a single shelf. * An array of books IDs can be provided in the request. These * will be added to the shelf in the same order as provided and overwrite * any existing book assignments. * The cover image of a shelf can be set by sending a file via an 'image' property within a 'multipart/form-data' request. * If the 'image' property is null then the shelf cover image will be removed. * * @throws ValidationException */ public function update(Request $request, string $id) { $shelf = $this->queries->findVisibleByIdOrFail(intval($id)); $this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf); $requestData = $this->validate($request, $this->rules()['update']); $bookIds = $request->get('books', null); $shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds); return response()->json($this->forJsonDisplay($shelf)); } /** * Delete a single shelf. * This will typically send the shelf to the recycle bin. * * @throws Exception */ public function delete(string $id) { $shelf = $this->queries->findVisibleByIdOrFail(intval($id)); $this->checkOwnablePermission(Permission::BookshelfDelete, $shelf); $this->bookshelfRepo->destroy($shelf); return response('', 204); } protected function forJsonDisplay(Bookshelf $shelf): Bookshelf { $shelf = clone $shelf; $shelf->unsetRelations()->refresh(); $shelf->load(['tags']); $shelf->makeVisible(['cover', 'description_html']) ->setAttribute('description_html', $shelf->descriptionInfo()->getHtml()) ->setAttribute('cover', $shelf->coverInfo()->getImage()); return $shelf; } protected function rules(): array { return [ 'create' => [ 'name' => ['required', 'string', 'max:255'], 'description' => ['string', 'max:1900'], 'description_html' => ['string', 'max:2000'], 'books' => ['array'], 'tags' => ['array'], 'image' => array_merge(['nullable'], $this->getImageValidationRules()), ], 'update' => [ 'name' => ['string', 'min:1', 'max:255'], 'description' => ['string', 'max:1900'], 'description_html' => ['string', 'max:2000'], 'books' => ['array'], 'tags' => ['array'], 'image' => array_merge(['nullable'], $this->getImageValidationRules()), ], ]; } } ================================================ FILE: app/Entities/Controllers/BookshelfController.php ================================================ getForCurrentUser('bookshelves_view_type'); $listOptions = SimpleListOptions::fromRequest($request, 'bookshelves')->withSortOptions([ 'name' => trans('common.sort_name'), 'created_at' => trans('common.sort_created_at'), 'updated_at' => trans('common.sort_updated_at'), ]); $shelves = $this->queries->visibleForListWithCover() ->orderBy($listOptions->getSort(), $listOptions->getOrder()) ->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000)); $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false; $popular = $this->queries->popularForList()->get(); $new = $this->queries->visibleForList() ->orderBy('created_at', 'desc') ->take(4) ->get(); $this->shelfContext->clearShelfContext(); $this->setPageTitle(trans('entities.shelves')); return view('shelves.index', [ 'shelves' => $shelves, 'recents' => $recents, 'popular' => $popular, 'new' => $new, 'view' => $view, 'listOptions' => $listOptions, ]); } /** * Show the form for creating a new bookshelf. */ public function create() { $this->checkPermission(Permission::BookshelfCreateAll); $books = $this->bookQueries->visibleForList()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']); $this->setPageTitle(trans('entities.shelves_create')); return view('shelves.create', ['books' => $books]); } /** * Store a newly created bookshelf in storage. * * @throws ValidationException * @throws ImageUploadException */ public function store(Request $request) { $this->checkPermission(Permission::BookshelfCreateAll); $validated = $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], 'description_html' => ['string', 'max:2000'], 'image' => array_merge(['nullable'], $this->getImageValidationRules()), 'tags' => ['array'], ]); $bookIds = explode(',', $request->get('books', '')); $shelf = $this->shelfRepo->create($validated, $bookIds); return redirect($shelf->getUrl()); } /** * Display the bookshelf of the given slug. * * @throws NotFoundException */ public function show(Request $request, ActivityQueries $activities, string $slug) { try { $shelf = $this->queries->findVisibleBySlugOrFail($slug); } catch (NotFoundException $exception) { $shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug); if (is_null($shelf)) { throw $exception; } return redirect($shelf->getUrl()); } $this->checkOwnablePermission(Permission::BookshelfView, $shelf); $listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([ 'default' => trans('common.sort_default'), 'name' => trans('common.sort_name'), 'created_at' => trans('common.sort_created_at'), 'updated_at' => trans('common.sort_updated_at'), ]); $sort = $listOptions->getSort(); $sortedVisibleShelfBooks = $shelf->visibleBooks() ->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder()) ->get() ->values() ->all(); View::incrementFor($shelf); $this->shelfContext->setShelfContext($shelf->id); $view = setting()->getForCurrentUser('bookshelf_view_type'); $this->setPageTitle($shelf->getShortName()); return view('shelves.show', [ 'shelf' => $shelf, 'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks, 'view' => $view, 'activity' => $activities->entityActivity($shelf, 20, 1), 'listOptions' => $listOptions, 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($shelf), ]); } /** * Show the form for editing the specified bookshelf. */ public function edit(string $slug) { $shelf = $this->queries->findVisibleBySlugOrFail($slug); $this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf); $shelfBookIds = $shelf->books()->get(['id'])->pluck('id'); $books = $this->bookQueries->visibleForList() ->whereNotIn('id', $shelfBookIds) ->orderBy('name') ->get(['name', 'id', 'slug', 'created_at', 'updated_at']); $this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()])); return view('shelves.edit', [ 'shelf' => $shelf, 'books' => $books, ]); } /** * Update the specified bookshelf in storage. * * @throws ValidationException * @throws ImageUploadException * @throws NotFoundException */ public function update(Request $request, string $slug) { $shelf = $this->queries->findVisibleBySlugOrFail($slug); $this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf); $validated = $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], 'description_html' => ['string', 'max:2000'], 'image' => array_merge(['nullable'], $this->getImageValidationRules()), 'tags' => ['array'], ]); if ($request->has('image_reset')) { $validated['image'] = null; } elseif (array_key_exists('image', $validated) && is_null($validated['image'])) { unset($validated['image']); } $bookIds = explode(',', $request->get('books', '')); $shelf = $this->shelfRepo->update($shelf, $validated, $bookIds); return redirect($shelf->getUrl()); } /** * Shows the page to confirm deletion. */ public function showDelete(string $slug) { $shelf = $this->queries->findVisibleBySlugOrFail($slug); $this->checkOwnablePermission(Permission::BookshelfDelete, $shelf); $this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()])); return view('shelves.delete', ['shelf' => $shelf]); } /** * Remove the specified bookshelf from storage. * * @throws Exception */ public function destroy(string $slug) { $shelf = $this->queries->findVisibleBySlugOrFail($slug); $this->checkOwnablePermission(Permission::BookshelfDelete, $shelf); $this->shelfRepo->destroy($shelf); return redirect('/shelves'); } } ================================================ FILE: app/Entities/Controllers/ChapterApiController.php ================================================ [ 'book_id' => ['required', 'integer'], 'name' => ['required', 'string', 'max:255'], 'description' => ['string', 'max:1900'], 'description_html' => ['string', 'max:2000'], 'tags' => ['array'], 'priority' => ['integer'], 'default_template_id' => ['nullable', 'integer'], ], 'update' => [ 'book_id' => ['integer'], 'name' => ['string', 'min:1', 'max:255'], 'description' => ['string', 'max:1900'], 'description_html' => ['string', 'max:2000'], 'tags' => ['array'], 'priority' => ['integer'], 'default_template_id' => ['nullable', 'integer'], ], ]; public function __construct( protected ChapterRepo $chapterRepo, protected ChapterQueries $queries, protected EntityQueries $entityQueries, ) { } /** * Get a listing of chapters visible to the user. */ public function list() { $chapters = $this->queries->visibleForList() ->addSelect(['created_by', 'updated_by']); return $this->apiListingResponse($chapters, [ 'id', 'book_id', 'name', 'slug', 'description', 'priority', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', ]); } /** * Create a new chapter in the system. */ public function create(Request $request) { $requestData = $this->validate($request, $this->rules['create']); $bookId = $request->get('book_id'); $book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId)); $this->checkOwnablePermission(Permission::ChapterCreate, $book); $chapter = $this->chapterRepo->create($requestData, $book); return response()->json($this->forJsonDisplay($chapter)); } /** * View the details of a single chapter. */ public function read(string $id) { $chapter = $this->queries->findVisibleByIdOrFail(intval($id)); $chapter = $this->forJsonDisplay($chapter); $chapter->load(['createdBy', 'updatedBy', 'ownedBy']); // Note: More fields than usual here, for backwards compatibility, // due to previously accidentally including more fields that desired. $pages = $this->entityQueries->pages->visibleForChapterList($chapter->id) ->addSelect(['created_by', 'updated_by', 'revision_count', 'editor']) ->get(); $chapter->setRelation('pages', $pages); return response()->json($chapter); } /** * Update the details of a single chapter. * Providing a 'book_id' property will essentially move the chapter * into that parent element if you have permissions to do so. */ public function update(Request $request, string $id) { $requestData = $this->validate($request, $this->rules()['update']); $chapter = $this->queries->findVisibleByIdOrFail(intval($id)); $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) { $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); try { $this->chapterRepo->move($chapter, "book:{$requestData['book_id']}"); } catch (Exception $exception) { if ($exception instanceof PermissionsException) { $this->showPermissionError(); } return $this->jsonError(trans('errors.selected_book_not_found')); } } $updatedChapter = $this->chapterRepo->update($chapter, $requestData); return response()->json($this->forJsonDisplay($updatedChapter)); } /** * Delete a chapter. * This will typically send the chapter to the recycle bin. */ public function delete(string $id) { $chapter = $this->queries->findVisibleByIdOrFail(intval($id)); $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); $this->chapterRepo->destroy($chapter); return response('', 204); } protected function forJsonDisplay(Chapter $chapter): Chapter { $chapter = clone $chapter; $chapter->unsetRelations()->refresh(); $chapter->load(['tags']); $chapter->makeVisible('description_html'); $chapter->setAttribute('description_html', $chapter->descriptionInfo()->getHtml()); /** @var Book $book */ $book = $chapter->book()->first(); $chapter->setAttribute('book_slug', $book->slug); return $chapter; } } ================================================ FILE: app/Entities/Controllers/ChapterController.php ================================================ entityQueries->books->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission(Permission::ChapterCreate, $book); $this->setPageTitle(trans('entities.chapters_create')); return view('chapters.create', [ 'book' => $book, 'current' => $book, ]); } /** * Store a newly created chapter in storage. * * @throws ValidationException */ public function store(Request $request, string $bookSlug) { $validated = $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], 'description_html' => ['string', 'max:2000'], 'tags' => ['array'], 'default_template_id' => ['nullable', 'integer'], ]); $book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission(Permission::ChapterCreate, $book); $chapter = $this->chapterRepo->create($validated, $book); return redirect($chapter->getUrl()); } /** * Display the specified chapter. */ public function show(string $bookSlug, string $chapterSlug) { try { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); } catch (NotFoundException $exception) { $chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug); if (is_null($chapter)) { throw $exception; } return redirect($chapter->getUrl()); } $sidebarTree = (new BookContents($chapter->book))->getTree(); $pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get(); $nextPreviousLocator = new NextPreviousContentLocator($chapter, $sidebarTree); View::incrementFor($chapter); $this->setPageTitle($chapter->getShortName()); return view('chapters.show', [ 'book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter, 'sidebarTree' => $sidebarTree, 'watchOptions' => new UserEntityWatchOptions(user(), $chapter), 'pages' => $pages, 'next' => $nextPreviousLocator->getNext(), 'previous' => $nextPreviousLocator->getPrevious(), 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($chapter), ]); } /** * Show the form for editing the specified chapter. */ public function edit(string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); $this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()])); return view('chapters.edit', ['book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter]); } /** * Update the specified chapter in storage. * * @throws NotFoundException */ public function update(Request $request, string $bookSlug, string $chapterSlug) { $validated = $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], 'description_html' => ['string', 'max:2000'], 'tags' => ['array'], 'default_template_id' => ['nullable', 'integer'], ]); $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); $chapter = $this->chapterRepo->update($chapter, $validated); return redirect($chapter->getUrl()); } /** * Shows the page to confirm deletion of this chapter. * * @throws NotFoundException */ public function showDelete(string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); $this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()])); return view('chapters.delete', ['book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter]); } /** * Remove the specified chapter from storage. * * @throws NotFoundException * @throws Throwable */ public function destroy(string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); $this->chapterRepo->destroy($chapter); return redirect($chapter->book->getUrl()); } /** * Show the page for moving a chapter. * * @throws NotFoundException */ public function showMove(string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $this->setPageTitle(trans('entities.chapters_move_named', ['chapterName' => $chapter->getShortName()])); $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); return view('chapters.move', [ 'chapter' => $chapter, 'book' => $chapter->book, ]); } /** * Perform the move action for a chapter. * * @throws NotFoundException|NotifyException */ public function move(Request $request, string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); $entitySelection = $request->get('entity_selection', null); if ($entitySelection === null || $entitySelection === '') { return redirect($chapter->getUrl()); } try { $this->chapterRepo->move($chapter, $entitySelection); } catch (PermissionsException $exception) { $this->showPermissionError(); } catch (MoveOperationException $exception) { $this->showErrorNotification(trans('errors.selected_book_not_found')); return redirect($chapter->getUrl('/move')); } return redirect($chapter->getUrl()); } /** * Show the view to copy a chapter. * * @throws NotFoundException */ public function showCopy(string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); session()->flashInput(['name' => $chapter->name]); return view('chapters.copy', [ 'book' => $chapter->book, 'chapter' => $chapter, ]); } /** * Create a copy of a chapter within the requested target destination. * * @throws NotFoundException * @throws Throwable */ public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $entitySelection = $request->get('entity_selection') ?: null; $newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent(); if (!$newParentBook instanceof Book) { $this->showErrorNotification(trans('errors.selected_book_not_found')); return redirect($chapter->getUrl('/copy')); } $this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook); $newName = $request->get('name') ?: $chapter->name; $chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName); $this->showSuccessNotification(trans('entities.chapters_copy_success')); return redirect($chapterCopy->getUrl()); } /** * Convert the chapter to a book. */ public function convertToBook(HierarchyTransformer $transformer, string $bookSlug, string $chapterSlug) { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); $this->checkPermission(Permission::BookCreateAll); $book = (new DatabaseTransaction(function () use ($chapter, $transformer) { return $transformer->transformChapterToBook($chapter); }))->run(); return redirect($book->getUrl()); } } ================================================ FILE: app/Entities/Controllers/PageApiController.php ================================================ [ 'book_id' => ['required_without:chapter_id', 'integer'], 'chapter_id' => ['required_without:book_id', 'integer'], 'name' => ['required', 'string', 'max:255'], 'html' => ['required_without:markdown', 'string'], 'markdown' => ['required_without:html', 'string'], 'tags' => ['array'], 'priority' => ['integer'], ], 'update' => [ 'book_id' => ['integer'], 'chapter_id' => ['integer'], 'name' => ['string', 'min:1', 'max:255'], 'html' => ['string'], 'markdown' => ['string'], 'tags' => ['array'], 'priority' => ['integer'], ], ]; public function __construct( protected PageRepo $pageRepo, protected PageQueries $queries, protected EntityQueries $entityQueries, ) { } /** * Get a listing of pages visible to the user. */ public function list() { $pages = $this->queries->visibleForList() ->addSelect(['created_by', 'updated_by', 'revision_count', 'editor']); return $this->apiListingResponse($pages, [ 'id', 'book_id', 'chapter_id', 'name', 'slug', 'priority', 'draft', 'template', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', ]); } /** * Create a new page in the system. * * The ID of a parent book or chapter is required to indicate * where this page should be located. * * Any HTML content provided should be kept to a single-block depth of plain HTML * elements to remain compatible with the BookStack front-end and editors. * Any images included via base64 data URIs will be extracted and saved as gallery * images against the page during upload. */ public function create(Request $request) { $this->validate($request, $this->rules['create']); if ($request->has('chapter_id')) { $parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id'))); } else { $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id'))); } $this->checkOwnablePermission(Permission::PageCreate, $parent); $draft = $this->pageRepo->getNewDraftPage($parent); $this->pageRepo->publishDraft($draft, $request->only(array_keys($this->rules['create']))); return response()->json($draft->forJsonDisplay()); } /** * View the details of a single page. * Pages will always have HTML content. They may have markdown content * if the Markdown editor was used to last update the page. * * The 'html' property is the fully rendered and escaped HTML content that BookStack * would show on page view, with page includes handled. * The 'raw_html' property is the direct database stored HTML content, which would be * what BookStack shows on page edit. * * See the "Content Security" section of these docs for security considerations when using * the page content returned from this endpoint. * * Comments for the page are provided in a tree-structure representing the hierarchy of top-level * comments and replies, for both archived and active comments. */ public function read(string $id) { $page = $this->queries->findVisibleByIdOrFail($id); $page = $page->forJsonDisplay(); $commentTree = (new CommentTree($page)); $commentTree->loadVisibleHtml(); $page->setAttribute('comments', [ 'active' => $commentTree->getActive(), 'archived' => $commentTree->getArchived(), ]); return response()->json($page); } /** * Update the details of a single page. * * See the 'create' action for details on the provided HTML/Markdown. * Providing a 'book_id' or 'chapter_id' property will essentially move * the page into that parent element if you have permissions to do so. */ public function update(Request $request, string $id) { $requestData = $this->validate($request, $this->rules['update']); $page = $this->queries->findVisibleByIdOrFail($id); $this->checkOwnablePermission(Permission::PageUpdate, $page); $parent = null; if ($request->has('chapter_id')) { $parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id'))); } elseif ($request->has('book_id')) { $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id'))); } if ($parent && !$parent->matches($page->getParent())) { $this->checkOwnablePermission(Permission::PageDelete, $page); try { $this->pageRepo->move($page, $parent->getType() . ':' . $parent->id); } catch (Exception $exception) { if ($exception instanceof PermissionsException) { $this->showPermissionError(); } return $this->jsonError(trans('errors.selected_book_chapter_not_found')); } } $updatedPage = $this->pageRepo->update($page, $requestData); return response()->json($updatedPage->forJsonDisplay()); } /** * Delete a page. * This will typically send the page to the recycle bin. */ public function delete(string $id) { $page = $this->queries->findVisibleByIdOrFail($id); $this->checkOwnablePermission(Permission::PageDelete, $page); $this->pageRepo->destroy($page); return response('', 204); } } ================================================ FILE: app/Entities/Controllers/PageController.php ================================================ entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); } else { $parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug); } $this->checkOwnablePermission(Permission::PageCreate, $parent); // Redirect to draft edit screen if signed in if ($this->isSignedIn()) { $draft = $this->pageRepo->getNewDraftPage($parent); return redirect($draft->getUrl()); } // Otherwise show the edit view if they're a guest $this->setPageTitle(trans('entities.pages_new')); return view('pages.guest-create', ['parent' => $parent]); } /** * Create a new page as a guest user. * * @throws ValidationException */ public function createAsGuest(Request $request, string $bookSlug, ?string $chapterSlug = null) { $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], ]); if ($chapterSlug) { $parent = $this->entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); } else { $parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug); } $this->checkOwnablePermission(Permission::PageCreate, $parent); $page = $this->pageRepo->getNewDraftPage($parent); $this->pageRepo->publishDraft($page, [ 'name' => $request->get('name'), ]); return redirect($page->getUrl('/edit')); } /** * Show form to continue editing a draft page. * * @throws NotFoundException */ public function editDraft(Request $request, string $bookSlug, int $pageId) { $draft = $this->queries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageCreate, $draft->getParent()); $editorData = new PageEditorData($draft, $this->entityQueries, $request->query('editor', '')); $this->setPageTitle(trans('entities.pages_edit_draft')); return view('pages.edit', $editorData->getViewData()); } /** * Store a new page by changing a draft into a page. * * @throws NotFoundException * @throws ValidationException */ public function store(Request $request, string $bookSlug, int $pageId) { $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], ]); $draftPage = $this->queries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent()); $page = $this->pageRepo->publishDraft($draftPage, $request->all()); return redirect($page->getUrl()); } /** * Display the specified page. * If the page is not found via the slug the revisions are searched for a match. * * @throws NotFoundException */ public function show(string $bookSlug, string $pageSlug) { try { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); } catch (NotFoundException $e) { $page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug); if (is_null($page)) { throw $e; } return redirect($page->getUrl()); } $pageContent = (new PageContent($page)); $page->html = $pageContent->render(); $pageNav = $pageContent->getNavigation($page->html); $sidebarTree = (new BookContents($page->book))->getTree(); $commentTree = (new CommentTree($page)); $nextPreviousLocator = new NextPreviousContentLocator($page, $sidebarTree); View::incrementFor($page); $this->setPageTitle($page->getShortName()); return view('pages.show', [ 'page' => $page, 'book' => $page->book, 'current' => $page, 'sidebarTree' => $sidebarTree, 'commentTree' => $commentTree, 'pageNav' => $pageNav, 'watchOptions' => new UserEntityWatchOptions(user(), $page), 'next' => $nextPreviousLocator->getNext(), 'previous' => $nextPreviousLocator->getPrevious(), 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($page), ]); } /** * Get a page from an ajax request. * * @throws NotFoundException */ public function getPageAjax(int $pageId) { $page = $this->queries->findVisibleByIdOrFail($pageId); $page->setHidden(array_diff($page->getHidden(), ['html', 'markdown'])); $page->makeHidden(['book']); $filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering')); $filter = new HtmlContentFilter($filterConfig); $page->html = $filter->filterString($page->html); return response()->json($page); } /** * Show the form for editing the specified page. * * @throws NotFoundException */ public function edit(Request $request, string $bookSlug, string $pageSlug) { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageUpdate, $page, $page->getUrl()); $editorData = new PageEditorData($page, $this->entityQueries, $request->query('editor', '')); if ($editorData->getWarnings()) { $this->showWarningNotification(implode("\n", $editorData->getWarnings())); } $this->setPageTitle(trans('entities.pages_editing_named', ['pageName' => $page->getShortName()])); return view('pages.edit', $editorData->getViewData()); } /** * Update the specified page in storage. * * @throws ValidationException * @throws NotFoundException */ public function update(Request $request, string $bookSlug, string $pageSlug) { $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], ]); $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageUpdate, $page); $this->pageRepo->update($page, $request->all()); return redirect($page->getUrl()); } /** * Save a draft update as a revision. * * @throws NotFoundException */ public function saveDraft(Request $request, int $pageId) { $page = $this->queries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageUpdate, $page); if (!$this->isSignedIn()) { return $this->jsonError(trans('errors.guests_cannot_save_drafts'), 500); } $draft = $this->pageRepo->updatePageDraft($page, $request->only(['name', 'html', 'markdown'])); $warnings = (new PageEditActivity($page))->getWarningMessagesForDraft($draft); return response()->json([ 'status' => 'success', 'message' => trans('entities.pages_edit_draft_save_at'), 'warning' => implode("\n", $warnings), 'timestamp' => $draft->updated_at->timestamp, ]); } /** * Redirect from a special link url which uses the page id rather than the name. * * @throws NotFoundException */ public function redirectFromLink(int $pageId) { $page = $this->queries->findVisibleByIdOrFail($pageId); return redirect($page->getUrl()); } /** * Show the deletion page for the specified page. * * @throws NotFoundException */ public function showDelete(string $bookSlug, string $pageSlug) { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageDelete, $page); $this->setPageTitle(trans('entities.pages_delete_named', ['pageName' => $page->getShortName()])); $usedAsTemplate = $this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 || $this->entityQueries->chapters->start()->where('default_template_id', '=', $page->id)->count() > 0; return view('pages.delete', [ 'book' => $page->book, 'page' => $page, 'current' => $page, 'usedAsTemplate' => $usedAsTemplate, ]); } /** * Show the deletion page for the specified page. * * @throws NotFoundException */ public function showDeleteDraft(string $bookSlug, int $pageId) { $page = $this->queries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageUpdate, $page); $this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()])); $usedAsTemplate = $this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 || $this->entityQueries->chapters->start()->where('default_template_id', '=', $page->id)->count() > 0; return view('pages.delete', [ 'book' => $page->book, 'page' => $page, 'current' => $page, 'usedAsTemplate' => $usedAsTemplate, ]); } /** * Remove the specified page from storage. * * @throws NotFoundException * @throws Throwable */ public function destroy(string $bookSlug, string $pageSlug) { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageDelete, $page); $parent = $page->getParent(); $this->pageRepo->destroy($page); return redirect($parent->getUrl()); } /** * Remove the specified draft page from storage. * * @throws NotFoundException * @throws Throwable */ public function destroyDraft(string $bookSlug, int $pageId) { $page = $this->queries->findVisibleByIdOrFail($pageId); $book = $page->book; $chapter = $page->chapter; $this->checkOwnablePermission(Permission::PageUpdate, $page); $this->pageRepo->destroy($page); $this->showSuccessNotification(trans('entities.pages_delete_draft_success')); if ($chapter && userCan(Permission::ChapterView, $chapter)) { return redirect($chapter->getUrl()); } return redirect($book->getUrl()); } /** * Show a listing of recently created pages. */ public function showRecentlyUpdated() { $visibleBelongsScope = function (BelongsTo $query) { $query->scopes('visible'); }; $pages = $this->queries->visibleForList() ->addSelect('updated_by') ->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope]) ->orderBy('updated_at', 'desc') ->paginate(20) ->setPath(url('/pages/recently-updated')); $this->setPageTitle(trans('entities.recently_updated_pages')); return view('common.detailed-listing-paginated', [ 'title' => trans('entities.recently_updated_pages'), 'entities' => $pages, 'showUpdatedBy' => true, 'showPath' => true, ]); } /** * Show the view to choose a new parent to move a page into. * * @throws NotFoundException */ public function showMove(string $bookSlug, string $pageSlug) { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageUpdate, $page); $this->checkOwnablePermission(Permission::PageDelete, $page); return view('pages.move', [ 'book' => $page->book, 'page' => $page, ]); } /** * Does the action of moving the location of a page. * * @throws NotFoundException * @throws Throwable */ public function move(Request $request, string $bookSlug, string $pageSlug) { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageUpdate, $page); $this->checkOwnablePermission(Permission::PageDelete, $page); $entitySelection = $request->get('entity_selection', null); if ($entitySelection === null || $entitySelection === '') { return redirect($page->getUrl()); } try { $this->pageRepo->move($page, $entitySelection); } catch (PermissionsException $exception) { $this->showPermissionError(); } catch (Exception $exception) { $this->showErrorNotification(trans('errors.selected_book_chapter_not_found')); return redirect($page->getUrl('/move')); } return redirect($page->getUrl()); } /** * Show the view to copy a page. * * @throws NotFoundException */ public function showCopy(string $bookSlug, string $pageSlug) { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); session()->flashInput(['name' => $page->name]); return view('pages.copy', [ 'book' => $page->book, 'page' => $page, ]); } /** * Create a copy of a page within the requested target destination. * * @throws NotFoundException * @throws Throwable */ public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug) { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageView, $page); $entitySelection = $request->get('entity_selection') ?: null; $newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent(); if (!$newParent instanceof Book && !$newParent instanceof Chapter) { $this->showErrorNotification(trans('errors.selected_book_chapter_not_found')); return redirect($page->getUrl('/copy')); } $this->checkOwnablePermission(Permission::PageCreate, $newParent); $newName = $request->get('name') ?: $page->name; $pageCopy = $cloner->clonePage($page, $newParent, $newName); $this->showSuccessNotification(trans('entities.pages_copy_success')); return redirect($pageCopy->getUrl()); } } ================================================ FILE: app/Entities/Controllers/PageRevisionController.php ================================================ pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([ 'id' => trans('entities.pages_revisions_sort_number') ]); $revisions = $page->revisions()->select([ 'id', 'page_id', 'name', 'created_at', 'created_by', 'updated_at', 'type', 'revision_number', 'summary', ]) ->selectRaw("IF(markdown = '', false, true) as is_markdown") ->with(['page.book', 'createdBy']) ->reorder('id', $listOptions->getOrder()) ->paginate(50); $this->setPageTitle(trans('entities.pages_revisions_named', ['pageName' => $page->getShortName()])); return view('pages.revisions', [ 'revisions' => $revisions, 'page' => $page, 'listOptions' => $listOptions, 'oldestRevisionId' => $page->revisions()->min('id'), ]); } /** * Shows a preview of a single revision. * * @throws NotFoundException */ public function show(string $bookSlug, string $pageSlug, int $revisionId) { $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); /** @var ?PageRevision $revision */ $revision = $page->revisions()->where('id', '=', $revisionId)->first(); if ($revision === null) { throw new NotFoundException(); } $page->fill($revision->toArray()); // TODO - Refactor PageContent so we don't need to juggle this $page->html = $revision->html; $page->html = (new PageContent($page))->render(); $this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()])); return view('pages.revision', [ 'page' => $page, 'book' => $page->book, 'diff' => null, 'revision' => $revision, ]); } /** * Shows the changes of a single revision. * * @throws NotFoundException */ public function changes(string $bookSlug, string $pageSlug, int $revisionId) { $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); /** @var ?PageRevision $revision */ $revision = $page->revisions()->where('id', '=', $revisionId)->first(); if ($revision === null) { throw new NotFoundException(); } $prev = $revision->getPreviousRevision(); $prevContent = $prev->html ?? ''; // TODO - Refactor PageContent so we can de-dupe these steps $rawDiff = Diff::excecute($prevContent, $revision->html); $filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering')); $filter = new HtmlContentFilter($filterConfig); $diff = $filter->filterString($rawDiff); $page->fill($revision->toArray()); $page->html = ''; $this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()])); return view('pages.revision', [ 'page' => $page, 'book' => $page->book, 'diff' => $diff, 'revision' => $revision, ]); } /** * Restores a page using the content of the specified revision. * * @throws NotFoundException */ public function restore(string $bookSlug, string $pageSlug, int $revisionId) { $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageUpdate, $page); $page = $this->pageRepo->restoreRevision($page, $revisionId); return redirect($page->getUrl()); } /** * Deletes a revision using the id of the specified revision. * * @throws NotFoundException */ public function destroy(string $bookSlug, string $pageSlug, int $revId) { $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageDelete, $page); $revision = $page->revisions()->where('id', '=', $revId)->first(); if ($revision === null) { throw new NotFoundException("Revision #{$revId} not found"); } // Check if it's the latest revision, cannot delete the latest revision. if (intval($page->currentRevision->id ?? null) === intval($revId)) { $this->showErrorNotification(trans('entities.revision_cannot_delete_latest')); return redirect($page->getUrl('/revisions')); } $revision->delete(); Activity::add(ActivityType::REVISION_DELETE, $revision); return redirect($page->getUrl('/revisions')); } /** * Destroys existing drafts, belonging to the current user, for the given page. */ public function destroyUserDraft(string $pageId) { $page = $this->pageQueries->findVisibleByIdOrFail($pageId); $this->revisionRepo->deleteDraftsForCurrentUser($page); return response('', 200); } } ================================================ FILE: app/Entities/Controllers/PageTemplateController.php ================================================ get('page', 1); $search = $request->get('search', ''); $count = 10; $query = $this->pageQueries->visibleTemplates() ->orderBy('name', 'asc') ->skip(($page - 1) * $count) ->take($count); if ($search) { $query->where('name', 'like', '%' . $search . '%'); } $templates = $query->paginate($count, ['*'], 'page', $page); $templates->withPath('/templates'); if ($search) { $templates->appends(['search' => $search]); } return view('pages.parts.template-manager-list', [ 'templates' => $templates, ]); } /** * Get the content of a template. * * @throws NotFoundException */ public function get(int $templateId) { $page = $this->pageQueries->findVisibleByIdOrFail($templateId); if (!$page->template) { throw new NotFoundException(); } return response()->json([ 'html' => $page->html, 'markdown' => $page->markdown, ]); } } ================================================ FILE: app/Entities/Controllers/RecycleBinApiController.php ================================================ middleware(function ($request, $next) { $this->checkPermission(Permission::SettingsManage); $this->checkPermission(Permission::RestrictionsManageAll); return $next($request); }); } /** * Get a top-level listing of the items in the recycle bin. * The "deletable" property will reflect the main item deleted. * For books and chapters, counts of child pages/chapters will * be loaded within this "deletable" data. * For chapters & pages, the parent item will be loaded within this "deletable" data. * Requires permission to manage both system settings and permissions. */ public function list() { return $this->apiListingResponse(Deletion::query()->with('deletable'), [ 'id', 'deleted_by', 'created_at', 'updated_at', 'deletable_type', 'deletable_id', ], [$this->listFormatter(...)]); } /** * Restore a single deletion from the recycle bin. * Requires permission to manage both system settings and permissions. */ public function restore(DeletionRepo $deletionRepo, string $deletionId) { $restoreCount = $deletionRepo->restore(intval($deletionId)); return response()->json(['restore_count' => $restoreCount]); } /** * Remove a single deletion from the recycle bin. * Use this endpoint carefully as it will entirely remove the underlying deleted items from the system. * Requires permission to manage both system settings and permissions. */ public function destroy(DeletionRepo $deletionRepo, string $deletionId) { $deleteCount = $deletionRepo->destroy(intval($deletionId)); return response()->json(['delete_count' => $deleteCount]); } /** * Load some related details for the deletion listing. */ protected function listFormatter(Deletion $deletion): void { $deletable = $deletion->deletable; if ($deletable instanceof BookChild) { $parent = $deletable->getParent(); $parent->setAttribute('type', $parent->getType()); $deletable->setRelation('parent', $parent); } if ($deletable instanceof Book || $deletable instanceof Chapter) { $countsToLoad = ['pages' => static::withTrashedQuery(...)]; if ($deletable instanceof Book) { $countsToLoad['chapters'] = static::withTrashedQuery(...); } $deletable->loadCount($countsToLoad); } } /** * @param Builder $query */ protected static function withTrashedQuery(Builder $query): void { $query->withTrashed(); } } ================================================ FILE: app/Entities/Controllers/RecycleBinController.php ================================================ middleware(function ($request, $next) { $this->checkPermission(Permission::SettingsManage); $this->checkPermission(Permission::RestrictionsManageAll); return $next($request); }); } /** * Show the top-level listing for the recycle bin. */ public function index() { $deletions = Deletion::query()->with(['deletable', 'deleter'])->paginate(10); $this->setPageTitle(trans('settings.recycle_bin')); return view('settings.recycle-bin.index', [ 'deletions' => $deletions, ]); } /** * Show the page to confirm a restore of the deletion of the given id. */ public function showRestore(string $id) { /** @var Deletion $deletion */ $deletion = Deletion::query()->findOrFail($id); // Walk the parent chain to find any cascading parent deletions $currentDeletable = $deletion->deletable; $searching = true; while ($searching && $currentDeletable instanceof Entity) { $parent = $currentDeletable->getParent(); if ($parent && $parent->trashed()) { $currentDeletable = $parent; } else { $searching = false; } } /** @var ?Deletion $parentDeletion */ $parentDeletion = ($currentDeletable === $deletion->deletable) ? null : $currentDeletable->deletions()->first(); return view('settings.recycle-bin.restore', [ 'deletion' => $deletion, 'parentDeletion' => $parentDeletion, ]); } /** * Restore the element attached to the given deletion. * * @throws \Exception */ public function restore(DeletionRepo $deletionRepo, string $id) { $restoreCount = $deletionRepo->restore((int) $id); $this->showSuccessNotification(trans('settings.recycle_bin_restore_notification', ['count' => $restoreCount])); return redirect($this->recycleBinBaseUrl); } /** * Show the page to confirm a Permanent deletion of the element attached to the deletion of the given id. */ public function showDestroy(string $id) { /** @var Deletion $deletion */ $deletion = Deletion::query()->findOrFail($id); return view('settings.recycle-bin.destroy', [ 'deletion' => $deletion, ]); } /** * Permanently delete the content associated with the given deletion. * * @throws \Exception */ public function destroy(DeletionRepo $deletionRepo, string $id) { $deleteCount = $deletionRepo->destroy((int) $id); $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount])); return redirect($this->recycleBinBaseUrl); } /** * Empty out the recycle bin. * * @throws \Exception */ public function empty(TrashCan $trash) { $deleteCount = $trash->empty(); $this->logActivity(ActivityType::RECYCLE_BIN_EMPTY); $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount])); return redirect($this->recycleBinBaseUrl); } } ================================================ FILE: app/Entities/EntityExistsRule.php ================================================ where('type', $this->type); return $existsRule->__toString(); } } ================================================ FILE: app/Entities/EntityProvider.php ================================================ bookshelf = new Bookshelf(); $this->book = new Book(); $this->chapter = new Chapter(); $this->page = new Page(); $this->pageRevision = new PageRevision(); } /** * Fetch all core entity types as an associated array * with their basic names as the keys. * * @return array */ public function all(): array { return [ 'bookshelf' => $this->bookshelf, 'book' => $this->book, 'chapter' => $this->chapter, 'page' => $this->page, ]; } /** * Get an entity instance by its basic name. */ public function get(string $type): Entity { $type = strtolower($type); $instance = $this->all()[$type] ?? null; if (is_null($instance)) { throw new \InvalidArgumentException("Provided type \"{$type}\" is not a valid entity type"); } return $instance; } /** * Get the morph classes, as an array, for a single or multiple types. */ public function getMorphClasses(array $types): array { $morphClasses = []; foreach ($types as $type) { $model = $this->get($type); $morphClasses[] = $model->getMorphClass(); } return $morphClasses; } } ================================================ FILE: app/Entities/Models/Book.php ================================================ slug), trim($path, '/')])); } /** * Get all pages within this book. * @return HasMany */ public function pages(): HasMany { return $this->hasMany(Page::class); } /** * Get the direct child pages of this book. */ public function directPages(): HasMany { return $this->pages()->whereNull('chapter_id'); } /** * Get all chapters within this book. * @return HasMany */ public function chapters(): HasMany { return $this->hasMany(Chapter::class); } /** * Get the shelves this book is contained within. */ public function shelves(): BelongsToMany { return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id'); } /** * Get the direct child items within this book. */ public function getDirectVisibleChildren(): Collection { $pages = $this->directPages()->scopes('visible')->get(); $chapters = $this->chapters()->scopes('visible')->get(); return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft'); } public function defaultTemplate(): EntityDefaultTemplate { return new EntityDefaultTemplate($this); } public function cover(): BelongsTo { return $this->belongsTo(Image::class, 'image_id'); } public function coverInfo(): EntityCover { return new EntityCover($this); } /** * Get the sort rule assigned to this container, if existing. */ public function sortRule(): BelongsTo { return $this->belongsTo(SortRule::class); } } ================================================ FILE: app/Entities/Models/BookChild.php ================================================ */ public function book(): BelongsTo { return $this->belongsTo(Book::class)->withTrashed(); } } ================================================ FILE: app/Entities/Models/Bookshelf.php ================================================ belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id') ->select(['entities.*', 'entity_container_data.*']) ->withPivot('order') ->orderBy('order', 'asc'); } /** * Related books that are visible to the current user. */ public function visibleBooks(): BelongsToMany { return $this->books()->scopes('visible'); } /** * Get the url for this bookshelf. */ public function getUrl(string $path = ''): string { return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')])); } /** * Check if this shelf contains the given book. */ public function contains(Book $book): bool { return $this->books()->where('id', '=', $book->id)->count() > 0; } /** * Add a book to the end of this shelf. */ public function appendBook(Book $book): void { if ($this->contains($book)) { return; } $maxOrder = $this->books()->max('order'); $this->books()->attach($book->id, ['order' => $maxOrder + 1]); } public function coverInfo(): EntityCover { return new EntityCover($this); } public function cover(): BelongsTo { return $this->belongsTo(Image::class, 'image_id'); } } ================================================ FILE: app/Entities/Models/Chapter.php ================================================ $pages * @property ?int $default_template_id * @property string $description * @property string $description_html */ class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTemplateInterface { use HasFactory; use ContainerTrait; public float $searchFactor = 1.2; protected $hidden = ['pivot', 'deleted_at', 'description_html', 'sort_rule_id', 'image_id', 'entity_id', 'entity_type', 'chapter_id']; protected $fillable = ['name', 'priority']; /** * Get the pages that this chapter contains. * * @return HasMany */ public function pages(string $dir = 'ASC'): HasMany { return $this->hasMany(Page::class)->orderBy('priority', $dir); } /** * Get the url of this chapter. */ public function getUrl(string $path = ''): string { $parts = [ 'books', urlencode($this->book_slug ?? $this->book->slug), 'chapter', urlencode($this->slug), trim($path, '/'), ]; return url('/' . implode('/', $parts)); } /** * Get the visible pages in this chapter. * @return Collection */ public function getVisiblePages(): Collection { return $this->pages() ->scopes('visible') ->orderBy('draft', 'desc') ->orderBy('priority', 'asc') ->get(); } public function defaultTemplate(): EntityDefaultTemplate { return new EntityDefaultTemplate($this); } } ================================================ FILE: app/Entities/Models/ContainerTrait.php ================================================ */ public function relatedData(): HasOne { return $this->hasOne(EntityContainerData::class, 'entity_id', 'id') ->where('entity_type', '=', $this->getMorphClass()); } } ================================================ FILE: app/Entities/Models/DeletableInterface.php ================================================ morphTo('deletable')->withTrashed(); } /** * Get the user that performed the deletion. */ public function deleter(): BelongsTo { return $this->belongsTo(User::class, 'deleted_by'); } /** * Create a new deletion record for the provided entity. */ public static function createForEntity(Entity $entity): self { $record = (new self())->forceFill([ 'deleted_by' => user()->id, 'deletable_type' => $entity->getMorphClass(), 'deletable_id' => $entity->id, ]); $record->save(); return $record; } public function logDescriptor(): string { $deletable = $this->deletable()->first(); if ($deletable instanceof Entity) { return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}"; } return "Deletion ({$this->id})"; } /** * Get a URL for this specific deletion. */ public function getUrl(string $path = 'restore'): string { return url("/settings/recycle-bin/{$this->id}/" . ltrim($path, '/')); } } ================================================ FILE: app/Entities/Models/Entity.php ================================================ relatedData()->firstOrNew(); $contentFields = $this->getContentsAttributes(); foreach ($contentFields as $key => $value) { $contents->setAttribute($key, $value); unset($this->attributes[$key]); } $this->setAttribute('type', $this->getMorphClass()); $result = parent::save($options); $contentsResult = true; if ($result && $contents->isDirty()) { $contentsFillData = $contents instanceof EntityPageData ? ['page_id' => $this->id] : ['entity_id' => $this->id, 'entity_type' => $this->getMorphClass()]; $contents->forceFill($contentsFillData); $contentsResult = $contents->save(); $this->touch(); } $this->forceFill($contentFields); return $result && $contentsResult; } /** * Check if this item is a container item. */ public function isContainer(): bool { return $this instanceof Bookshelf || $this instanceof Book || $this instanceof Chapter; } /** * Get the entities that are visible to the current user. */ public function scopeVisible(Builder $query): Builder { return app()->make(PermissionApplicator::class)->restrictEntityQuery($query); } /** * Query scope to get the last view from the current user. */ public function scopeWithLastView(Builder $query) { $viewedAtQuery = View::query()->select('updated_at') ->whereColumn('viewable_id', '=', 'entities.id') ->whereColumn('viewable_type', '=', 'entities.type') ->where('user_id', '=', user()->id) ->take(1); return $query->addSelect(['last_viewed_at' => $viewedAtQuery]); } /** * Query scope to get the total view count of the entities. */ public function scopeWithViewCount(Builder $query): void { $viewCountQuery = View::query()->selectRaw('SUM(views) as view_count') ->whereColumn('viewable_id', '=', 'entities.id') ->whereColumn('viewable_type', '=', 'entities.type') ->take(1); $query->addSelect(['view_count' => $viewCountQuery]); } /** * Compares this entity to another given entity. * Matches by comparing class and id. */ public function matches(self $entity): bool { return [get_class($this), $this->id] === [get_class($entity), $entity->id]; } /** * Checks if the current entity matches or contains the given. */ public function matchesOrContains(self $entity): bool { if ($this->matches($entity)) { return true; } if (($entity instanceof BookChild) && $this instanceof Book) { return $entity->book_id === $this->id; } if ($entity instanceof Page && $this instanceof Chapter) { return $entity->chapter_id === $this->id; } return false; } /** * Gets the activity objects for this entity. */ public function activity(): MorphMany { return $this->morphMany(Activity::class, 'loggable') ->orderBy('created_at', 'desc'); } /** * Get View objects for this entity. */ public function views(): MorphMany { return $this->morphMany(View::class, 'viewable'); } /** * Get the Tag models that have been user assigned to this entity. */ public function tags(): MorphMany { return $this->morphMany(Tag::class, 'entity') ->orderBy('order', 'asc'); } /** * Get the comments for an entity. * @return MorphMany */ public function comments(bool $orderByCreated = true): MorphMany { $query = $this->morphMany(Comment::class, 'commentable'); return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query; } /** * Get the related search terms. */ public function searchTerms(): MorphMany { return $this->morphMany(SearchTerm::class, 'entity'); } /** * Get this entities assigned permissions. */ public function permissions(): MorphMany { return $this->morphMany(EntityPermission::class, 'entity'); } /** * Check if this entity has a specific restriction set against it. */ public function hasPermissions(): bool { return $this->permissions()->count() > 0; } /** * Get the entity jointPermissions this is connected to. */ public function jointPermissions(): MorphMany { return $this->morphMany(JointPermission::class, 'entity'); } /** * Get the user who owns this entity. * @return BelongsTo */ public function ownedBy(): BelongsTo { return $this->belongsTo(User::class, 'owned_by'); } public function getOwnerFieldName(): string { return 'owned_by'; } /** * Get the related delete records for this entity. */ public function deletions(): MorphMany { return $this->morphMany(Deletion::class, 'deletable'); } /** * Get the references pointing from this entity to other items. */ public function referencesFrom(): MorphMany { return $this->morphMany(Reference::class, 'from'); } /** * Get the references pointing to this entity from other items. */ public function referencesTo(): MorphMany { return $this->morphMany(Reference::class, 'to'); } /** * Check if this instance or class is a certain type of entity. * Examples of $type are 'page', 'book', 'chapter'. * * @deprecated Use instanceof instead. */ public static function isA(string $type): bool { return static::getType() === strtolower($type); } /** * Get the entity type as a simple lowercase word. */ public static function getType(): string { $className = array_slice(explode('\\', static::class), -1, 1)[0]; return strtolower($className); } /** * Gets a limited-length version of the entity name. */ public function getShortName(int $length = 25): string { if (mb_strlen($this->name) <= $length) { return $this->name; } return mb_substr($this->name, 0, $length - 3) . '...'; } /** * Get an excerpt of this entity's descriptive content to the specified length. */ public function getExcerpt(int $length = 100): string { $text = $this->{$this->textField} ?? ''; if (mb_strlen($text) > $length) { $text = mb_substr($text, 0, $length - 3) . '...'; } return trim($text); } /** * Get the url of this entity. */ abstract public function getUrl(string $path = '/'): string; /** * Get the parent entity if existing. * This is the "static" parent and does not include dynamic * relations such as shelves to books. */ public function getParent(): ?self { if ($this instanceof Page) { /** @var BelongsTo $builder */ $builder = $this->chapter_id ? $this->chapter() : $this->book(); return $builder->withTrashed()->first(); } if ($this instanceof Chapter) { /** @var BelongsTo $builder */ $builder = $this->book(); return $builder->withTrashed()->first(); } return null; } /** * Rebuild the permissions for this entity. */ public function rebuildPermissions(): void { app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this); } /** * Index the current entity for search. */ public function indexForSearch(): void { app()->make(SearchIndex::class)->indexEntity(clone $this); } /** * {@inheritdoc} */ public function favourites(): MorphMany { return $this->morphMany(Favourite::class, 'favouritable'); } /** * Check if the entity is a favourite of the current user. */ public function isFavourite(): bool { return $this->favourites() ->where('user_id', '=', user()->id) ->exists(); } /** * Get the related watches for this entity. */ public function watches(): MorphMany { return $this->morphMany(Watch::class, 'watchable'); } /** * Get the related slug history for this entity. */ public function slugHistory(): MorphMany { return $this->morphMany(SlugHistory::class, 'sluggable'); } /** * {@inheritdoc} */ public function logDescriptor(): string { return "({$this->id}) {$this->name}"; } /** * @return HasOne */ abstract public function relatedData(): HasOne; /** * Get the attributes that are intended for the related contents model. * @return array */ protected function getContentsAttributes(): array { $contentFields = []; $contentModel = $this instanceof Page ? EntityPageData::class : EntityContainerData::class; foreach ($this->attributes as $key => $value) { if (in_array($key, $contentModel::$fields)) { $contentFields[$key] = $value; } } return $contentFields; } /** * Create a new instance for the given entity type. */ public static function instanceFromType(string $type): self { return match ($type) { 'page' => new Page(), 'chapter' => new Chapter(), 'book' => new Book(), 'bookshelf' => new Bookshelf(), }; } } ================================================ FILE: app/Entities/Models/EntityContainerData.php ================================================ where($this->getKeyName(), '=', $this->getKeyForSaveQuery()) ->where('entity_type', '=', $this->entity_type); return $query; } /** * Override the default set keys for a select query method to make it work with composite keys. */ protected function setKeysForSelectQuery($query): Builder { $query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery()) ->where('entity_type', '=', $this->entity_type); return $query; } } ================================================ FILE: app/Entities/Models/EntityPageData.php ================================================ withGlobalScope('entity', new EntityScope()); } public function withoutGlobalScope($scope): static { // Prevent removal of the entity scope if ($scope === 'entity') { return $this; } return parent::withoutGlobalScope($scope); } /** * Override the default forceDelete method to add type filter onto the query * since it specifically ignores scopes by default. */ public function forceDelete() { return $this->query->where('type', '=', $this->model->getMorphClass())->delete(); } } ================================================ FILE: app/Entities/Models/EntityScope.php ================================================ where('type', '=', $model->getMorphClass()); $table = $model->getTable(); if ($model instanceof Page) { $builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', "{$table}.id"); } else { $builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model, $table) { $join->on('entity_container_data.entity_id', '=', "{$table}.id") ->where('entity_container_data.entity_type', '=', $model->getMorphClass()); }); } } } ================================================ FILE: app/Entities/Models/EntityTable.php ================================================ make(PermissionApplicator::class)->restrictEntityQuery($query); } /** * Get the entity jointPermissions this is connected to. */ public function jointPermissions(): HasMany { return $this->hasMany(JointPermission::class, 'entity_id') ->whereColumn('entity_type', '=', 'entities.type'); } /** * Get the Tags that have been assigned to entities. */ public function tags(): HasMany { return $this->hasMany(Tag::class, 'entity_id') ->whereColumn('entity_type', '=', 'entities.type'); } /** * Get the assigned permissions. */ public function permissions(): HasMany { return $this->hasMany(EntityPermission::class, 'entity_id') ->whereColumn('entity_type', '=', 'entities.type'); } /** * Get View objects for this entity. */ public function views(): HasMany { return $this->hasMany(View::class, 'viewable_id') ->whereColumn('viewable_type', '=', 'entities.type'); } } ================================================ FILE: app/Entities/Models/HasCoverInterface.php ================================================ */ public function cover(): BelongsTo; } ================================================ FILE: app/Entities/Models/HasDefaultTemplateInterface.php ================================================ 'boolean', 'template' => 'boolean', ]; /** * Get the entities that are visible to the current user. */ public function scopeVisible(Builder $query): Builder { $query = app()->make(PermissionApplicator::class)->restrictDraftsOnPageQuery($query); return parent::scopeVisible($query); } /** * Get the chapter that this page is in, If applicable. */ public function chapter(): BelongsTo { return $this->belongsTo(Chapter::class); } /** * Check if this page has a chapter. */ public function hasChapter(): bool { return $this->chapter()->count() > 0; } /** * Get the associated page revisions, ordered by created date. * Only provides actual saved page revision instances, Not drafts. */ public function revisions(): HasMany { return $this->allRevisions() ->where('type', '=', 'version') ->orderBy('created_at', 'desc') ->orderBy('id', 'desc'); } /** * Get the current revision for the page if existing. */ public function currentRevision(): HasOne { return $this->hasOne(PageRevision::class) ->where('type', '=', 'version') ->orderBy('created_at', 'desc') ->orderBy('id', 'desc'); } /** * Get all revision instances assigned to this page. * Includes all types of revisions. */ public function allRevisions(): HasMany { return $this->hasMany(PageRevision::class); } /** * Get the attachments assigned to this page. */ public function attachments(): HasMany { return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc'); } /** * Get the url of this page. */ public function getUrl(string $path = ''): string { $parts = [ 'books', urlencode($this->book_slug ?? $this->book->slug), $this->draft ? 'draft' : 'page', $this->draft ? $this->id : urlencode($this->slug), trim($path, '/'), ]; return url('/' . implode('/', $parts)); } /** * Get the ID-based permalink for this page. */ public function getPermalink(): string { return url("/link/{$this->id}"); } /** * Get this page for JSON display. */ public function forJsonDisplay(): self { $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']); $refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown'])); $refreshed->setAttribute('raw_html', $refreshed->html); $refreshed->setAttribute('html', (new PageContent($refreshed))->render()); return $refreshed; } /** * @return HasOne */ public function relatedData(): HasOne { return $this->hasOne(EntityPageData::class, 'page_id', 'id'); } } ================================================ FILE: app/Entities/Models/PageRevision.php ================================================ belongsTo(User::class, 'created_by'); } /** * Get the page this revision originates from. */ public function page(): BelongsTo { return $this->belongsTo(Page::class); } /** * Get the url for this revision. */ public function getUrl(string $path = ''): string { return $this->page->getUrl('/revisions/' . $this->id . '/' . ltrim($path, '/')); } /** * Get the previous revision for the same page if existing. */ public function getPreviousRevision(): ?PageRevision { $id = static::newQuery()->where('page_id', '=', $this->page_id) ->where('id', '<', $this->id) ->max('id'); if ($id) { return static::query()->find($id); } return null; } /** * Allows checking of the exact class, Used to check entity type. * Included here to align with entities in similar use cases. * (Yup, Bit of an awkward hack). * * @deprecated Use instanceof instead. */ public static function isA(string $type): bool { return $type === 'revision'; } public function logDescriptor(): string { return "Revision #{$this->revision_number} (ID: {$this->id}) for page ID {$this->page_id}"; } } ================================================ FILE: app/Entities/Models/SlugHistory.php ================================================ hasMany(JointPermission::class, 'entity_id', 'sluggable_id') ->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type'); } } ================================================ FILE: app/Entities/Queries/BookQueries.php ================================================ */ class BookQueries implements ProvidesEntityQueries { protected static array $listAttributes = [ 'id', 'slug', 'name', 'description', 'created_at', 'updated_at', 'image_id', 'owned_by', ]; /** * @return Builder */ public function start(): Builder { return Book::query(); } public function findVisibleById(int $id): ?Book { return $this->start()->scopes('visible')->find($id); } public function findVisibleByIdOrFail(int $id): Book { return $this->start()->scopes('visible')->findOrFail($id); } public function findVisibleBySlugOrFail(string $slug): Book { /** @var ?Book $book */ $book = $this->start() ->scopes('visible') ->where('slug', '=', $slug) ->first(); if ($book === null) { throw new NotFoundException(trans('errors.book_not_found')); } return $book; } public function visibleForList(): Builder { return $this->start()->scopes('visible') ->select(static::$listAttributes); } public function visibleForContent(): Builder { return $this->start()->scopes('visible'); } public function visibleForListWithCover(): Builder { return $this->visibleForList()->with('cover'); } public function recentlyViewedForCurrentUser(): Builder { return $this->visibleForList() ->scopes('withLastView') ->having('last_viewed_at', '>', 0) ->orderBy('last_viewed_at', 'desc'); } public function popularForList(): Builder { return $this->visibleForList() ->scopes('withViewCount') ->having('view_count', '>', 0) ->orderBy('view_count', 'desc'); } } ================================================ FILE: app/Entities/Queries/BookshelfQueries.php ================================================ */ class BookshelfQueries implements ProvidesEntityQueries { protected static array $listAttributes = [ 'id', 'slug', 'name', 'description', 'created_at', 'updated_at', 'image_id', 'owned_by', ]; /** * @return Builder */ public function start(): Builder { return Bookshelf::query(); } public function findVisibleById(int $id): ?Bookshelf { return $this->start()->scopes('visible')->find($id); } public function findVisibleByIdOrFail(int $id): Bookshelf { $shelf = $this->findVisibleById($id); if (is_null($shelf)) { throw new NotFoundException(trans('errors.bookshelf_not_found')); } return $shelf; } public function findVisibleBySlugOrFail(string $slug): Bookshelf { /** @var ?Bookshelf $shelf */ $shelf = $this->start() ->scopes('visible') ->where('slug', '=', $slug) ->first(); if ($shelf === null) { throw new NotFoundException(trans('errors.bookshelf_not_found')); } return $shelf; } public function visibleForList(): Builder { return $this->start()->scopes('visible')->select(static::$listAttributes); } public function visibleForContent(): Builder { return $this->start()->scopes('visible'); } public function visibleForListWithCover(): Builder { return $this->visibleForList()->with('cover'); } public function recentlyViewedForCurrentUser(): Builder { return $this->visibleForList() ->scopes('withLastView') ->having('last_viewed_at', '>', 0) ->orderBy('last_viewed_at', 'desc'); } public function popularForList(): Builder { return $this->visibleForList() ->scopes('withViewCount') ->having('view_count', '>', 0) ->orderBy('view_count', 'desc'); } } ================================================ FILE: app/Entities/Queries/ChapterQueries.php ================================================ */ class ChapterQueries implements ProvidesEntityQueries { protected static array $listAttributes = [ 'id', 'slug', 'name', 'description', 'priority', 'book_id', 'created_at', 'updated_at', 'owned_by', ]; public function start(): Builder { return Chapter::query(); } public function findVisibleById(int $id): ?Chapter { return $this->start()->scopes('visible')->find($id); } public function findVisibleByIdOrFail(int $id): Chapter { return $this->start()->scopes('visible')->findOrFail($id); } public function findVisibleBySlugsOrFail(string $bookSlug, string $chapterSlug): Chapter { /** @var ?Chapter $chapter */ $chapter = $this->start() ->scopes('visible') ->with('book') ->whereHas('book', function (Builder $query) use ($bookSlug) { $query->where('slug', '=', $bookSlug); }) ->where('slug', '=', $chapterSlug) ->first(); if (is_null($chapter)) { throw new NotFoundException(trans('errors.chapter_not_found')); } return $chapter; } public function usingSlugs(string $bookSlug, string $chapterSlug): Builder { return $this->start() ->where('slug', '=', $chapterSlug) ->whereHas('book', function (Builder $query) use ($bookSlug) { $query->where('slug', '=', $bookSlug); }); } public function visibleForList(): Builder { return $this->start() ->scopes('visible') ->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) { $builder->select('slug') ->from('entities as books') ->where('type', '=', 'book') ->whereColumn('books.id', '=', 'entities.book_id'); }])); } public function visibleForContent(): Builder { return $this->start()->scopes('visible'); } } ================================================ FILE: app/Entities/Queries/EntityQueries.php ================================================ findVisibleById($entityType, $entityId); } /** * Find an entity by its ID. */ public function findVisibleById(string $type, int $id): ?Entity { $queries = $this->getQueriesForType($type); return $queries->findVisibleById($id); } /** * Find an entity by looking up old slugs in the slug history. */ public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity { $id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug); if ($id === null) { return null; } return $this->findVisibleById($type, $id); } /** * Start a query across all entity types. * Combines the description/text fields into a single 'description' field. * @return Builder */ public function visibleForList(): Builder { $rawDescriptionField = DB::raw('COALESCE(description, text) as description'); $bookSlugSelect = function (QueryBuilder $query) { return $query->select('slug')->from('entities as books') ->whereColumn('books.id', '=', 'entities.book_id') ->where('type', '=', 'book'); }; return EntityTable::query()->scopes('visible') ->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', 'book_slug' => $bookSlugSelect, $rawDescriptionField]) ->leftJoin('entity_container_data', function (JoinClause $join) { $join->on('entity_container_data.entity_id', '=', 'entities.id') ->on('entity_container_data.entity_type', '=', 'entities.type'); })->leftJoin('entity_page_data', function (JoinClause $join) { $join->on('entity_page_data.page_id', '=', 'entities.id') ->where('entities.type', '=', 'page'); }); } /** * Start a query of visible entities of the given type, * suitable for listing display. * @return Builder */ public function visibleForListForType(string $entityType): Builder { $queries = $this->getQueriesForType($entityType); return $queries->visibleForList(); } /** * Start a query of visible entities of the given type, * suitable for using the contents of the items. * @return Builder */ public function visibleForContentForType(string $entityType): Builder { $queries = $this->getQueriesForType($entityType); return $queries->visibleForContent(); } protected function getQueriesForType(string $type): ProvidesEntityQueries { $queries = match ($type) { 'page' => $this->pages, 'chapter' => $this->chapters, 'book' => $this->books, 'bookshelf' => $this->shelves, default => null, }; if (is_null($queries)) { throw new InvalidArgumentException("No entity query class configured for {$type}"); } return $queries; } } ================================================ FILE: app/Entities/Queries/PageQueries.php ================================================ */ class PageQueries implements ProvidesEntityQueries { protected static array $contentAttributes = [ 'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'markdown', 'text', 'created_at', 'updated_at', 'priority', 'created_by', 'updated_by', 'owned_by', ]; protected static array $listAttributes = [ 'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at', 'priority', 'owned_by', ]; /** * @return Builder */ public function start(): Builder { return Page::query(); } public function findVisibleById(int $id): ?Page { return $this->start()->scopes('visible')->find($id); } public function findVisibleByIdOrFail(int $id): Page { $page = $this->findVisibleById($id); if (is_null($page)) { throw new NotFoundException(trans('errors.page_not_found')); } return $page; } public function findVisibleBySlugsOrFail(string $bookSlug, string $pageSlug): Page { /** @var ?Page $page */ $page = $this->start()->with('book') ->scopes('visible') ->whereHas('book', function (Builder $query) use ($bookSlug) { $query->where('slug', '=', $bookSlug); }) ->where('slug', '=', $pageSlug) ->first(); if (is_null($page)) { throw new NotFoundException(trans('errors.page_not_found')); } return $page; } public function usingSlugs(string $bookSlug, string $pageSlug): Builder { return $this->start() ->where('slug', '=', $pageSlug) ->whereHas('book', function (Builder $query) use ($bookSlug) { $query->where('slug', '=', $bookSlug); }); } /** * @return Builder */ public function visibleForList(): Builder { return $this->start() ->scopes('visible') ->select($this->mergeBookSlugForSelect(static::$listAttributes)); } /** * @return Builder */ public function visibleForContent(): Builder { return $this->start()->scopes('visible'); } public function visibleForChapterList(int $chapterId): Builder { return $this->visibleForList() ->where('chapter_id', '=', $chapterId) ->orderBy('draft', 'desc') ->orderBy('priority', 'asc'); } public function visibleWithContents(): Builder { return $this->start() ->scopes('visible') ->select($this->mergeBookSlugForSelect(static::$contentAttributes)); } public function currentUserDraftsForList(): Builder { return $this->visibleForList() ->where('draft', '=', true) ->where('created_by', '=', user()->id); } public function visibleTemplates(bool $includeContents = false): Builder { $base = $includeContents ? $this->visibleWithContents() : $this->visibleForList(); return $base->where('template', '=', true); } protected function mergeBookSlugForSelect(array $columns): array { return array_merge($columns, ['book_slug' => function ($builder) { $builder->select('slug') ->from('entities as books') ->where('type', '=', 'book') ->whereColumn('books.id', '=', 'entities.book_id'); }]); } } ================================================ FILE: app/Entities/Queries/PageRevisionQueries.php ================================================ whereHas('page', function (Builder $query) { $query->scopes('visible'); }) ->where('slug', '=', $pageSlug) ->where('type', '=', 'version') ->where('book_slug', '=', $bookSlug) ->orderBy('created_at', 'desc') ->first(); } public function findLatestCurrentUserDraftsForPageId(int $pageId): ?PageRevision { /** @var ?PageRevision $revision */ $revision = $this->latestCurrentUserDraftsForPageId($pageId)->first(); return $revision; } public function latestCurrentUserDraftsForPageId(int $pageId): Builder { return $this->start() ->where('created_by', '=', user()->id) ->where('type', 'update_draft') ->where('page_id', '=', $pageId) ->orderBy('created_at', 'desc'); } } ================================================ FILE: app/Entities/Queries/ProvidesEntityQueries.php ================================================ */ public function start(): Builder; /** * Find the entity of the given ID or return null if not found. */ public function findVisibleById(int $id): ?Entity; /** * Start a query for items that are visible, with selection * configured for list display of this item. * @return Builder */ public function visibleForList(): Builder; /** * Start a query for items that are visible, with selection * configured for using the content of the items found. * @return Builder */ public function visibleForContent(): Builder; } ================================================ FILE: app/Entities/Queries/QueryPopular.php ================================================ permissions ->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type') ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count')) ->groupBy('viewable_id', 'viewable_type') ->orderBy('view_count', 'desc'); if (!empty($filterModels)) { $query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels)); } $views = $query ->skip($count * ($page - 1)) ->take($count) ->get(); $this->listLoader->loadIntoRelations($views->all(), 'viewable', true); return $views->pluck('viewable')->filter(); } } ================================================ FILE: app/Entities/Queries/QueryRecentlyViewed.php ================================================ isGuest()) { return collect(); } $query = $this->permissions->restrictEntityRelationQuery( View::query(), 'views', 'viewable_id', 'viewable_type' ) ->orderBy('views.updated_at', 'desc') ->where('user_id', '=', user()->id); $views = $query ->skip(($page - 1) * $count) ->take($count) ->get(); $this->listLoader->loadIntoRelations($views->all(), 'viewable', false); return $views->pluck('viewable')->filter(); } } ================================================ FILE: app/Entities/Queries/QueryTopFavourites.php ================================================ isGuest()) { return collect(); } $query = $this->permissions ->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type') ->select('favourites.*') ->leftJoin('views', function (JoinClause $join) { $join->on('favourites.favouritable_id', '=', 'views.viewable_id'); $join->on('favourites.favouritable_type', '=', 'views.viewable_type'); $join->where('views.user_id', '=', user()->id); }) ->orderBy('views.views', 'desc') ->where('favourites.user_id', '=', user()->id); $favourites = $query ->skip($skip) ->take($count) ->get(); $this->listLoader->loadIntoRelations($favourites->all(), 'favouritable', false); return $favourites->pluck('favouritable')->filter(); } } ================================================ FILE: app/Entities/Repos/BaseRepo.php ================================================ refresh(); $entity->fill($input); $entity->forceFill([ 'created_by' => user()->id, 'updated_by' => user()->id, 'owned_by' => user()->id, ]); $this->refreshSlug($entity); if ($entity instanceof HasDescriptionInterface) { $this->updateDescription($entity, $input); } $entity->save(); if (isset($input['tags'])) { $this->tagRepo->saveTagsToEntity($entity, $input['tags']); } $entity->refresh(); $entity->rebuildPermissions(); $entity->indexForSearch(); $this->referenceStore->updateForEntity($entity); return $entity; } /** * Update the given entity. * @template T of Entity * @param T $entity * @return T */ public function update(Entity $entity, array $input): Entity { $oldUrl = $entity->getUrl(); $entity->fill($input); $entity->updated_by = user()->id; if ($entity->isDirty('name') || empty($entity->slug)) { $this->refreshSlug($entity); } if ($entity instanceof HasDescriptionInterface) { $this->updateDescription($entity, $input); } $entity->save(); if (isset($input['tags'])) { $this->tagRepo->saveTagsToEntity($entity, $input['tags']); $entity->touch(); } $entity->indexForSearch(); $this->referenceStore->updateForEntity($entity); if ($oldUrl !== $entity->getUrl()) { $this->referenceUpdater->updateEntityReferences($entity, $oldUrl); } return $entity; } /** * Update the given items' cover image or clear it. * * @throws ImageUploadException * @throws \Exception */ public function updateCoverImage(Entity&HasCoverInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false): void { if ($coverImage) { $imageType = 'cover_' . $entity->type; $this->imageRepo->destroyImage($entity->coverInfo()->getImage()); $image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true); $entity->coverInfo()->setImage($image); $entity->save(); } if ($removeImage) { $this->imageRepo->destroyImage($entity->coverInfo()->getImage()); $entity->coverInfo()->setImage(null); $entity->save(); } } /** * Sort the parent of the given entity if any auto sort actions are set for it. * Typically ran during create/update/insert events. */ public function sortParent(Entity $entity): void { if ($entity instanceof BookChild) { $book = $entity->book; $this->bookSorter->runBookAutoSort($book); } } /** * Update the description of the given entity from input data. */ protected function updateDescription(Entity $entity, array $input): void { if (!$entity instanceof HasDescriptionInterface) { return; } if (isset($input['description_html'])) { $entity->descriptionInfo()->set( HtmlDescriptionFilter::filterFromString($input['description_html']), html_entity_decode(strip_tags($input['description_html'])) ); } else if (isset($input['description'])) { $entity->descriptionInfo()->set('', $input['description']); } } /** * Refresh the slug for the given entity. */ public function refreshSlug(Entity $entity): void { $this->slugHistory->recordForEntity($entity); $this->slugGenerator->regenerateForEntity($entity); } } ================================================ FILE: app/Entities/Repos/BookRepo.php ================================================ baseRepo->create(new Book(), $input); $this->baseRepo->updateCoverImage($book, $input['image'] ?? null); $book->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null)); Activity::add(ActivityType::BOOK_CREATE, $book); $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) { $book->sort_rule_id = $defaultBookSortSetting; } $book->save(); return $book; }))->run(); } /** * Update the given book. */ public function update(Book $book, array $input): Book { $book = $this->baseRepo->update($book, $input); if (array_key_exists('default_template_id', $input)) { $book->defaultTemplate()->setFromId(intval($input['default_template_id'])); } if (array_key_exists('image', $input)) { $this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null); } $book->save(); Activity::add(ActivityType::BOOK_UPDATE, $book); return $book; } /** * Update the given book's cover image or clear it. * * @throws ImageUploadException * @throws Exception */ public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false): void { $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage); } /** * Remove a book from the system. * * @throws Exception */ public function destroy(Book $book): void { $this->trashCan->softDestroyBook($book); Activity::add(ActivityType::BOOK_DELETE, $book); $this->trashCan->autoClearOld(); } } ================================================ FILE: app/Entities/Repos/BookshelfRepo.php ================================================ baseRepo->create(new Bookshelf(), $input); $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null); $this->updateBooks($shelf, $bookIds); Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf); return $shelf; }))->run(); } /** * Update an existing shelf in the system using the given input. */ public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf { $shelf = $this->baseRepo->update($shelf, $input); if (!is_null($bookIds)) { $this->updateBooks($shelf, $bookIds); } if (array_key_exists('image', $input)) { $this->baseRepo->updateCoverImage($shelf, $input['image'], $input['image'] === null); } Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf); return $shelf; } /** * Update which books are assigned to this shelf by syncing the given book ids. * Function ensures the managed books are visible to the current user and existing, * and that the user does not alter the assignment of books that are not visible to them. */ protected function updateBooks(Bookshelf $shelf, array $bookIds): void { $numericIDs = collect($bookIds)->map(function ($id) { return intval($id); }); $existingBookIds = $shelf->books()->pluck('id')->toArray(); $visibleExistingBookIds = $this->bookQueries->visibleForList() ->whereIn('id', $existingBookIds) ->pluck('id') ->toArray(); $nonVisibleExistingBookIds = array_values(array_diff($existingBookIds, $visibleExistingBookIds)); $newIdsToAssign = $this->bookQueries->visibleForList() ->whereIn('id', $bookIds) ->pluck('id') ->toArray(); $maxNewIndex = max($numericIDs->keys()->toArray() ?: [0]); $syncData = []; foreach ($newIdsToAssign as $id) { $syncData[$id] = ['order' => $numericIDs->search($id)]; } foreach ($nonVisibleExistingBookIds as $index => $id) { $syncData[$id] = ['order' => $maxNewIndex + ($index + 1)]; } $shelf->books()->sync($syncData); } /** * Remove a bookshelf from the system. * * @throws Exception */ public function destroy(Bookshelf $shelf): void { $this->trashCan->softDestroyShelf($shelf); Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf); $this->trashCan->autoClearOld(); } } ================================================ FILE: app/Entities/Repos/ChapterRepo.php ================================================ book_id = $parentBook->id; $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1; $chapter = $this->baseRepo->create($chapter, $input); $chapter->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null)); $chapter->save(); Activity::add(ActivityType::CHAPTER_CREATE, $chapter); $this->baseRepo->sortParent($chapter); return $chapter; }))->run(); } /** * Update the given chapter. */ public function update(Chapter $chapter, array $input): Chapter { $chapter = $this->baseRepo->update($chapter, $input); if (array_key_exists('default_template_id', $input)) { $chapter->defaultTemplate()->setFromId(intval($input['default_template_id'])); } $chapter->save(); Activity::add(ActivityType::CHAPTER_UPDATE, $chapter); $this->baseRepo->sortParent($chapter); return $chapter; } /** * Remove a chapter from the system. * * @throws Exception */ public function destroy(Chapter $chapter): void { $this->trashCan->softDestroyChapter($chapter); Activity::add(ActivityType::CHAPTER_DELETE, $chapter); $this->trashCan->autoClearOld(); } /** * Move the given chapter into a new parent book. * The $parentIdentifier must be a string of the following format: * 'book:' (book:5). * * @throws MoveOperationException * @throws PermissionsException */ public function move(Chapter $chapter, string $parentIdentifier): Book { $parent = $this->entityQueries->findVisibleByStringIdentifier($parentIdentifier); if (!$parent instanceof Book) { throw new MoveOperationException('Book to move chapter into not found'); } if (!userCan(Permission::ChapterCreate, $parent)) { throw new PermissionsException('User does not have permission to create a chapter within the chosen book'); } return (new DatabaseTransaction(function () use ($chapter, $parent) { $this->parentChanger->changeBook($chapter, $parent->id); $chapter->rebuildPermissions(); Activity::add(ActivityType::CHAPTER_MOVE, $chapter); $this->baseRepo->sortParent($chapter); return $parent; }))->run(); } } ================================================ FILE: app/Entities/Repos/DeletionRepo.php ================================================ findOrFail($id); Activity::add(ActivityType::RECYCLE_BIN_RESTORE, $deletion); return $this->trashCan->restoreFromDeletion($deletion); } public function destroy(int $id): int { /** @var Deletion $deletion */ $deletion = Deletion::query()->findOrFail($id); Activity::add(ActivityType::RECYCLE_BIN_DESTROY, $deletion); return $this->trashCan->destroyFromDeletion($deletion); } } ================================================ FILE: app/Entities/Repos/PageRepo.php ================================================ forceFill([ 'name' => trans('entities.pages_initial_name'), 'created_by' => user()->id, 'owned_by' => user()->id, 'updated_by' => user()->id, 'draft' => true, 'editor' => PageEditorType::getSystemDefault()->value, 'html' => '', 'markdown' => '', 'text' => '', ]); if ($parent instanceof Chapter) { $page->chapter_id = $parent->id; $page->book_id = $parent->book_id; } else { $page->book_id = $parent->id; } $defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get(); if ($defaultTemplate) { $page->forceFill([ 'html' => $defaultTemplate->html, 'markdown' => $defaultTemplate->markdown, ]); $page->text = (new PageContent($page))->toPlainText(); } (new DatabaseTransaction(function () use ($page) { $page->save(); $page->rebuildPermissions(); }))->run(); return $page; } /** * Publish a draft page to make it a live, non-draft page. */ public function publishDraft(Page $draft, array $input): Page { return (new DatabaseTransaction(function () use ($draft, $input) { $draft->draft = false; $draft->revision_count = 1; $draft->priority = $this->getNewPriority($draft); $this->updateTemplateStatusAndContentFromInput($draft, $input); $draft = $this->baseRepo->update($draft, $input); $draft->rebuildPermissions(); $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision'); $this->revisionRepo->storeNewForPage($draft, $summary); $draft->refresh(); Activity::add(ActivityType::PAGE_CREATE, $draft); $this->baseRepo->sortParent($draft); return $draft; }))->run(); } /** * Directly update the content for the given page from the provided input. * Used for direct content access in a way that performs required changes * (Search index and reference regen) without performing an official update. */ public function setContentFromInput(Page $page, array $input): void { $this->updateTemplateStatusAndContentFromInput($page, $input); $this->baseRepo->update($page, []); } /** * Update a page in the system. */ public function update(Page $page, array $input): Page { // Hold the old details to compare later $oldName = $page->name; $oldHtml = $page->html; $oldMarkdown = $page->markdown; $this->updateTemplateStatusAndContentFromInput($page, $input); $page = $this->baseRepo->update($page, $input); // Update with new details $page->revision_count++; $page->save(); // Remove all update drafts for this user and page. $this->revisionRepo->deleteDraftsForCurrentUser($page); // Save a revision after updating $summary = trim($input['summary'] ?? ''); $htmlChanged = isset($input['html']) && $input['html'] !== $oldHtml; $nameChanged = isset($input['name']) && $input['name'] !== $oldName; $markdownChanged = isset($input['markdown']) && $input['markdown'] !== $oldMarkdown; if ($htmlChanged || $nameChanged || $markdownChanged || $summary) { $this->revisionRepo->storeNewForPage($page, $summary); } Activity::add(ActivityType::PAGE_UPDATE, $page); $this->baseRepo->sortParent($page); return $page; } protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void { if (isset($input['template']) && userCan(Permission::TemplatesManage)) { $page->template = ($input['template'] === 'true'); } $pageContent = new PageContent($page); $defaultEditor = PageEditorType::getSystemDefault(); $currentEditor = PageEditorType::forPage($page) ?: $defaultEditor; $inputEditor = PageEditorType::fromRequestValue($input['editor'] ?? '') ?? $currentEditor; $newEditor = $currentEditor; $haveInput = isset($input['markdown']) || isset($input['html']); $inputEmpty = empty($input['markdown']) && empty($input['html']); if ($haveInput && $inputEmpty) { $pageContent->setNewHTML('', user()); } elseif (!empty($input['markdown']) && is_string($input['markdown'])) { $newEditor = PageEditorType::Markdown; $pageContent->setNewMarkdown($input['markdown'], user()); } elseif (isset($input['html'])) { $newEditor = ($inputEditor->isHtmlBased() ? $inputEditor : null) ?? ($defaultEditor->isHtmlBased() ? $defaultEditor : null) ?? PageEditorType::WysiwygTinymce; $pageContent->setNewHTML($input['html'], user()); } if (($newEditor !== $currentEditor || empty($page->editor)) && userCan(Permission::EditorChange)) { $page->editor = $newEditor->value; } elseif (empty($page->editor)) { $page->editor = $defaultEditor->value; } } /** * Save a page update draft. */ public function updatePageDraft(Page $page, array $input): Page|PageRevision { // If the page itself is a draft, simply update that if ($page->draft) { $this->updateTemplateStatusAndContentFromInput($page, $input); $page->forceFill(array_intersect_key($input, array_flip(['name'])))->save(); $page->save(); return $page; } // Otherwise, save the data to a revision $draft = $this->revisionRepo->getNewDraftForCurrentUser($page); $draft->fill($input); if (!empty($input['markdown'])) { $draft->markdown = $input['markdown']; $draft->html = ''; } else { $draft->html = $input['html']; $draft->markdown = ''; } $draft->save(); return $draft; } /** * Destroy a page from the system. * * @throws Exception */ public function destroy(Page $page): void { $this->trashCan->softDestroyPage($page); Activity::add(ActivityType::PAGE_DELETE, $page); $this->trashCan->autoClearOld(); } /** * Restores a revision's content back into a page. */ public function restoreRevision(Page $page, int $revisionId): Page { $oldUrl = $page->getUrl(); $page->revision_count++; /** @var PageRevision $revision */ $revision = $page->revisions()->where('id', '=', $revisionId)->first(); $page->fill($revision->toArray()); $content = new PageContent($page); if (!empty($revision->markdown)) { $content->setNewMarkdown($revision->markdown, user()); } else { $content->setNewHTML($revision->html, user()); } $page->updated_by = user()->id; $this->baseRepo->refreshSlug($page); $page->save(); $page->indexForSearch(); $this->referenceStore->updateForEntity($page); $summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]); $this->revisionRepo->storeNewForPage($page, $summary); if ($oldUrl !== $page->getUrl()) { $this->referenceUpdater->updateEntityReferences($page, $oldUrl); } Activity::add(ActivityType::PAGE_RESTORE, $page); Activity::add(ActivityType::REVISION_RESTORE, $revision); $this->baseRepo->sortParent($page); return $page; } /** * Move the given page into a new parent book or chapter. * The $parentIdentifier must be a string of the following format: * 'book:' (book:5). * * @throws MoveOperationException * @throws PermissionsException */ public function move(Page $page, string $parentIdentifier): Entity { $parent = $this->entityQueries->findVisibleByStringIdentifier($parentIdentifier); if (!$parent instanceof Chapter && !$parent instanceof Book) { throw new MoveOperationException('Book or chapter to move page into not found'); } if (!userCan(Permission::PageCreate, $parent)) { throw new PermissionsException('User does not have permission to create a page within the new parent'); } return (new DatabaseTransaction(function () use ($page, $parent) { $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null; $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id; $this->parentChanger->changeBook($page, $newBookId); $page->rebuildPermissions(); Activity::add(ActivityType::PAGE_MOVE, $page); $this->baseRepo->sortParent($page); return $parent; }))->run(); } /** * Get a new priority for a page. */ protected function getNewPriority(Page $page): int { $parent = $page->getParent(); if ($parent instanceof Chapter) { /** @var ?Page $lastPage */ $lastPage = $parent->pages('desc')->first(); return $lastPage ? $lastPage->priority + 1 : 0; } return (new BookContents($page->book))->getLastPriority() + 1; } } ================================================ FILE: app/Entities/Repos/RevisionRepo.php ================================================ queries->latestCurrentUserDraftsForPageId($page->id)->delete(); } /** * Get a user update_draft page revision to update for the given page. * Checks for an existing revision before providing a fresh one. */ public function getNewDraftForCurrentUser(Page $page): PageRevision { $draft = $this->queries->findLatestCurrentUserDraftsForPageId($page->id); if ($draft) { return $draft; } $draft = new PageRevision(); $draft->page_id = $page->id; $draft->slug = $page->slug; $draft->book_slug = $page->book->slug; $draft->created_by = user()->id; $draft->type = 'update_draft'; return $draft; } /** * Store a new revision in the system for the given page. */ public function storeNewForPage(Page $page, ?string $summary = null): PageRevision { $revision = new PageRevision(); $revision->name = $page->name; $revision->html = $page->html; $revision->markdown = $page->markdown; $revision->text = $page->text; $revision->page_id = $page->id; $revision->slug = $page->slug; $revision->book_slug = $page->book->slug; $revision->created_by = user()->id; $revision->created_at = $page->updated_at; $revision->type = 'version'; $revision->summary = $summary; $revision->revision_number = $page->revision_count; $revision->save(); $this->deleteOldRevisions($page); return $revision; } /** * Delete old revisions, for the given page, from the system. */ protected function deleteOldRevisions(Page $page): void { $revisionLimit = config('app.revision_limit'); if ($revisionLimit === false) { return; } $revisionsToDelete = PageRevision::query() ->where('page_id', '=', $page->id) ->orderBy('created_at', 'desc') ->skip(intval($revisionLimit)) ->take(10) ->get(['id']); if ($revisionsToDelete->count() > 0) { PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete(); } } } ================================================ FILE: app/Entities/Tools/BookContents.php ================================================ queries = app()->make(EntityQueries::class); } /** * Get the current priority of the last item at the top-level of the book. */ public function getLastPriority(): int { $maxPage = $this->book->pages() ->where('draft', '=', false) ->whereDoesntHave('chapter') ->max('priority'); $maxChapter = $this->book->chapters() ->max('priority'); return max($maxChapter, $maxPage, 1); } /** * Get the contents as a sorted collection tree. */ public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection { $pages = $this->getPages($showDrafts, $renderPages); $chapters = $this->book->chapters()->scopes('visible')->get(); $all = collect()->concat($pages)->concat($chapters); $chapterMap = $chapters->keyBy('id'); $lonePages = collect(); $pages->groupBy('chapter_id')->each(function ($pages, $chapter_id) use ($chapterMap, &$lonePages) { $chapter = $chapterMap->get($chapter_id); if ($chapter) { $chapter->setAttribute('visible_pages', collect($pages)->sortBy($this->bookChildSortFunc())); } else { $lonePages = $lonePages->concat($pages); } }); $chapters->whereNull('visible_pages')->each(function (Chapter $chapter) { $chapter->setAttribute('visible_pages', collect([])); }); $all->each(function (Entity $entity) use ($renderPages) { $entity->setRelation('book', $this->book); if ($renderPages && $entity instanceof Page) { $entity->html = (new PageContent($entity))->render(); } }); return collect($chapters)->concat($lonePages)->sortBy($this->bookChildSortFunc()); } /** * Function for providing a sorting score for an entity in relation to the * other items within the book. */ protected function bookChildSortFunc(): callable { return function (Entity $entity) { if ($entity->getAttribute('draft') ?? false) { return -100; } return $entity->getAttribute('priority') ?? 0; }; } /** * Get the visible pages within this book. */ protected function getPages(bool $showDrafts = false, bool $getPageContent = false): Collection { if ($getPageContent) { $query = $this->queries->pages->visibleWithContents(); } else { $query = $this->queries->pages->visibleForList(); } if (!$showDrafts) { $query->where('draft', '=', false); } return $query->where('book_id', '=', $this->book->id)->get(); } } ================================================ FILE: app/Entities/Tools/Cloner.php ================================================ referenceChangeContext = new ReferenceChangeContext(); } /** * Clone the given page into the given parent using the provided name. */ public function clonePage(Page $original, Entity $parent, string $newName): Page { $context = $this->newReferenceChangeContext(); $page = $this->createPageClone($original, $parent, $newName); $this->referenceUpdater->changeReferencesUsingContext($context); return $page; } protected function createPageClone(Page $original, Entity $parent, string $newName): Page { $copyPage = $this->pageRepo->getNewDraftPage($parent); $pageData = $this->entityToInputData($original); $pageData['name'] = $newName; $newPage = $this->pageRepo->publishDraft($copyPage, $pageData); $this->referenceChangeContext->add($original, $newPage); return $newPage; } /** * Clone the given page into the given parent using the provided name. * Clones all child pages. */ public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter { $context = $this->newReferenceChangeContext(); $chapter = $this->createChapterClone($original, $parent, $newName); $this->referenceUpdater->changeReferencesUsingContext($context); return $chapter; } protected function createChapterClone(Chapter $original, Book $parent, string $newName): Chapter { $chapterDetails = $this->entityToInputData($original); $chapterDetails['name'] = $newName; $copyChapter = $this->chapterRepo->create($chapterDetails, $parent); if (userCan(Permission::PageCreate, $copyChapter)) { /** @var Page $page */ foreach ($original->getVisiblePages() as $page) { $this->createPageClone($page, $copyChapter, $page->name); } } $this->referenceChangeContext->add($original, $copyChapter); return $copyChapter; } /** * Clone the given book. * Clones all child chapters and pages. */ public function cloneBook(Book $original, string $newName): Book { $context = $this->newReferenceChangeContext(); $book = $this->createBookClone($original, $newName); $this->referenceUpdater->changeReferencesUsingContext($context); return $book; } protected function createBookClone(Book $original, string $newName): Book { $bookDetails = $this->entityToInputData($original); $bookDetails['name'] = $newName; // Clone book $copyBook = $this->bookRepo->create($bookDetails); // Clone contents $directChildren = $original->getDirectVisibleChildren(); foreach ($directChildren as $child) { if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) { $this->createChapterClone($child, $copyBook, $child->name); } if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) { $this->createPageClone($child, $copyBook, $child->name); } } // Clone bookshelf relationships /** @var Bookshelf $shelf */ foreach ($original->shelves as $shelf) { if (userCan(Permission::BookshelfUpdate, $shelf)) { $shelf->appendBook($copyBook); } } $this->referenceChangeContext->add($original, $copyBook); return $copyBook; } /** * Convert an entity to a raw data array of input data. * * @return array */ public function entityToInputData(Entity $entity): array { $inputData = $entity->getAttributes(); $inputData['tags'] = $this->entityTagsToInputArray($entity); // Add a cover to the data if existing on the original entity if ($entity instanceof HasCoverInterface) { $cover = $entity->coverInfo()->getImage(); if ($cover) { $inputData['image'] = $this->imageToUploadedFile($cover); } } return $inputData; } /** * Copy the permission settings from the source entity to the target entity. */ public function copyEntityPermissions(Entity $sourceEntity, Entity $targetEntity): void { $permissions = $sourceEntity->permissions()->get(['role_id', 'view', 'create', 'update', 'delete'])->toArray(); $targetEntity->permissions()->delete(); $targetEntity->permissions()->createMany($permissions); $targetEntity->rebuildPermissions(); } /** * Convert an image instance to an UploadedFile instance to mimic * a file being uploaded. */ protected function imageToUploadedFile(Image $image): ?UploadedFile { $imgData = $this->imageService->getImageData($image); $tmpImgFilePath = tempnam(sys_get_temp_dir(), 'bs_cover_clone_'); file_put_contents($tmpImgFilePath, $imgData); return new UploadedFile($tmpImgFilePath, basename($image->path)); } /** * Convert the tags on the given entity to the raw format * that's used for incoming request data. */ protected function entityTagsToInputArray(Entity $entity): array { $tags = []; /** @var Tag $tag */ foreach ($entity->tags as $tag) { $tags[] = ['name' => $tag->name, 'value' => $tag->value]; } return $tags; } protected function newReferenceChangeContext(): ReferenceChangeContext { $this->referenceChangeContext = new ReferenceChangeContext(); return $this->referenceChangeContext; } } ================================================ FILE: app/Entities/Tools/EntityCover.php ================================================ where('id', '=', $this->entity->image_id); } /** * Check if a cover image exists for this entity. */ public function exists(): bool { return $this->entity->image_id !== null && $this->imageQuery()->exists(); } /** * Get the assigned cover image model. */ public function getImage(): Image|null { if ($this->entity->image_id === null) { return null; } $cover = $this->imageQuery()->first(); if ($cover instanceof Image) { return $cover; } return null; } /** * Returns a cover image URL, or the given default if none assigned/existing. */ public function getUrl(int $width = 440, int $height = 250, string|null $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='): string|null { if (!$this->entity->image_id) { return $default; } try { return $this->getImage()?->getThumb($width, $height, false) ?? $default; } catch (Exception $err) { return $default; } } /** * Set the image to use as the cover for this entity. */ public function setImage(Image|null $image): void { if ($image === null) { $this->entity->image_id = null; } else { $this->entity->image_id = $image->id; } } } ================================================ FILE: app/Entities/Tools/EntityDefaultTemplate.php ================================================ entity->default_template_id); if (!$changing) { return; } if ($templateId === 0) { $this->entity->default_template_id = null; return; } $pageQueries = app()->make(PageQueries::class); $templateExists = $pageQueries->visibleTemplates() ->where('id', '=', $templateId) ->exists(); $this->entity->default_template_id = $templateExists ? $templateId : null; } /** * Get the default template for this entity (if visible). */ public function get(): Page|null { if (!$this->entity->default_template_id) { return null; } $pageQueries = app()->make(PageQueries::class); $page = $pageQueries->visibleTemplates(true) ->where('id', '=', $this->entity->default_template_id) ->first(); if ($page instanceof Page) { return $page; } return null; } } ================================================ FILE: app/Entities/Tools/EntityHtmlDescription.php ================================================ html = $this->entity->description_html ?? ''; $this->plain = $this->entity->description ?? ''; } /** * Update the description from HTML code. * Optionally takes plaintext to use for the model also. */ public function set(string $html, string|null $plaintext = null): void { $this->html = $html; $this->entity->description_html = $this->html; if ($plaintext !== null) { $this->plain = $plaintext; $this->entity->description = $this->plain; } if (empty($html) && !empty($plaintext)) { $this->html = $this->getHtml(); $this->entity->description_html = $this->html; } } /** * Get the description as HTML. * Optionally returns the raw HTML if requested. */ public function getHtml(bool $raw = false): string { $html = $this->html ?: '

' . nl2br(e($this->plain)) . '

'; if ($raw) { return $html; } $isEmpty = empty(trim(strip_tags($html))); if ($isEmpty) { return '

'; } $filter = new HtmlContentFilter(new HtmlContentFilterConfig()); return $filter->filterString($html); } public function getPlain(): string { return $this->plain; } } ================================================ FILE: app/Entities/Tools/EntityHydrator.php ================================================ getRawOriginal(); $instance = Entity::instanceFromType($entity->type); if ($instance instanceof Page) { $data['text'] = $data['description']; unset($data['description']); } $instance = $instance->setRawAttributes($data, true); $hydrated[] = $instance; } if ($loadTags) { $this->loadTagsIntoModels($hydrated); } if ($loadParents) { $this->loadParentsIntoModels($hydrated); } return $hydrated; } /** * @param Entity[] $entities */ protected function loadTagsIntoModels(array $entities): void { $idsByType = []; $entityMap = []; foreach ($entities as $entity) { if (!isset($idsByType[$entity->type])) { $idsByType[$entity->type] = []; } $idsByType[$entity->type][] = $entity->id; $entityMap[$entity->type . ':' . $entity->id] = $entity; } $query = Tag::query(); foreach ($idsByType as $type => $ids) { $query->orWhere(function ($query) use ($type, $ids) { $query->where('entity_type', '=', $type) ->whereIn('entity_id', $ids); }); } $tags = empty($idsByType) ? [] : $query->get()->all(); $tagMap = []; foreach ($tags as $tag) { $key = $tag->entity_type . ':' . $tag->entity_id; if (!isset($tagMap[$key])) { $tagMap[$key] = []; } $tagMap[$key][] = $tag; } foreach ($entityMap as $key => $entity) { $entityTags = new Collection($tagMap[$key] ?? []); $entity->setRelation('tags', $entityTags); } } /** * @param Entity[] $entities */ protected function loadParentsIntoModels(array $entities): void { $parentsByType = ['book' => [], 'chapter' => []]; foreach ($entities as $entity) { if ($entity->getAttribute('book_id') !== null) { $parentsByType['book'][] = $entity->getAttribute('book_id'); } if ($entity->getAttribute('chapter_id') !== null) { $parentsByType['chapter'][] = $entity->getAttribute('chapter_id'); } } $parentQuery = $this->entityQueries->visibleForList(); $filtered = count($parentsByType['book']) > 0 || count($parentsByType['chapter']) > 0; $parentQuery = $parentQuery->where(function ($query) use ($parentsByType) { foreach ($parentsByType as $type => $ids) { if (count($ids) > 0) { $query = $query->orWhere(function ($query) use ($type, $ids) { $query->where('type', '=', $type) ->whereIn('id', $ids); }); } } }); $parentModels = $filtered ? $parentQuery->get()->all() : []; $parents = $this->hydrate($parentModels); $parentMap = []; foreach ($parents as $parent) { $parentMap[$parent->type . ':' . $parent->id] = $parent; } foreach ($entities as $entity) { if ($entity instanceof Page || $entity instanceof Chapter) { $key = 'book:' . $entity->getRawAttribute('book_id'); $entity->setRelation('book', $parentMap[$key] ?? null); } if ($entity instanceof Page) { $key = 'chapter:' . $entity->getRawAttribute('chapter_id'); $entity->setRelation('chapter', $parentMap[$key] ?? null); } } } } ================================================ FILE: app/Entities/Tools/HierarchyTransformer.php ================================================ cloner->entityToInputData($chapter); $book = $this->bookRepo->create($inputData); $this->cloner->copyEntityPermissions($chapter, $book); /** @var Page $page */ foreach ($chapter->pages as $page) { $page->chapter_id = 0; $page->save(); $this->parentChanger->changeBook($page, $book->id); } $this->trashCan->destroyEntity($chapter); Activity::add(ActivityType::BOOK_CREATE_FROM_CHAPTER, $book); return $book; } /** * Transform a book into a shelf. * Does not check permissions, check before calling. */ public function transformBookToShelf(Book $book): Bookshelf { $inputData = $this->cloner->entityToInputData($book); $shelf = $this->shelfRepo->create($inputData, []); $this->cloner->copyEntityPermissions($book, $shelf); $shelfBookSyncData = []; /** @var Chapter $chapter */ foreach ($book->chapters as $index => $chapter) { $newBook = $this->transformChapterToBook($chapter); $shelfBookSyncData[$newBook->id] = ['order' => $index]; if (!$newBook->hasPermissions()) { $this->cloner->copyEntityPermissions($shelf, $newBook); } } if ($book->directPages->count() > 0) { $book->name .= ' ' . trans('entities.pages'); $shelfBookSyncData[$book->id] = ['order' => count($shelfBookSyncData) + 1]; $book->save(); } else { $this->trashCan->destroyEntity($book); } $shelf->books()->sync($shelfBookSyncData); Activity::add(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $shelf); return $shelf; } } ================================================ FILE: app/Entities/Tools/Markdown/CheckboxConverter.php ================================================ getAttribute('type')) === 'checkbox') { $isChecked = $element->getAttribute('checked') === 'checked'; return $isChecked ? ' [x] ' : ' [ ] '; } return $element->getValue(); } /** * @return string[] */ public function getSupportedTags(): array { return ['input']; } } ================================================ FILE: app/Entities/Tools/Markdown/CustomDivConverter.php ================================================ getAttribute('drawio-diagram'); if ($drawIoDiagram) { return "
{$element->getValue()}
\n\n"; } return parent::convert($element); } } ================================================ FILE: app/Entities/Tools/Markdown/CustomImageConverter.php ================================================ getParent(); // Remain as HTML if within diagram block. $withinDrawing = $parent && !empty($parent->getAttribute('drawio-diagram')); if ($withinDrawing) { $src = e($element->getAttribute('src')); $alt = e($element->getAttribute('alt')); return "\"{$alt}\"/"; } return parent::convert($element); } } ================================================ FILE: app/Entities/Tools/Markdown/CustomListItemRenderer.php ================================================ baseRenderer = new ListItemRenderer(); } /** * @return HtmlElement|string|null */ public function render(Node $node, ChildNodeRendererInterface $childRenderer) { $listItem = $this->baseRenderer->render($node, $childRenderer); if ($node instanceof ListItem && $this->startsTaskListItem($node) && $listItem instanceof HtmlElement) { $listItem->setAttribute('class', 'task-list-item'); } return $listItem; } private function startsTaskListItem(ListItem $block): bool { $firstChild = $block->firstChild(); return $firstChild instanceof Paragraph && $firstChild->firstChild() instanceof TaskListItemMarker; } } ================================================ FILE: app/Entities/Tools/Markdown/CustomParagraphConverter.php ================================================ getAttribute('class')); if (strpos($class, 'callout') !== false) { return "<{$element->getTagName()} class=\"{$class}\">{$element->getValue()}getTagName()}>\n\n"; } return parent::convert($element); } } ================================================ FILE: app/Entities/Tools/Markdown/CustomStrikeThroughExtension.php ================================================ addDelimiterProcessor(new StrikethroughDelimiterProcessor()); $environment->addRenderer(Strikethrough::class, new CustomStrikethroughRenderer()); } } ================================================ FILE: app/Entities/Tools/Markdown/CustomStrikethroughRenderer.php ================================================ HTML tags instead of in order to * match front-end markdown-it rendering. */ class CustomStrikethroughRenderer implements NodeRendererInterface { public function render(Node $node, ChildNodeRendererInterface $childRenderer) { Strikethrough::assertInstanceOf($node); return new HtmlElement('s', $node->data->get('attributes'), $childRenderer->renderNodes($node->children())); } } ================================================ FILE: app/Entities/Tools/Markdown/HtmlToMarkdown.php ================================================ html = $html; } /** * Run the conversion. */ public function convert(): string { $converter = new HtmlConverter($this->getConverterEnvironment()); $html = $this->prepareHtml($this->html); return $converter->convert($html); } /** * Run any pre-processing to the HTML to clean it up manually before conversion. */ protected function prepareHtml(string $html): string { // Carriage returns can cause whitespace issues in output $html = str_replace("\r\n", "\n", $html); // Attributes on the pre tag can cause issues with conversion return preg_replace('/
/', '
', $html);
    }

    /**
     * Get the HTML to Markdown customized environment.
     * Extends the default provided environment with some BookStack specific tweaks.
     */
    protected function getConverterEnvironment(): Environment
    {
        $environment = new Environment([
            'header_style'            => 'atx', // Set to 'atx' to output H1 and H2 headers as # Header1 and ## Header2
            'suppress_errors'         => true, // Set to false to show warnings when loading malformed HTML
            'strip_tags'              => false, // Set to true to strip tags that don't have markdown equivalents. N.B. Strips tags, not their content. Useful to clean MS Word HTML output.
            'strip_placeholder_links' => false, // Set to true to remove  that doesn't have href.
            'bold_style'              => '**', // DEPRECATED: Set to '__' if you prefer the underlined style
            'italic_style'            => '*', // DEPRECATED: Set to '_' if you prefer the underlined style
            'remove_nodes'            => '', // space-separated list of dom nodes that should be removed. example: 'meta style script'
            'hard_break'              => false, // Set to true to turn 
into `\n` instead of ` \n` 'list_item_style' => '-', // Set the default character for each
  • in a