diff --git a/.env.example b/.env.example index f5e81277ced..13114b8b076 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,24 @@ +# This file, when named as ".env" in the root of your BookStack install +# folder, is used for the core configuration of the application. +# By default this file contains the most common required options but +# a full list of options can be found in the '.env.example.complete' file. + +# NOTE: If any of your values contain a space or a hash you will need to +# wrap the entire value in quotes. (eg. MAIL_FROM_NAME="BookStack Mailer") + # Application key # Used for encryption where needed. # Run `php artisan key:generate` to generate a valid key. APP_KEY=SomeRandomString # Application URL -# Remove the hash below and set a URL if using BookStack behind -# a proxy, if using a third-party authentication option. # This must be the root URL that you want to host BookStack on. -# All URL's in BookStack will be generated using this value. -#APP_URL=https://example.com +# All URLs in BookStack will be generated using this value +# to ensure URLs generated are consistent and secure. +# If you change this in the future you may need to run a command +# to update stored URLs in the database. Command example: +# php artisan bookstack:update-url https://old.example.com https://new.example.com +APP_URL=https://example.com # Database details DB_HOST=localhost @@ -16,20 +26,28 @@ DB_DATABASE=database_database DB_USERNAME=database_username DB_PASSWORD=database_user_password +# Storage system to use +# By default files are stored on the local filesystem, with images being placed in +# public web space so they can be efficiently served directly by the web-server. +# For other options with different security levels & considerations, refer to: +# https://www.bookstackapp.com/docs/admin/upload-config/ +STORAGE_TYPE=local + # Mail system to use # Can be 'smtp' or 'sendmail' MAIL_DRIVER=smtp -# Mail sender options -MAIL_FROM_NAME=BookStack +# Mail sender details +MAIL_FROM_NAME="BookStack" MAIL_FROM=bookstack@example.com # SMTP mail options +# These settings can be checked using the "Send a Test Email" +# feature found in the "Settings > Maintenance" area of the system. +# For more detailed documentation on mail options, refer to: +# https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration MAIL_HOST=localhost -MAIL_PORT=1025 +MAIL_PORT=587 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null - - -# A full list of options can be found in the '.env.example.complete' file. \ No newline at end of file diff --git a/.env.example.complete b/.env.example.complete index 472ca051b33..6c773f601f1 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -3,6 +3,10 @@ # Each option is shown with it's default value. # Do not copy this whole file to use as your '.env' file. +# The details here only serve as a quick reference. +# Please refer to the BookStack documentation for full details: +# https://www.bookstackapp.com/docs/ + # Application environment # Can be 'production', 'development', 'testing' or 'demo' APP_ENV=production @@ -32,38 +36,58 @@ APP_LANG=en # APP_LANG will be used if such a header is not provided. APP_AUTO_LANG_PUBLIC=true -# Application timezone -# Used where dates are displayed such as on exported content. +# Application timezones +# The first option is used to determine what timezone is used for date storage. +# Leaving that as "UTC" is advised. +# The second option is used to set the timezone which will be used for date +# formatting and display. This defaults to the "APP_TIMEZONE" value. # Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php APP_TIMEZONE=UTC +APP_DISPLAY_TIMEZONE=UTC # Application theme # Used to specific a themes/ folder where BookStack UI # overrides can be made. Defaults to disabled. APP_THEME=false +# Trusted proxies +# Used to indicate trust of systems that proxy to the application so +# certain header values (Such as "X-Forwarded-For") can be used from the +# incoming proxy request to provide origin detail. +# Set to an IP address, or multiple comma seperated IP addresses. +# Can alternatively be set to "*" to trust all proxy addresses. +APP_PROXIES=null + # Database details # Host can contain a port (localhost:3306) or a separate DB_PORT option can be used. +# An ipv6 address can be used via the square bracket format ([::1]). DB_HOST=localhost DB_PORT=3306 DB_DATABASE=database_database DB_USERNAME=database_username DB_PASSWORD=database_user_password -# Mail system to use -# Can be 'smtp', 'mail' or 'sendmail' -MAIL_DRIVER=smtp +# MySQL specific connection options +# Path to Certificate Authority (CA) certificate file for your MySQL instance. +# When this option is used host name identity verification will be performed +# which checks the hostname, used by the client, against names within the +# certificate itself (Common Name or Subject Alternative Name). +MYSQL_ATTR_SSL_CA="/path/to/ca.pem" -# Mail sending options -MAIL_FROM=mail@bookstackapp.com +# Mail configuration +# Refer to https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration +MAIL_DRIVER=smtp +MAIL_FROM=bookstack@example.com MAIL_FROM_NAME=BookStack -# SMTP mail options MAIL_HOST=localhost -MAIL_PORT=1025 +MAIL_PORT=587 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null +MAIL_VERIFY_SSL=true + +MAIL_SENDMAIL_COMMAND="/usr/sbin/sendmail -bs" # Cache & Session driver to use # Can be 'file', 'database', 'memcached' or 'redis' @@ -92,8 +116,7 @@ MEMCACHED_SERVERS=127.0.0.1:11211:100 REDIS_SERVERS=127.0.0.1:6379:0 # Queue driver to use -# Queue not really currently used but may be configurable in the future. -# Would advise not to change this for now. +# Can be 'sync', 'database' or 'redis' QUEUE_CONNECTION=sync # Storage system to use @@ -126,9 +149,13 @@ STORAGE_S3_ENDPOINT=https://my-custom-s3-compatible.service.com:8001 STORAGE_URL=false # Authentication method to use -# Can be 'standard', 'ldap' or 'saml2' +# Can be 'standard', 'ldap', 'saml2' or 'oidc' AUTH_METHOD=standard +# Automatically initiate login via external auth system if it's the only auth method. +# Works with saml2 or oidc auth methods. +AUTH_AUTO_INITIATE=false + # Social authentication configuration # All disabled by default. # Refer to https://www.bookstackapp.com/docs/admin/third-party-auth/ @@ -193,12 +220,15 @@ LDAP_SERVER=false LDAP_BASE_DN=false LDAP_DN=false LDAP_PASS=false -LDAP_USER_FILTER=false +LDAP_USER_FILTER="(&(uid={user}))" LDAP_VERSION=false +LDAP_START_TLS=false LDAP_TLS_INSECURE=false +LDAP_TLS_CA_CERT=false LDAP_ID_ATTRIBUTE=uid LDAP_EMAIL_ATTRIBUTE=mail LDAP_DISPLAY_NAME_ATTRIBUTE=cn +LDAP_THUMBNAIL_ATTRIBUTE=null LDAP_FOLLOW_REFERRALS=true LDAP_DUMP_USER_DETAILS=false @@ -207,6 +237,7 @@ LDAP_DUMP_USER_DETAILS=false LDAP_USER_TO_GROUPS=false LDAP_GROUP_ATTRIBUTE="memberOf" LDAP_REMOVE_FROM_GROUPS=false +LDAP_DUMP_USER_GROUPS=false # SAML authentication configuration # Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/ @@ -221,6 +252,9 @@ SAML2_IDP_x509=null SAML2_ONELOGIN_OVERRIDES=null SAML2_DUMP_USER_DETAILS=false SAML2_AUTOLOAD_METADATA=false +SAML2_IDP_AUTHNCONTEXT=true +SAML2_SP_x509=null +SAML2_SP_x509_KEY=null # SAML group sync configuration # Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/ @@ -228,6 +262,26 @@ SAML2_USER_TO_GROUPS=false SAML2_GROUP_ATTRIBUTE=group SAML2_REMOVE_FROM_GROUPS=false +# OpenID Connect authentication configuration +# Refer to https://www.bookstackapp.com/docs/admin/oidc-auth/ +OIDC_NAME=SSO +OIDC_DISPLAY_NAME_CLAIMS=name +OIDC_CLIENT_ID=null +OIDC_CLIENT_SECRET=null +OIDC_ISSUER=null +OIDC_ISSUER_DISCOVER=false +OIDC_PUBLIC_KEY=null +OIDC_AUTH_ENDPOINT=null +OIDC_TOKEN_ENDPOINT=null +OIDC_USERINFO_ENDPOINT=null +OIDC_ADDITIONAL_SCOPES=null +OIDC_DUMP_USER_DETAILS=false +OIDC_USER_TO_GROUPS=false +OIDC_GROUPS_CLAIM=groups +OIDC_REMOVE_FROM_GROUPS=false +OIDC_EXTERNAL_ID_CLAIM=sub +OIDC_END_SESSION_ENDPOINT=false + # Disable default third-party services such as Gravatar and Draw.IO # Service-specific options will override this option DISABLE_EXTERNAL_SERVICES=false @@ -238,36 +292,150 @@ DISABLE_EXTERNAL_SERVICES=false # Example: AVATAR_URL=https://seccdn.libravatar.org/avatar/${hash}?s=${size}&d=identicon AVATAR_URL= -# Enable draw.io integration +# Enable diagrams.net integration # Can simply be true/false to enable/disable the integration. -# Alternatively, It can be URL to the draw.io instance you want to use. -# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1 +# Alternatively, It can be URL to the diagrams.net instance you want to use. +# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1&configure=1 DRAWIO=true # Default item listing view -# Used for public visitors and user's without a preference -# Can be 'list' or 'grid' +# Used for public visitors and user's without a preference. +# Can be 'list' or 'grid'. APP_VIEWS_BOOKS=list APP_VIEWS_BOOKSHELVES=grid +APP_VIEWS_BOOKSHELF=grid + +# Use dark mode by default +# Will be overriden by any user/session preference. +APP_DEFAULT_DARK_MODE=false # Page revision limit # Number of page revisions to keep in the system before deleting old revisions. # If set to 'false' a limit will not be enforced. -REVISION_LIMIT=50 - -# Allow 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 - */ - "DOMPDF_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 - */ - "DOMPDF_ENABLE_JAVASCRIPT" => true, - - /** - * 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 - */ - "DOMPDF_ENABLE_REMOTE" => true, - - /** - * A ratio applied to the fonts height to be more like browsers' line height - */ - "DOMPDF_FONT_HEIGHT_RATIO" => 1.1, - - /** - * Enable CSS float - * - * Allows people to disabled CSS float support - * @var bool - */ - "DOMPDF_ENABLE_CSS_FLOAT" => true, - - - /** - * Use the more-than-experimental HTML5 Lib parser - */ - "DOMPDF_ENABLE_HTML5PARSER" => true, - - - ], - - -]; diff --git a/app/Config/exports.php b/app/Config/exports.php new file mode 100644 index 00000000000..f48fe0a67a3 --- /dev/null +++ b/app/Config/exports.php @@ -0,0 +1,307 @@ + '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/dompdf'), // 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/dompdf/cache'), + + /** + * 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, + ], +]; diff --git a/app/Config/filesystems.php b/app/Config/filesystems.php index bd7d28300ab..facf5f2df2f 100644 --- a/app/Config/filesystems.php +++ b/app/Config/filesystems.php @@ -11,7 +11,7 @@ return [ // Default Filesystem Disk - // Options: local, local_secure, s3 + // Options: local, local_secure, local_secure_restricted, s3 'default' => env('STORAGE_TYPE', 'local'), // Filesystem to use specifically for image uploads. @@ -25,50 +25,52 @@ // file storage service, such as s3, to store publicly accessible assets. 'url' => env('STORAGE_URL', false), - // Default Cloud Filesystem Disk - 'cloud' => 's3', - // Available filesystem disks // Only local, local_secure & s3 are supported by BookStack 'disks' => [ 'local' => [ - 'driver' => 'local', - 'root' => public_path(), + 'driver' => 'local', + 'root' => public_path(), + 'serve' => false, + 'throw' => true, + 'directory_visibility' => 'public', ], - 'local_secure' => [ + 'local_secure_attachments' => [ 'driver' => 'local', - 'root' => storage_path(), + 'root' => storage_path('uploads/files/'), + 'serve' => false, + 'throw' => true, ], - 'ftp' => [ - 'driver' => 'ftp', - 'host' => 'ftp.example.com', - 'username' => 'your-username', - 'password' => 'your-password', + '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), + '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, ], - 'rackspace' => [ - 'driver' => 'rackspace', - 'username' => 'your-username', - 'key' => 'your-key', - 'container' => 'your-container', - 'endpoint' => 'https://identity.api.rackspacecloud.com/v2.0/', - 'region' => 'IAD', - 'url_type' => 'publicURL', - ], + ], + // 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'), ], ]; diff --git a/app/Config/hashing.php b/app/Config/hashing.php index 756718ce2bd..91d0db16b9e 100644 --- a/app/Config/hashing.php +++ b/app/Config/hashing.php @@ -21,7 +21,8 @@ // 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', 10), + 'rounds' => env('BCRYPT_ROUNDS', 12), + 'verify' => true, ], // Argon Options @@ -29,9 +30,9 @@ // 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, + 'memory' => 1024, 'threads' => 2, - 'time' => 2, + 'time' => 2, ], ]; diff --git a/app/Config/logging.php b/app/Config/logging.php index 375e84083f9..f5cbd5ffc01 100644 --- a/app/Config/logging.php +++ b/app/Config/logging.php @@ -1,7 +1,10 @@ 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 @@ -28,53 +40,66 @@ // "custom", "stack" 'channels' => [ 'stack' => [ - 'driver' => 'stack', - 'channels' => ['daily'], + 'driver' => 'stack', + 'channels' => ['daily'], 'ignore_exceptions' => false, ], 'single' => [ 'driver' => 'single', - 'path' => storage_path('logs/laravel.log'), - 'level' => 'debug', - 'days' => 14, + '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, - ], - - 'slack' => [ - 'driver' => 'slack', - 'url' => env('LOG_SLACK_WEBHOOK_URL'), - 'username' => 'Laravel Log', - 'emoji' => ':boom:', - 'level' => 'critical', + 'path' => storage_path('logs/laravel.log'), + 'level' => 'debug', + 'days' => 7, + 'replace_placeholders' => true, ], 'stderr' => [ - 'driver' => 'monolog', + 'driver' => 'monolog', + 'level' => 'debug', 'handler' => StreamHandler::class, - 'with' => [ + 'with' => [ 'stream' => 'php://stderr', ], + 'processors' => [PsrLogMessageProcessor::class], ], 'syslog' => [ 'driver' => 'syslog', - 'level' => 'debug', + 'level' => 'debug', + 'facility' => LOG_USER, + 'replace_placeholders' => true, ], 'errorlog' => [ 'driver' => 'errorlog', - 'level' => 'debug', + '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', + 'driver' => 'monolog', 'handler' => NullHandler::class, ], @@ -84,6 +109,17 @@ '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'), ], ]; diff --git a/app/Config/mail.php b/app/Config/mail.php index a91bdf23797..7256ce8848e 100644 --- a/app/Config/mail.php +++ b/app/Config/mail.php @@ -8,48 +8,61 @@ * Do not edit this file unless you're happy to maintain any changes yourself. */ +// Configured mail encryption method. +// STARTTLS should still be attempted, but tls/ssl forces TLS usage. +$mailEncryption = env('MAIL_ENCRYPTION', null); +$mailPort = intval(env('MAIL_PORT', 587)); + return [ // Mail driver to use. - // Options: smtp, mail, sendmail, log - 'driver' => env('MAIL_DRIVER', 'smtp'), - - // SMTP host address - 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), - - // SMTP host port - 'port' => env('MAIL_PORT', 587), + // From Laravel 7+ this is MAIL_MAILER in laravel. + // Kept as MAIL_DRIVER in BookStack to prevent breaking change. + // Options: smtp, sendmail, log, array + 'default' => env('MAIL_DRIVER', 'smtp'), // Global "From" address & name 'from' => [ - 'address' => env('MAIL_FROM', 'mail@bookstackapp.com'), - 'name' => env('MAIL_FROM_NAME', 'BookStack') + 'address' => env('MAIL_FROM', 'bookstack@example.com'), + 'name' => env('MAIL_FROM_NAME', 'BookStack'), ], - // Email encryption protocol - 'encryption' => env('MAIL_ENCRYPTION', 'tls'), + // 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), + ], - // SMTP server username - 'username' => env('MAIL_USERNAME'), + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'), + ], - // SMTP server password - 'password' => env('MAIL_PASSWORD'), + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], - // Sendmail application path - 'sendmail' => '/usr/sbin/sendmail -bs', + 'array' => [ + 'transport' => 'array', + ], - // Email markdown configuration - 'markdown' => [ - 'theme' => 'default', - 'paths' => [ - resource_path('views/vendor/mail'), + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], ], ], - - // Log Channel - // If you are using the "log" driver, you may specify the logging channel - // if you prefer to keep mail messages separate from other log entries - // for simpler reading. Otherwise, the default channel will be used. - 'log_channel' => env('MAIL_LOG_CHANNEL'), - ]; diff --git a/app/Config/oidc.php b/app/Config/oidc.php new file mode 100644 index 00000000000..16bec873c9c --- /dev/null +++ b/app/Config/oidc.php @@ -0,0 +1,63 @@ + 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), +]; diff --git a/app/Config/queue.php b/app/Config/queue.php index 46f6962c5f3..08f3a5baab5 100644 --- a/app/Config/queue.php +++ b/app/Config/queue.php @@ -11,37 +11,47 @@ return [ // Default driver to use for the queue - // Options: null, sync, redis + // Options: sync, database, redis 'default' => env('QUEUE_CONNECTION', 'sync'), // Queue connection configuration 'connections' => [ - 'sync' => [ 'driver' => 'sync', ], 'database' => [ - 'driver' => 'database', - 'table' => 'jobs', - 'queue' => 'default', - 'retry_after' => 90, + '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, + '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' => [ - 'database' => 'mysql', 'table' => 'failed_jobs', + 'driver' => 'database-uuids', + 'database' => 'mysql', + 'table' => 'failed_jobs', ], ]; diff --git a/app/Config/saml2.php b/app/Config/saml2.php index 5f2c1395b83..44d06c5b2e6 100644 --- a/app/Config/saml2.php +++ b/app/Config/saml2.php @@ -1,5 +1,8 @@ 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 @@ -77,10 +79,11 @@ // 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' => '', - 'privateKey' => '', + 'x509cert' => $SAML2_SP_x509 ?: '', + 'privateKey' => env('SAML2_SP_x509_KEY', ''), ], // Identity Provider Data that we want connect with our SP 'idp' => [ @@ -101,7 +104,7 @@ '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' => '', + 'responseUrl' => null, // SAML protocol binding to be used when returning the // message. Onelogin Toolkit supports for this endpoint the // HTTP-Redirect binding only @@ -139,6 +142,19 @@ // ) // ), ], + '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, + ], ], ]; diff --git a/app/Config/services.php b/app/Config/services.php index fcde621d2b5..d7345823150 100644 --- a/app/Config/services.php +++ b/app/Config/services.php @@ -28,16 +28,16 @@ '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), + '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), + '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), ], @@ -47,7 +47,7 @@ '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), + 'auto_confirm' => env('SLACK_AUTO_CONFIRM_EMAIL', false), ], 'facebook' => [ @@ -56,7 +56,7 @@ '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), + 'auto_confirm' => env('FACEBOOK_AUTO_CONFIRM_EMAIL', false), ], 'twitter' => [ @@ -65,27 +65,27 @@ '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), + '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), + '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), + 'auto_confirm' => env('AZURE_AUTO_CONFIRM_EMAIL', false), ], 'okta' => [ - 'client_id' => env('OKTA_APP_ID'), + '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'), + '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), + 'auto_confirm' => env('OKTA_AUTO_CONFIRM_EMAIL', false), ], 'gitlab' => [ @@ -95,43 +95,47 @@ '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), + 'auto_confirm' => env('GITLAB_AUTO_CONFIRM_EMAIL', false), ], 'twitch' => [ - 'client_id' => env('TWITCH_APP_ID'), + 'client_id' => env('TWITCH_APP_ID'), 'client_secret' => env('TWITCH_APP_SECRET'), - 'redirect' => env('APP_URL') . '/login/service/twitch/callback', + '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), + 'auto_confirm' => env('TWITCH_AUTO_CONFIRM_EMAIL', false), ], 'discord' => [ - 'client_id' => env('DISCORD_APP_ID'), + 'client_id' => env('DISCORD_APP_ID'), 'client_secret' => env('DISCORD_APP_SECRET'), - 'redirect' => env('APP_URL') . '/login/service/discord/callback', - 'name' => 'Discord', + '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), + 'auto_confirm' => env('DISCORD_AUTO_CONFIRM_EMAIL', false), ], 'ldap' => [ - 'server' => env('LDAP_SERVER', false), - 'dump_user_details' => env('LDAP_DUMP_USER_DETAILS', 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'), + '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), + '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), ], ]; diff --git a/app/Config/session.php b/app/Config/session.php index 37f1627bb5f..f2ec2509fc8 100644 --- a/app/Config/session.php +++ b/app/Config/session.php @@ -1,5 +1,7 @@ '/', + 'path' => '/' . (explode('/', env('APP_URL', ''), 4)[3] ?? ''), // Session Cookie Domain // Here you may change the domain of the cookie used to identify a session @@ -69,7 +71,8 @@ // 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', false), + '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 @@ -80,6 +83,13 @@ // 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 - 'same_site' => null, + // 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, ]; diff --git a/app/Config/setting-defaults.php b/app/Config/setting-defaults.php index d84c0c26413..2f270b283a2 100644 --- a/app/Config/setting-defaults.php +++ b/app/Config/setting-defaults.php @@ -16,12 +16,32 @@ '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, + ], + ]; diff --git a/app/Config/snappy.php b/app/Config/snappy.php deleted file mode 100644 index f347eda2334..00000000000 --- a/app/Config/snappy.php +++ /dev/null @@ -1,28 +0,0 @@ - [ - 'enabled' => true, - 'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false), - 'timeout' => false, - 'options' => [ - 'outline' => true - ], - 'env' => [], - ], - 'image' => [ - 'enabled' => false, - 'binary' => '/usr/local/bin/wkhtmltoimage', - 'timeout' => false, - 'options' => [], - 'env' => [], - ], -]; diff --git a/app/Config/view.php b/app/Config/view.php index 80bc9ef8fe8..2eb30b4c9de 100644 --- a/app/Config/view.php +++ b/app/Config/view.php @@ -8,12 +8,6 @@ * Do not edit this file unless you're happy to maintain any changes yourself. */ -// Join up possible view locations -$viewPaths = [realpath(base_path('resources/views'))]; -if ($theme = env('APP_THEME', false)) { - array_unshift($viewPaths, base_path('themes/' . $theme)); -} - return [ // App theme @@ -26,7 +20,7 @@ // 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' => $viewPaths, + 'paths' => [realpath(base_path('resources/views'))], // Compiled View Path // This option determines where all the compiled Blade templates will be diff --git a/app/Console/Commands/AssignSortRuleCommand.php b/app/Console/Commands/AssignSortRuleCommand.php new file mode 100644 index 00000000000..f00df83831c --- /dev/null +++ b/app/Console/Commands/AssignSortRuleCommand.php @@ -0,0 +1,99 @@ +argument('sort-rule')); + 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; + } +} diff --git a/app/Console/Commands/CleanupImages.php b/app/Console/Commands/CleanupImages.php deleted file mode 100644 index f2e2d9fbd44..00000000000 --- a/app/Console/Commands/CleanupImages.php +++ /dev/null @@ -1,85 +0,0 @@ -imageService = $imageService; - parent::__construct(); - } - - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() - { - $checkRevisions = $this->option('all') ? false : true; - $dryRun = $this->option('force') ? false : true; - - if (!$dryRun) { - $proceed = $this->confirm("This operation is destructive and is not guaranteed to be fully accurate.\nEnsure you have a backup of your images.\nAre you sure you want to proceed?"); - if (!$proceed) { - return; - } - } - - $deleted = $this->imageService->deleteUnusedImages($checkRevisions, $dryRun); - $deleteCount = count($deleted); - - if ($dryRun) { - $this->comment('Dry run, No images have been deleted'); - $this->comment($deleteCount . ' images found that would have been deleted'); - $this->showDeletedImages($deleted); - $this->comment('Run with -f or --force to perform deletions'); - return; - } - - $this->showDeletedImages($deleted); - $this->comment($deleteCount . ' images deleted'); - } - - protected function showDeletedImages($paths) - { - if ($this->getOutput()->getVerbosity() <= OutputInterface::VERBOSITY_NORMAL) { - return; - } - if (count($paths) > 0) { - $this->line('Images to delete:'); - } - foreach ($paths as $path) { - $this->line($path); - } - } -} diff --git a/app/Console/Commands/CleanupImagesCommand.php b/app/Console/Commands/CleanupImagesCommand.php new file mode 100644 index 00000000000..18e60ff1773 --- /dev/null +++ b/app/Console/Commands/CleanupImagesCommand.php @@ -0,0 +1,76 @@ +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); + } + } +} diff --git a/app/Console/Commands/ClearActivity.php b/app/Console/Commands/ClearActivity.php deleted file mode 100644 index 932ba7ddd19..00000000000 --- a/app/Console/Commands/ClearActivity.php +++ /dev/null @@ -1,47 +0,0 @@ -activity = $activity; - parent::__construct(); - } - - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() - { - $this->activity->newQuery()->truncate(); - $this->comment('System activity cleared'); - } -} diff --git a/app/Console/Commands/ClearActivityCommand.php b/app/Console/Commands/ClearActivityCommand.php new file mode 100644 index 00000000000..6ec2e1a2aaa --- /dev/null +++ b/app/Console/Commands/ClearActivityCommand.php @@ -0,0 +1,33 @@ +truncate(); + $this->comment('System activity cleared'); + return 0; + } +} diff --git a/app/Console/Commands/ClearRevisions.php b/app/Console/Commands/ClearRevisions.php deleted file mode 100644 index 15f1fcc0a71..00000000000 --- a/app/Console/Commands/ClearRevisions.php +++ /dev/null @@ -1,50 +0,0 @@ -pageRevision = $pageRevision; - parent::__construct(); - } - - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() - { - $deleteTypes = $this->option('all') ? ['version', 'update_draft'] : ['version']; - $this->pageRevision->newQuery()->whereIn('type', $deleteTypes)->delete(); - $this->comment('Revisions deleted'); - } -} diff --git a/app/Console/Commands/ClearRevisionsCommand.php b/app/Console/Commands/ClearRevisionsCommand.php new file mode 100644 index 00000000000..ad001fdb1e8 --- /dev/null +++ b/app/Console/Commands/ClearRevisionsCommand.php @@ -0,0 +1,36 @@ +option('all') ? ['version', 'update_draft'] : ['version']; + PageRevision::query()->whereIn('type', $deleteTypes)->delete(); + $this->comment('Revisions deleted'); + return 0; + } +} diff --git a/app/Console/Commands/ClearViews.php b/app/Console/Commands/ClearViews.php deleted file mode 100644 index 35356210b66..00000000000 --- a/app/Console/Commands/ClearViews.php +++ /dev/null @@ -1,42 +0,0 @@ -comment('Views cleared'); - } -} diff --git a/app/Console/Commands/ClearViewsCommand.php b/app/Console/Commands/ClearViewsCommand.php new file mode 100644 index 00000000000..87ea503dc47 --- /dev/null +++ b/app/Console/Commands/ClearViewsCommand.php @@ -0,0 +1,33 @@ +truncate(); + $this->comment('Views cleared'); + return 0; + } +} diff --git a/app/Console/Commands/CopyShelfPermissions.php b/app/Console/Commands/CopyShelfPermissions.php deleted file mode 100644 index 6b5d35a4767..00000000000 --- a/app/Console/Commands/CopyShelfPermissions.php +++ /dev/null @@ -1,88 +0,0 @@ -bookshelfRepo = $repo; - parent::__construct(); - } - - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() - { - $shelfSlug = $this->option('slug'); - $cascadeAll = $this->option('all'); - $shelves = null; - - if (!$cascadeAll && !$shelfSlug) { - $this->error('Either a --slug or --all option must be provided.'); - return; - } - - 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; - } - - $shelves = Bookshelf::query()->get(['id', 'restricted']); - } - - if ($shelfSlug) { - $shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id', 'restricted']); - if ($shelves->count() === 0) { - $this->info('No shelves found with the given slug.'); - } - } - - foreach ($shelves as $shelf) { - $this->bookshelfRepo->copyDownPermissions($shelf, false); - $this->info('Copied permissions for shelf [' . $shelf->id . ']'); - } - - $this->info('Permissions copied for ' . $shelves->count() . ' shelves.'); - } -} diff --git a/app/Console/Commands/CopyShelfPermissionsCommand.php b/app/Console/Commands/CopyShelfPermissionsCommand.php new file mode 100644 index 00000000000..1207621debc --- /dev/null +++ b/app/Console/Commands/CopyShelfPermissionsCommand.php @@ -0,0 +1,75 @@ +option('slug'); + $cascadeAll = $this->option('all'); + $noInteraction = boolval($this->option('no-interaction')); + $shelves = null; + + if (!$cascadeAll && !$shelfSlug) { + $this->error('Either a --slug or --all option must be provided.'); + + return 1; + } + + if ($cascadeAll) { + if (!$noInteraction) { + $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) { + 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; + } +} diff --git a/app/Console/Commands/CreateAdmin.php b/app/Console/Commands/CreateAdmin.php deleted file mode 100644 index e67da871763..00000000000 --- a/app/Console/Commands/CreateAdmin.php +++ /dev/null @@ -1,85 +0,0 @@ -userRepo = $userRepo; - parent::__construct(); - } - - /** - * Execute the console command. - * - * @return mixed - * @throws \BookStack\Exceptions\NotFoundException - */ - public function handle() - { - $email = trim($this->option('email')); - if (empty($email)) { - $email = $this->ask('Please specify an email address for the new admin user'); - } - if (mb_strlen($email) < 5 || !filter_var($email, FILTER_VALIDATE_EMAIL)) { - return $this->error('Invalid email address provided'); - } - - if ($this->userRepo->getByEmail($email) !== null) { - return $this->error('A user with the provided email already exists!'); - } - - $name = trim($this->option('name')); - if (empty($name)) { - $name = $this->ask('Please specify an name for the new admin user'); - } - if (mb_strlen($name) < 2) { - return $this->error('Invalid name provided'); - } - - $password = trim($this->option('password')); - if (empty($password)) { - $password = $this->secret('Please specify a password for the new admin user'); - } - if (mb_strlen($password) < 5) { - return $this->error('Invalid password provided, Must be at least 5 characters'); - } - - - $user = $this->userRepo->create(['email' => $email, 'name' => $name, 'password' => $password]); - $this->userRepo->attachSystemRole($user, 'admin'); - $this->userRepo->downloadAndAssignUserAvatar($user); - $user->email_confirmed = true; - $user->save(); - - $this->info("Admin account with email \"{$user->email}\" successfully created!"); - } -} diff --git a/app/Console/Commands/CreateAdminCommand.php b/app/Console/Commands/CreateAdminCommand.php new file mode 100644 index 00000000000..bf72553f72a --- /dev/null +++ b/app/Console/Commands/CreateAdminCommand.php @@ -0,0 +1,162 @@ +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; + } +} diff --git a/app/Console/Commands/DeleteUsers.php b/app/Console/Commands/DeleteUsers.php deleted file mode 100644 index c73c883de2d..00000000000 --- a/app/Console/Commands/DeleteUsers.php +++ /dev/null @@ -1,57 +0,0 @@ -user = $user; - $this->userRepo = $userRepo; - parent::__construct(); - } - - public function handle() - { - $confirm = $this->ask('This will delete all users from the system that are not "admin" or system users. Are you sure you want to continue? (Type "yes" to continue)'); - $numDeleted = 0; - if (strtolower(trim($confirm)) === 'yes') { - $totalUsers = $this->user->count(); - $users = $this->user->where('system_name', '=', null)->with('roles')->get(); - foreach ($users as $user) { - if ($user->hasSystemRole('admin')) { - // don't delete users with "admin" role - continue; - } - $this->userRepo->destroy($user); - ++$numDeleted; - } - $this->info("Deleted $numDeleted of $totalUsers total users."); - } else { - $this->info('Exiting...'); - } - } -} diff --git a/app/Console/Commands/DeleteUsersCommand.php b/app/Console/Commands/DeleteUsersCommand.php new file mode 100644 index 00000000000..d5c85dc8c5a --- /dev/null +++ b/app/Console/Commands/DeleteUsersCommand.php @@ -0,0 +1,53 @@ +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; + } +} diff --git a/app/Console/Commands/HandlesSingleUser.php b/app/Console/Commands/HandlesSingleUser.php new file mode 100644 index 00000000000..d3014aab188 --- /dev/null +++ b/app/Console/Commands/HandlesSingleUser.php @@ -0,0 +1,40 @@ +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; + } +} diff --git a/app/Console/Commands/InstallModuleCommand.php b/app/Console/Commands/InstallModuleCommand.php new file mode 100644 index 00000000000..8e77474554e --- /dev/null +++ b/app/Console/Commands/InstallModuleCommand.php @@ -0,0 +1,319 @@ +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]); + $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) { + $comparison = new UrlComparison($location, $redirectLocation); + $redirectOriginMatches = $comparison->originsMatch(); + + if (!$redirectOriginMatches) { + $redirectUrl = parse_url($redirectLocation); + $redirectOrigin = ($redirectUrl['scheme'] ?? '') . '://' . ($redirectUrl['host'] ?? '') . (isset($redirectUrl['port']) ? ':' . $redirectUrl['port'] : ''); + $this->info("The download URL is redirecting to a different site: {$redirectOrigin}"); + $shouldContinue = $this->confirm("Do you trust downloading the module from this site?"); + if (!$shouldContinue) { + $this->error("Stopping module installation"); + return null; + } + } + + $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(); + } + } +} diff --git a/app/Console/Commands/RefreshAvatarCommand.php b/app/Console/Commands/RefreshAvatarCommand.php new file mode 100644 index 00000000000..e402285e734 --- /dev/null +++ b/app/Console/Commands/RefreshAvatarCommand.php @@ -0,0 +1,116 @@ +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; + } +} diff --git a/app/Console/Commands/RegenerateCommentContent.php b/app/Console/Commands/RegenerateCommentContent.php deleted file mode 100644 index 587a5edb310..00000000000 --- a/app/Console/Commands/RegenerateCommentContent.php +++ /dev/null @@ -1,61 +0,0 @@ -commentRepo = $commentRepo; - parent::__construct(); - } - - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() - { - $connection = \DB::getDefaultConnection(); - if ($this->option('database') !== null) { - \DB::setDefaultConnection($this->option('database')); - } - - Comment::query()->chunk(100, function ($comments) { - foreach ($comments as $comment) { - $comment->html = $this->commentRepo->commentToHtml($comment->text); - $comment->save(); - } - }); - - \DB::setDefaultConnection($connection); - $this->comment('Comment HTML content has been regenerated'); - } -} diff --git a/app/Console/Commands/RegeneratePermissions.php b/app/Console/Commands/RegeneratePermissions.php deleted file mode 100644 index 4fde08e6b60..00000000000 --- a/app/Console/Commands/RegeneratePermissions.php +++ /dev/null @@ -1,58 +0,0 @@ -permissionService = $permissionService; - parent::__construct(); - } - - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() - { - $connection = \DB::getDefaultConnection(); - if ($this->option('database') !== null) { - \DB::setDefaultConnection($this->option('database')); - $this->permissionService->setConnection(\DB::connection($this->option('database'))); - } - - $this->permissionService->buildJointPermissions(); - - \DB::setDefaultConnection($connection); - $this->comment('Permissions regenerated'); - } -} diff --git a/app/Console/Commands/RegeneratePermissionsCommand.php b/app/Console/Commands/RegeneratePermissionsCommand.php new file mode 100644 index 00000000000..856e943c529 --- /dev/null +++ b/app/Console/Commands/RegeneratePermissionsCommand.php @@ -0,0 +1,44 @@ +option('database')) { + DB::setDefaultConnection($this->option('database')); + } + + $permissionBuilder->rebuildForAll(); + + DB::setDefaultConnection($connection); + $this->comment('Permissions regenerated'); + + return 0; + } +} diff --git a/app/Console/Commands/RegenerateReferencesCommand.php b/app/Console/Commands/RegenerateReferencesCommand.php new file mode 100644 index 00000000000..563da100a79 --- /dev/null +++ b/app/Console/Commands/RegenerateReferencesCommand.php @@ -0,0 +1,45 @@ +option('database')) { + DB::setDefaultConnection($this->option('database')); + } + + $references->updateForAll(); + + DB::setDefaultConnection($connection); + + $this->comment('References have been regenerated'); + + return 0; + } +} diff --git a/app/Console/Commands/RegenerateSearch.php b/app/Console/Commands/RegenerateSearch.php deleted file mode 100644 index dc57f2cea76..00000000000 --- a/app/Console/Commands/RegenerateSearch.php +++ /dev/null @@ -1,55 +0,0 @@ -searchService = $searchService; - } - - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() - { - $connection = DB::getDefaultConnection(); - if ($this->option('database') !== null) { - DB::setDefaultConnection($this->option('database')); - $this->searchService->setConnection(DB::connection($this->option('database'))); - } - - $this->searchService->indexAllEntities(); - DB::setDefaultConnection($connection); - $this->comment('Search index regenerated'); - } -} diff --git a/app/Console/Commands/RegenerateSearchCommand.php b/app/Console/Commands/RegenerateSearchCommand.php new file mode 100644 index 00000000000..f67a51e3d93 --- /dev/null +++ b/app/Console/Commands/RegenerateSearchCommand.php @@ -0,0 +1,46 @@ +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; + } +} diff --git a/app/Console/Commands/ResetMfaCommand.php b/app/Console/Commands/ResetMfaCommand.php new file mode 100644 index 00000000000..2b0801e39da --- /dev/null +++ b/app/Console/Commands/ResetMfaCommand.php @@ -0,0 +1,53 @@ +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; + } +} diff --git a/app/Console/Commands/UpdateUrl.php b/app/Console/Commands/UpdateUrl.php deleted file mode 100644 index b95e277d176..00000000000 --- a/app/Console/Commands/UpdateUrl.php +++ /dev/null @@ -1,91 +0,0 @@ -db = $db; - parent::__construct(); - } - - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() - { - $oldUrl = str_replace("'", '', $this->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"], - "pages" => ["html", "text", "markdown"], - "images" => ["url"], - "comments" => ["html", "text"], - ]; - - foreach ($columnsToUpdateByTable as $table => $columns) { - foreach ($columns as $column) { - $changeCount = $this->db->table($table)->update([ - $column => $this->db->raw("REPLACE({$column}, '{$oldUrl}', '{$newUrl}')") - ]); - $this->info("Updated {$changeCount} rows in {$table}->{$column}"); - } - } - - $this->info("URL update procedure complete."); - return 0; - } - - /** - * 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 - { - $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); - } -} diff --git a/app/Console/Commands/UpdateUrlCommand.php b/app/Console/Commands/UpdateUrlCommand.php new file mode 100644 index 00000000000..fd86e070667 --- /dev/null +++ b/app/Console/Commands/UpdateUrlCommand.php @@ -0,0 +1,125 @@ +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); + } +} diff --git a/app/Console/Commands/UpgradeDatabaseEncoding.php b/app/Console/Commands/UpgradeDatabaseEncoding.php deleted file mode 100644 index a17fc952351..00000000000 --- a/app/Console/Commands/UpgradeDatabaseEncoding.php +++ /dev/null @@ -1,57 +0,0 @@ -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); - } -} diff --git a/app/Console/Commands/UpgradeDatabaseEncodingCommand.php b/app/Console/Commands/UpgradeDatabaseEncodingCommand.php new file mode 100644 index 00000000000..245ce57c6f0 --- /dev/null +++ b/app/Console/Commands/UpgradeDatabaseEncodingCommand.php @@ -0,0 +1,50 @@ +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; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e75d9380163..f49be1d63b4 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -1,23 +1,17 @@ -load(__DIR__.'/Commands'); + $this->load(__DIR__ . '/Commands'); } } diff --git a/app/Entities/Book.php b/app/Entities/Book.php deleted file mode 100644 index af8344b88f5..00000000000 --- a/app/Entities/Book.php +++ /dev/null @@ -1,131 +0,0 @@ -slug) . '/' . trim($path, '/')); - } - return url('/books/' . urlencode($this->slug)); - } - - /** - * Returns book cover image, if book cover not exists return default cover image. - * @param int $width - Width of the image - * @param int $height - Height of the image - * @return string - */ - public function getBookCover($width = 440, $height = 250) - { - $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; - if (!$this->image_id) { - return $default; - } - - try { - $cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default; - } catch (Exception $err) { - $cover = $default; - } - return $cover; - } - - /** - * Get the cover image of the book - */ - public function cover(): BelongsTo - { - return $this->belongsTo(Image::class, 'image_id'); - } - - /** - * Get the type of the image model that is used when storing a cover image. - */ - public function coverImageTypeKey(): string - { - return 'cover_book'; - } - - /** - * Get all pages within this book. - * @return HasMany - */ - public function pages() - { - return $this->hasMany(Page::class); - } - - /** - * Get the direct child pages of this book. - * @return HasMany - */ - public function directPages() - { - return $this->pages()->where('chapter_id', '=', '0'); - } - - /** - * Get all chapters within this book. - * @return HasMany - */ - public function chapters() - { - return $this->hasMany(Chapter::class); - } - - /** - * Get the shelves this book is contained within. - * @return BelongsToMany - */ - public function shelves() - { - return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id'); - } - - /** - * Get the direct child items within this book. - * @return Collection - */ - public function getDirectChildren(): Collection - { - $pages = $this->directPages()->visible()->get(); - $chapters = $this->chapters()->visible()->get(); - return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft'); - } - - /** - * Get an excerpt of this book's description to the specified length or less. - * @param int $length - * @return string - */ - public function getExcerpt(int $length = 100) - { - $description = $this->description; - return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description; - } -} diff --git a/app/Entities/BookChild.php b/app/Entities/BookChild.php deleted file mode 100644 index 6eac4375ddc..00000000000 --- a/app/Entities/BookChild.php +++ /dev/null @@ -1,60 +0,0 @@ -with('book') - ->whereHas('book', function (Builder $query) use ($bookSlug) { - $query->where('slug', '=', $bookSlug); - }) - ->where('slug', '=', $childSlug); - } - - /** - * Get the book this page sits in. - * @return BelongsTo - */ - public function book(): BelongsTo - { - return $this->belongsTo(Book::class); - } - - /** - * Change the book that this entity belongs to. - */ - public function changeBook(int $newBookId): Entity - { - $this->book_id = $newBookId; - $this->refreshSlug(); - $this->save(); - $this->refresh(); - - // Update related activity - $this->activity()->update(['book_id' => $newBookId]); - - // Update all child pages if a chapter - if ($this instanceof Chapter) { - foreach ($this->pages as $page) { - $page->changeBook($newBookId); - } - } - - return $this; - } -} diff --git a/app/Entities/Bookshelf.php b/app/Entities/Bookshelf.php deleted file mode 100644 index 474ba51cd82..00000000000 --- a/app/Entities/Bookshelf.php +++ /dev/null @@ -1,122 +0,0 @@ -belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id') - ->withPivot('order') - ->orderBy('order', 'asc'); - } - - /** - * Related books that are visible to the current user. - */ - public function visibleBooks(): BelongsToMany - { - return $this->books()->visible(); - } - - /** - * Get the url for this bookshelf. - * @param string|bool $path - * @return string - */ - public function getUrl($path = false) - { - if ($path !== false) { - return url('/shelves/' . urlencode($this->slug) . '/' . trim($path, '/')); - } - return url('/shelves/' . urlencode($this->slug)); - } - - /** - * Returns BookShelf cover image, if cover does not exists return default cover image. - * @param int $width - Width of the image - * @param int $height - Height of the image - * @return string - */ - public function getBookCover($width = 440, $height = 250) - { - // TODO - Make generic, focused on books right now, Perhaps set-up a better image - $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; - if (!$this->image_id) { - return $default; - } - - try { - $cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default; - } catch (\Exception $err) { - $cover = $default; - } - return $cover; - } - - /** - * Get the cover image of the shelf - */ - public function cover(): BelongsTo - { - return $this->belongsTo(Image::class, 'image_id'); - } - - /** - * Get the type of the image model that is used when storing a cover image. - */ - public function coverImageTypeKey(): string - { - return 'cover_shelf'; - } - - /** - * Get an excerpt of this book's description to the specified length or less. - * @param int $length - * @return string - */ - public function getExcerpt(int $length = 100) - { - $description = $this->description; - return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description; - } - - /** - * Check if this shelf contains the given book. - * @param Book $book - * @return bool - */ - public function contains(Book $book): bool - { - return $this->books()->where('id', '=', $book->id)->count() > 0; - } - - /** - * Add a book to the end of this shelf. - * @param Book $book - */ - public function appendBook(Book $book) - { - if ($this->contains($book)) { - return; - } - - $maxOrder = $this->books()->max('order'); - $this->books()->attach($book->id, ['order' => $maxOrder + 1]); - } -} diff --git a/app/Entities/BreadcrumbsViewComposer.php b/app/Entities/BreadcrumbsViewComposer.php index 43d63d02602..c9269c7c66a 100644 --- a/app/Entities/BreadcrumbsViewComposer.php +++ b/app/Entities/BreadcrumbsViewComposer.php @@ -1,32 +1,28 @@ -entityContextManager = $entityContextManager; + public function __construct( + protected ShelfContext $shelfContext + ) { } /** * Modify data when the view is composed. - * @param View $view */ - public function compose(View $view) + public function compose(View $view): void { $crumbs = $view->getData()['crumbs']; $firstCrumb = $crumbs[0] ?? null; + if ($firstCrumb instanceof Book) { - $shelf = $this->entityContextManager->getContextualShelfForBook($firstCrumb); + $shelf = $this->shelfContext->getContextualShelfForBook($firstCrumb); if ($shelf) { array_unshift($crumbs, $shelf); $view->with('crumbs', $crumbs); diff --git a/app/Entities/Chapter.php b/app/Entities/Chapter.php deleted file mode 100644 index 3290afcfa6b..00000000000 --- a/app/Entities/Chapter.php +++ /dev/null @@ -1,74 +0,0 @@ - $pages - * @package BookStack\Entities - */ -class Chapter extends BookChild -{ - public $searchFactor = 1.3; - - protected $fillable = ['name', 'description', 'priority', 'book_id']; - protected $hidden = ['restricted', 'pivot']; - - /** - * Get the pages that this chapter contains. - * @param string $dir - * @return mixed - */ - public function pages($dir = 'ASC') - { - return $this->hasMany(Page::class)->orderBy('priority', $dir); - } - - /** - * Get the url of this chapter. - * @param string|bool $path - * @return string - */ - public function getUrl($path = false) - { - $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug; - $fullPath = '/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug); - - if ($path !== false) { - $fullPath .= '/' . trim($path, '/'); - } - - return url($fullPath); - } - - /** - * Get an excerpt of this chapter's description to the specified length or less. - * @param int $length - * @return string - */ - public function getExcerpt(int $length = 100) - { - $description = $this->text ?? $this->description; - return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description; - } - - /** - * Check if this chapter has any child pages. - * @return bool - */ - public function hasChildren() - { - return count($this->pages) > 0; - } - - /** - * Get the visible pages in this chapter. - */ - public function getVisiblePages(): Collection - { - return $this->pages()->visible() - ->orderBy('draft', 'desc') - ->orderBy('priority', 'asc') - ->get(); - } -} diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php new file mode 100644 index 00000000000..c47ece22569 --- /dev/null +++ b/app/Entities/Controllers/BookApiController.php @@ -0,0 +1,165 @@ +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'], + ], + ]; + } +} diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php new file mode 100644 index 00000000000..98470d91ce8 --- /dev/null +++ b/app/Entities/Controllers/BookController.php @@ -0,0 +1,290 @@ +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->input('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->input('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()); + } +} diff --git a/app/Entities/Controllers/BookshelfApiController.php b/app/Entities/Controllers/BookshelfApiController.php new file mode 100644 index 00000000000..e620eb59c29 --- /dev/null +++ b/app/Entities/Controllers/BookshelfApiController.php @@ -0,0 +1,148 @@ +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->input('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->input('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()), + ], + ]; + } +} diff --git a/app/Entities/Controllers/BookshelfController.php b/app/Entities/Controllers/BookshelfController.php new file mode 100644 index 00000000000..1e8b26b5156 --- /dev/null +++ b/app/Entities/Controllers/BookshelfController.php @@ -0,0 +1,232 @@ +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->input('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->input('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'); + } +} diff --git a/app/Entities/Controllers/ChapterApiController.php b/app/Entities/Controllers/ChapterApiController.php new file mode 100644 index 00000000000..9e0c69b1776 --- /dev/null +++ b/app/Entities/Controllers/ChapterApiController.php @@ -0,0 +1,155 @@ + [ + '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->input('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; + } +} diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php new file mode 100644 index 00000000000..db2391599ab --- /dev/null +++ b/app/Entities/Controllers/ChapterController.php @@ -0,0 +1,285 @@ +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->input('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->input('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->input('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()); + } +} diff --git a/app/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php new file mode 100644 index 00000000000..38042e67058 --- /dev/null +++ b/app/Entities/Controllers/PageApiController.php @@ -0,0 +1,173 @@ + [ + '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->input('chapter_id'))); + } else { + $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('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->input('chapter_id'))); + } elseif ($request->has('book_id')) { + $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('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); + } +} diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php new file mode 100644 index 00000000000..82edfbc2763 --- /dev/null +++ b/app/Entities/Controllers/PageController.php @@ -0,0 +1,473 @@ +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->input('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->input('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->input('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->input('name') ?: $page->name; + $pageCopy = $cloner->clonePage($page, $newParent, $newName); + $this->showSuccessNotification(trans('entities.pages_copy_success')); + + return redirect($pageCopy->getUrl()); + } +} diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php new file mode 100644 index 00000000000..cc6b79bfe45 --- /dev/null +++ b/app/Entities/Controllers/PageRevisionController.php @@ -0,0 +1,185 @@ +checkPermission(Permission::RevisionViewAll); + $page = $this->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) + { + $this->checkPermission(Permission::RevisionViewAll); + + $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) + { + $this->checkPermission(Permission::RevisionViewAll); + + $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) + { + $this->checkPermission(Permission::RevisionViewAll); + $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) + { + $this->checkPermission(Permission::RevisionViewAll); + $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); + } +} diff --git a/app/Entities/Controllers/PageTemplateController.php b/app/Entities/Controllers/PageTemplateController.php new file mode 100644 index 00000000000..9ff2fe0293e --- /dev/null +++ b/app/Entities/Controllers/PageTemplateController.php @@ -0,0 +1,67 @@ +input('page', 1); + $search = $request->input('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, + ]); + } +} diff --git a/app/Entities/Controllers/RecycleBinApiController.php b/app/Entities/Controllers/RecycleBinApiController.php new file mode 100644 index 00000000000..6146851366b --- /dev/null +++ b/app/Entities/Controllers/RecycleBinApiController.php @@ -0,0 +1,100 @@ +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(); + } +} diff --git a/app/Entities/Controllers/RecycleBinController.php b/app/Entities/Controllers/RecycleBinController.php new file mode 100644 index 00000000000..f3c2b6a01cc --- /dev/null +++ b/app/Entities/Controllers/RecycleBinController.php @@ -0,0 +1,129 @@ +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); + } +} diff --git a/app/Entities/Entity.php b/app/Entities/Entity.php deleted file mode 100644 index 6a5894cacb9..00000000000 --- a/app/Entities/Entity.php +++ /dev/null @@ -1,312 +0,0 @@ -scopeHasPermission($query, 'view'); - } - - /** - * Scope the query to those entities that the current user has the given permission for. - */ - public function scopeHasPermission(Builder $query, string $permission) - { - return Permissions::restrictEntityQuery($query, $permission); - } - - /** - * 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', '=', $this->getTable() . '.id') - ->where('viewable_type', '=', $this->getMorphClass()) - ->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) - { - $viewCountQuery = View::query()->selectRaw('SUM(views) as view_count') - ->whereColumn('viewable_id', '=', $this->getTable() . '.id') - ->where('viewable_type', '=', $this->getMorphClass())->take(1); - - $query->addSelect(['view_count' => $viewCountQuery]); - } - - /** - * Compares this entity to another given entity. - * Matches by comparing class and id. - * @param $entity - * @return bool - */ - public function matches($entity) - { - return [get_class($this), $this->id] === [get_class($entity), $entity->id]; - } - - /** - * Checks if an entity matches or contains another given entity. - * @param Entity $entity - * @return bool - */ - public function matchesOrContains(Entity $entity) - { - $matches = [get_class($this), $this->id] === [get_class($entity), $entity->id]; - - if ($matches) { - return true; - } - - if (($entity->isA('chapter') || $entity->isA('page')) && $this->isA('book')) { - return $entity->book_id === $this->id; - } - - if ($entity->isA('page') && $this->isA('chapter')) { - return $entity->chapter_id === $this->id; - } - - return false; - } - - /** - * Gets the activity objects for this entity. - * @return MorphMany - */ - public function activity() - { - return $this->morphMany(Activity::class, 'entity') - ->orderBy('created_at', 'desc'); - } - - /** - * Get View objects for this entity. - */ - public function views() - { - return $this->morphMany(View::class, 'viewable'); - } - - /** - * Get the Tag models that have been user assigned to this entity. - * @return MorphMany - */ - public function tags() - { - return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc'); - } - - /** - * Get the comments for an entity - * @param bool $orderByCreated - * @return MorphMany - */ - public function comments($orderByCreated = true) - { - $query = $this->morphMany(Comment::class, 'entity'); - return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query; - } - - /** - * Get the related search terms. - * @return MorphMany - */ - public function searchTerms() - { - return $this->morphMany(SearchTerm::class, 'entity'); - } - - /** - * Get this entities restrictions. - */ - public function permissions() - { - return $this->morphMany(EntityPermission::class, 'restrictable'); - } - - /** - * Check if this entity has a specific restriction set against it. - * @param $role_id - * @param $action - * @return bool - */ - public function hasRestriction($role_id, $action) - { - return $this->permissions()->where('role_id', '=', $role_id) - ->where('action', '=', $action)->count() > 0; - } - - /** - * Get the entity jointPermissions this is connected to. - * @return MorphMany - */ - public function jointPermissions() - { - return $this->morphMany(JointPermission::class, 'entity'); - } - - /** - * Allows checking of the exact class, Used to check entity type. - * Cleaner method for is_a. - * @param $type - * @return bool - */ - public static function isA($type) - { - return static::getType() === strtolower($type); - } - - /** - * Get entity type. - * @return mixed - */ - public static function getType() - { - return strtolower(static::getClassName()); - } - - /** - * Get an instance of an entity of the given type. - * @param $type - * @return Entity - */ - public static function getEntityInstance($type) - { - $types = ['Page', 'Book', 'Chapter', 'Bookshelf']; - $className = str_replace([' ', '-', '_'], '', ucwords($type)); - if (!in_array($className, $types)) { - return null; - } - - return app('BookStack\\Entities\\' . $className); - } - - /** - * Gets a limited-length version of the entities name. - * @param int $length - * @return string - */ - public function getShortName($length = 25) - { - if (mb_strlen($this->name) <= $length) { - return $this->name; - } - return mb_substr($this->name, 0, $length - 3) . '...'; - } - - /** - * Get the body text of this entity. - * @return mixed - */ - public function getText() - { - return $this->{$this->textField}; - } - - /** - * Get an excerpt of this entity's descriptive content to the specified length. - * @param int $length - * @return mixed - */ - public function getExcerpt(int $length = 100) - { - $text = $this->getText(); - if (mb_strlen($text) > $length) { - $text = mb_substr($text, 0, $length-3) . '...'; - } - return trim($text); - } - - /** - * Get the url of this entity - * @param $path - * @return string - */ - public function getUrl($path = '/') - { - return $path; - } - - /** - * Rebuild the permissions for this entity. - */ - public function rebuildPermissions() - { - /** @noinspection PhpUnhandledExceptionInspection */ - Permissions::buildJointPermissionsForEntity(clone $this); - } - - /** - * Index the current entity for search - */ - public function indexForSearch() - { - $searchService = app()->make(SearchService::class); - $searchService->indexEntity(clone $this); - } - - /** - * Generate and set a new URL slug for this model. - */ - public function refreshSlug(): string - { - $generator = new SlugGenerator($this); - $this->slug = $generator->generate(); - return $this->slug; - } -} diff --git a/app/Entities/EntityExistsRule.php b/app/Entities/EntityExistsRule.php new file mode 100644 index 00000000000..da210544611 --- /dev/null +++ b/app/Entities/EntityExistsRule.php @@ -0,0 +1,20 @@ +where('type', $this->type); + return $existsRule->__toString(); + } +} diff --git a/app/Entities/EntityProvider.php b/app/Entities/EntityProvider.php index 6bf923b3112..3276a6c7a91 100644 --- a/app/Entities/EntityProvider.php +++ b/app/Entities/EntityProvider.php @@ -1,80 +1,67 @@ -bookshelf = $bookshelf; - $this->book = $book; - $this->chapter = $chapter; - $this->page = $page; - $this->pageRevision = $pageRevision; + public function __construct() + { + $this->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, + 'book' => $this->book, + 'chapter' => $this->chapter, + 'page' => $this->page, ]; } /** - * Get an entity instance by it's basic name. + * Get an entity instance by its basic name. */ public function get(string $type): Entity { $type = strtolower($type); - return $this->all()[$type]; + $instance = $this->all()[$type] ?? null; + + if (is_null($instance)) { + throw new \InvalidArgumentException("Provided type \"{$type}\" is not a valid entity type"); + } + + return $instance; } /** @@ -87,6 +74,7 @@ public function getMorphClasses(array $types): array $model = $this->get($type); $morphClasses[] = $model->getMorphClass(); } + return $morphClasses; } } diff --git a/app/Entities/ExportService.php b/app/Entities/ExportService.php deleted file mode 100644 index f945dfbe4af..00000000000 --- a/app/Entities/ExportService.php +++ /dev/null @@ -1,228 +0,0 @@ -imageService = $imageService; - } - - /** - * Convert a page to a self-contained HTML file. - * Includes required CSS & image content. Images are base64 encoded into the HTML. - * @throws Throwable - */ - public function pageToContainedHtml(Page $page) - { - $page->html = (new PageContent($page))->render(); - $pageHtml = view('pages.export', [ - 'page' => $page, - 'format' => 'html', - ])->render(); - return $this->containHtml($pageHtml); - } - - /** - * Convert a chapter to a self-contained HTML file. - * @throws Throwable - */ - public function chapterToContainedHtml(Chapter $chapter) - { - $pages = $chapter->getVisiblePages(); - $pages->each(function ($page) { - $page->html = (new PageContent($page))->render(); - }); - $html = view('chapters.export', [ - 'chapter' => $chapter, - 'pages' => $pages, - 'format' => 'html', - ])->render(); - return $this->containHtml($html); - } - - /** - * Convert a book to a self-contained HTML file. - * @throws Throwable - */ - public function bookToContainedHtml(Book $book) - { - $bookTree = (new BookContents($book))->getTree(false, true); - $html = view('books.export', [ - 'book' => $book, - 'bookChildren' => $bookTree, - 'format' => 'html', - ])->render(); - return $this->containHtml($html); - } - - /** - * Convert a page to a PDF file. - * @throws Throwable - */ - public function pageToPdf(Page $page) - { - $page->html = (new PageContent($page))->render(); - $html = view('pages.export', [ - 'page' => $page, - 'format' => 'pdf', - ])->render(); - return $this->htmlToPdf($html); - } - - /** - * Convert a chapter to a PDF file. - * @throws Throwable - */ - public function chapterToPdf(Chapter $chapter) - { - $pages = $chapter->getVisiblePages(); - $pages->each(function ($page) { - $page->html = (new PageContent($page))->render(); - }); - - $html = view('chapters.export', [ - 'chapter' => $chapter, - 'pages' => $pages, - 'format' => 'pdf', - ])->render(); - - return $this->htmlToPdf($html); - } - - /** - * Convert a book to a PDF file. - * @throws Throwable - */ - public function bookToPdf(Book $book) - { - $bookTree = (new BookContents($book))->getTree(false, true); - $html = view('books.export', [ - 'book' => $book, - 'bookChildren' => $bookTree, - 'format' => 'pdf', - ])->render(); - return $this->htmlToPdf($html); - } - - /** - * Convert normal web-page HTML to a PDF. - * @throws Exception - */ - protected function htmlToPdf(string $html): string - { - $containedHtml = $this->containHtml($html); - $useWKHTML = config('snappy.pdf.binary') !== false; - if ($useWKHTML) { - $pdf = SnappyPDF::loadHTML($containedHtml); - $pdf->setOption('print-media-type', true); - } else { - $pdf = DomPDF::loadHTML($containedHtml); - } - return $pdf->output(); - } - - /** - * Bundle of the contents of a html file to be self-contained. - * @throws Exception - */ - protected function containHtml(string $htmlContent): string - { - $imageTagsOutput = []; - preg_match_all("/\/i", $htmlContent, $imageTagsOutput); - - // Replace image src with base64 encoded image strings - if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) { - foreach ($imageTagsOutput[0] as $index => $imgMatch) { - $oldImgTagString = $imgMatch; - $srcString = $imageTagsOutput[2][$index]; - $imageEncoded = $this->imageService->imageUriToBase64($srcString); - if ($imageEncoded === null) { - $imageEncoded = $srcString; - } - $newImgTagString = str_replace($srcString, $imageEncoded, $oldImgTagString); - $htmlContent = str_replace($oldImgTagString, $newImgTagString, $htmlContent); - } - } - - $linksOutput = []; - preg_match_all("/\/i", $htmlContent, $linksOutput); - - // Replace image src with base64 encoded image strings - if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) { - foreach ($linksOutput[0] as $index => $linkMatch) { - $oldLinkString = $linkMatch; - $srcString = $linksOutput[2][$index]; - if (strpos(trim($srcString), 'http') !== 0) { - $newSrcString = url($srcString); - $newLinkString = str_replace($srcString, $newSrcString, $oldLinkString); - $htmlContent = str_replace($oldLinkString, $newLinkString, $htmlContent); - } - } - } - - // Replace any relative links with system domain - return $htmlContent; - } - - /** - * Converts the page contents into simple plain text. - * This method filters any bad looking content to provide a nice final output. - */ - public function pageToPlainText(Page $page): string - { - $html = (new PageContent($page))->render(); - $text = strip_tags($html); - // Replace multiple spaces with single spaces - $text = preg_replace('/\ {2,}/', ' ', $text); - // Reduce multiple horrid whitespace characters. - $text = preg_replace('/(\x0A|\xA0|\x0A|\r|\n){2,}/su', "\n\n", $text); - $text = html_entity_decode($text); - // Add title - $text = $page->name . "\n\n" . $text; - return $text; - } - - /** - * Convert a chapter into a plain text string. - */ - public function chapterToPlainText(Chapter $chapter): string - { - $text = $chapter->name . "\n\n"; - $text .= $chapter->description . "\n\n"; - foreach ($chapter->pages as $page) { - $text .= $this->pageToPlainText($page); - } - return $text; - } - - /** - * Convert a book into a plain text string. - */ - public function bookToPlainText(Book $book): string - { - $bookTree = (new BookContents($book))->getTree(false, true); - $text = $book->name . "\n\n"; - foreach ($bookTree as $bookChild) { - if ($bookChild->isA('chapter')) { - $text .= $this->chapterToPlainText($bookChild); - } else { - $text .= $this->pageToPlainText($bookChild); - } - } - return $text; - } -} diff --git a/app/Entities/HasCoverImage.php b/app/Entities/HasCoverImage.php deleted file mode 100644 index 31277f4b69c..00000000000 --- a/app/Entities/HasCoverImage.php +++ /dev/null @@ -1,20 +0,0 @@ -book = $book; - } - - /** - * Get the current priority of the last item - * at the top-level of the book. - */ - public function getLastPriority(): int - { - $maxPage = Page::visible()->where('book_id', '=', $this->book->id) - ->where('draft', '=', false) - ->where('chapter_id', '=', 0)->max('priority'); - $maxChapter = Chapter::visible()->where('book_id', '=', $this->book->id) - ->max('priority'); - return max($maxChapter, $maxPage, 1); - } - - /** - * Get the contents as a sorted collection tree. - * TODO - Support $renderPages option - */ - public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection - { - $pages = $this->getPages($showDrafts); - $chapters = Chapter::visible()->where('book_id', '=', $this->book->id)->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('pages', collect($pages)->sortBy($this->bookChildSortFunc())); - } else { - $lonePages = $lonePages->concat($pages); - } - }); - - $all->each(function (Entity $entity) { - $entity->setRelation('book', $this->book); - }); - - 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 (isset($entity['draft']) && $entity['draft']) { - return -100; - } - return $entity['priority'] ?? 0; - }; - } - - /** - * Get the visible pages within this book. - */ - protected function getPages(bool $showDrafts = false): Collection - { - $query = Page::visible()->where('book_id', '=', $this->book->id); - - if (!$showDrafts) { - $query->where('draft', '=', false); - } - - return $query->get(); - } - - /** - * Sort the books content using the given map. - * The map is a single-dimension collection of objects in the following format: - * { - * +"id": "294" (ID of item) - * +"sort": 1 (Sort order index) - * +"parentChapter": false (ID of parent chapter, as string, or false) - * +"type": "page" (Entity type of item) - * +"book": "1" (Id of book to place item in) - * } - * - * Returns a list of books that were involved in the operation. - * @throws SortOperationException - */ - public function sortUsingMap(Collection $sortMap): Collection - { - // Load models into map - $this->loadModelsIntoSortMap($sortMap); - $booksInvolved = $this->getBooksInvolvedInSort($sortMap); - - // Perform the sort - $sortMap->each(function ($mapItem) { - $this->applySortUpdates($mapItem); - }); - - // Update permissions and activity. - $booksInvolved->each(function (Book $book) { - $book->rebuildPermissions(); - }); - - return $booksInvolved; - } - - /** - * Using the given sort map item, detect changes for the related model - * and update it if required. - */ - protected function applySortUpdates(\stdClass $sortMapItem) - { - /** @var BookChild $model */ - $model = $sortMapItem->model; - - $priorityChanged = intval($model->priority) !== intval($sortMapItem->sort); - $bookChanged = intval($model->book_id) !== intval($sortMapItem->book); - $chapterChanged = ($sortMapItem->type === 'page') && intval($model->chapter_id) !== $sortMapItem->parentChapter; - - if ($bookChanged) { - $model->changeBook($sortMapItem->book); - } - - if ($chapterChanged) { - $model->chapter_id = intval($sortMapItem->parentChapter); - $model->save(); - } - - if ($priorityChanged) { - $model->priority = intval($sortMapItem->sort); - $model->save(); - } - } - - /** - * Load models from the database into the given sort map. - */ - protected function loadModelsIntoSortMap(Collection $sortMap): void - { - $keyMap = $sortMap->keyBy(function (\stdClass $sortMapItem) { - return $sortMapItem->type . ':' . $sortMapItem->id; - }); - $pageIds = $sortMap->where('type', '=', 'page')->pluck('id'); - $chapterIds = $sortMap->where('type', '=', 'chapter')->pluck('id'); - - $pages = Page::visible()->whereIn('id', $pageIds)->get(); - $chapters = Chapter::visible()->whereIn('id', $chapterIds)->get(); - - foreach ($pages as $page) { - $sortItem = $keyMap->get('page:' . $page->id); - $sortItem->model = $page; - } - - foreach ($chapters as $chapter) { - $sortItem = $keyMap->get('chapter:' . $chapter->id); - $sortItem->model = $chapter; - } - } - - /** - * Get the books involved in a sort. - * The given sort map should have its models loaded first. - * @throws SortOperationException - */ - protected function getBooksInvolvedInSort(Collection $sortMap): Collection - { - $bookIdsInvolved = collect([$this->book->id]); - $bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('book')); - $bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('model.book_id')); - $bookIdsInvolved = $bookIdsInvolved->unique()->toArray(); - - $books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get(); - - if (count($books) !== count($bookIdsInvolved)) { - throw new SortOperationException("Could not find all books requested in sort operation"); - } - - return $books; - } -} diff --git a/app/Entities/Managers/EntityContext.php b/app/Entities/Managers/EntityContext.php deleted file mode 100644 index 551cd1a100c..00000000000 --- a/app/Entities/Managers/EntityContext.php +++ /dev/null @@ -1,54 +0,0 @@ -session = $session; - } - - /** - * Get the current bookshelf context for the given book. - */ - public function getContextualShelfForBook(Book $book): ?Bookshelf - { - $contextBookshelfId = $this->session->get($this->KEY_SHELF_CONTEXT_ID, null); - - if (!is_int($contextBookshelfId)) { - return null; - } - - $shelf = Bookshelf::visible()->find($contextBookshelfId); - $shelfContainsBook = $shelf && $shelf->contains($book); - - return $shelfContainsBook ? $shelf : null; - } - - /** - * Store the current contextual shelf ID. - * @param int $shelfId - */ - public function setShelfContext(int $shelfId) - { - $this->session->put($this->KEY_SHELF_CONTEXT_ID, $shelfId); - } - - /** - * Clear the session stored shelf context id. - */ - public function clearShelfContext() - { - $this->session->forget($this->KEY_SHELF_CONTEXT_ID); - } -} diff --git a/app/Entities/Managers/PageContent.php b/app/Entities/Managers/PageContent.php deleted file mode 100644 index 36bc2445c33..00000000000 --- a/app/Entities/Managers/PageContent.php +++ /dev/null @@ -1,304 +0,0 @@ -page = $page; - } - - /** - * Update the content of the page with new provided HTML. - */ - public function setNewHTML(string $html) - { - $this->page->html = $this->formatHtml($html); - $this->page->text = $this->toPlainText(); - } - - /** - * Formats a page's html to be tagged correctly within the system. - */ - protected function formatHtml(string $htmlText): string - { - if ($htmlText == '') { - return $htmlText; - } - - libxml_use_internal_errors(true); - $doc = new DOMDocument(); - $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8')); - - $container = $doc->documentElement; - $body = $container->childNodes->item(0); - $childNodes = $body->childNodes; - - // Set ids on top-level nodes - $idMap = []; - foreach ($childNodes as $index => $childNode) { - $this->setUniqueId($childNode, $idMap); - } - - // Ensure no duplicate ids within child items - $xPath = new DOMXPath($doc); - $idElems = $xPath->query('//body//*//*[@id]'); - foreach ($idElems as $domElem) { - $this->setUniqueId($domElem, $idMap); - } - - // Generate inner html as a string - $html = ''; - foreach ($childNodes as $childNode) { - $html .= $doc->saveHTML($childNode); - } - - return $html; - } - - /** - * Set a unique id on the given DOMElement. - * A map for existing ID's should be passed in to check for current existence. - * @param DOMElement $element - * @param array $idMap - */ - protected function setUniqueId($element, array &$idMap) - { - if (get_class($element) !== 'DOMElement') { - return; - } - - // Overwrite id if not a BookStack custom id - $existingId = $element->getAttribute('id'); - if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) { - $idMap[$existingId] = true; - return; - } - - // Create an unique id for the element - // Uses the content as a basis to ensure output is the same every time - // the same content is passed through. - $contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20); - $newId = urlencode($contentId); - $loopIndex = 0; - - while (isset($idMap[$newId])) { - $newId = urlencode($contentId . '-' . $loopIndex); - $loopIndex++; - } - - $element->setAttribute('id', $newId); - $idMap[$newId] = true; - } - - /** - * Get a plain-text visualisation of this page. - */ - protected function toPlainText(): string - { - $html = $this->render(true); - return strip_tags($html); - } - - /** - * Render the page for viewing - */ - public function render(bool $blankIncludes = false) : string - { - $content = $this->page->html; - - if (!config('app.allow_content_scripts')) { - $content = $this->escapeScripts($content); - } - - if ($blankIncludes) { - $content = $this->blankPageIncludes($content); - } else { - $content = $this->parsePageIncludes($content); - } - - return $content; - } - - /** - * Parse the headers on the page to get a navigation menu - */ - public function getNavigation(string $htmlContent): array - { - if (empty($htmlContent)) { - return []; - } - - libxml_use_internal_errors(true); - $doc = new DOMDocument(); - $doc->loadHTML(mb_convert_encoding($htmlContent, 'HTML-ENTITIES', 'UTF-8')); - $xPath = new DOMXPath($doc); - $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6"); - - return $headers ? $this->headerNodesToLevelList($headers) : []; - } - - /** - * Convert a DOMNodeList into an array of readable header attributes - * with levels normalised to the lower header level. - */ - protected function headerNodesToLevelList(DOMNodeList $nodeList): array - { - $tree = collect($nodeList)->map(function ($header) { - $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue)); - $text = mb_substr($text, 0, 100); - - return [ - 'nodeName' => strtolower($header->nodeName), - 'level' => intval(str_replace('h', '', $header->nodeName)), - 'link' => '#' . $header->getAttribute('id'), - 'text' => $text, - ]; - })->filter(function ($header) { - return mb_strlen($header['text']) > 0; - }); - - // Shift headers if only smaller headers have been used - $levelChange = ($tree->pluck('level')->min() - 1); - $tree = $tree->map(function ($header) use ($levelChange) { - $header['level'] -= ($levelChange); - return $header; - }); - - return $tree->toArray(); - } - - /** - * Remove any page include tags within the given HTML. - */ - protected function blankPageIncludes(string $html) : string - { - return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html); - } - - /** - * Parse any include tags "{{@#section}}" to be part of the page. - */ - protected function parsePageIncludes(string $html) : string - { - $matches = []; - preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches); - - foreach ($matches[1] as $index => $includeId) { - $fullMatch = $matches[0][$index]; - $splitInclude = explode('#', $includeId, 2); - - // Get page id from reference - $pageId = intval($splitInclude[0]); - if (is_nan($pageId)) { - continue; - } - - // Find page and skip this if page not found - $matchedPage = Page::visible()->find($pageId); - if ($matchedPage === null) { - $html = str_replace($fullMatch, '', $html); - continue; - } - - // If we only have page id, just insert all page html and continue. - if (count($splitInclude) === 1) { - $html = str_replace($fullMatch, $matchedPage->html, $html); - continue; - } - - // Create and load HTML into a document - $innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]); - $html = str_replace($fullMatch, trim($innerContent), $html); - } - - return $html; - } - - - /** - * Fetch the content from a specific section of the given page. - */ - protected function fetchSectionOfPage(Page $page, string $sectionId): string - { - $topLevelTags = ['table', 'ul', 'ol']; - $doc = new DOMDocument(); - libxml_use_internal_errors(true); - $doc->loadHTML(mb_convert_encoding(''.$page->html.'', 'HTML-ENTITIES', 'UTF-8')); - - // Search included content for the id given and blank out if not exists. - $matchingElem = $doc->getElementById($sectionId); - if ($matchingElem === null) { - return ''; - } - - // Otherwise replace the content with the found content - // Checks if the top-level wrapper should be included by matching on tag types - $innerContent = ''; - $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags); - if ($isTopLevel) { - $innerContent .= $doc->saveHTML($matchingElem); - } else { - foreach ($matchingElem->childNodes as $childNode) { - $innerContent .= $doc->saveHTML($childNode); - } - } - libxml_clear_errors(); - - return $innerContent; - } - - /** - * Escape script tags within HTML content. - */ - protected function escapeScripts(string $html) : string - { - if (empty($html)) { - return $html; - } - - libxml_use_internal_errors(true); - $doc = new DOMDocument(); - $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); - $xPath = new DOMXPath($doc); - - // Remove standard script tags - $scriptElems = $xPath->query('//script'); - foreach ($scriptElems as $scriptElem) { - $scriptElem->parentNode->removeChild($scriptElem); - } - - // Remove data or JavaScript iFrames - $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]'); - foreach ($badIframes as $badIframe) { - $badIframe->parentNode->removeChild($badIframe); - } - - // Remove 'on*' attributes - $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]'); - foreach ($onAttributes as $attr) { - /** @var \DOMAttr $attr*/ - $attrName = $attr->nodeName; - $attr->parentNode->removeAttribute($attrName); - } - - $html = ''; - $topElems = $doc->documentElement->childNodes->item(0)->childNodes; - foreach ($topElems as $child) { - $html .= $doc->saveHTML($child); - } - - return $html; - } -} diff --git a/app/Entities/Managers/PageEditActivity.php b/app/Entities/Managers/PageEditActivity.php deleted file mode 100644 index cebbf8720f1..00000000000 --- a/app/Entities/Managers/PageEditActivity.php +++ /dev/null @@ -1,74 +0,0 @@ -page = $page; - } - - /** - * Check if there's active editing being performed on this page. - * @return bool - */ - public function hasActiveEditing(): bool - { - return $this->activePageEditingQuery(60)->count() > 0; - } - - /** - * Get a notification message concerning the editing activity on the page. - */ - public function activeEditingMessage(): string - { - $pageDraftEdits = $this->activePageEditingQuery(60)->get(); - $count = $pageDraftEdits->count(); - - $userMessage = $count > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $count]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]); - $timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]); - return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]); - } - - /** - * Get the message to show when the user will be editing one of their drafts. - * @param PageRevision $draft - * @return string - */ - public function getEditingActiveDraftMessage(PageRevision $draft): string - { - $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]); - if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) { - return $message; - } - return $message . "\n" . trans('entities.pages_draft_edited_notification'); - } - - /** - * A query to check for active update drafts on a particular page - * within the last given many minutes. - */ - protected function activePageEditingQuery(int $withinMinutes): Builder - { - $checkTime = Carbon::now()->subMinutes($withinMinutes); - $query = PageRevision::query() - ->where('type', '=', 'update_draft') - ->where('page_id', '=', $this->page->id) - ->where('updated_at', '>', $this->page->updated_at) - ->where('created_by', '!=', user()->id) - ->where('updated_at', '>=', $checkTime) - ->with('createdBy'); - - return $query; - } -} diff --git a/app/Entities/Managers/TrashCan.php b/app/Entities/Managers/TrashCan.php deleted file mode 100644 index 1a32294fc75..00000000000 --- a/app/Entities/Managers/TrashCan.php +++ /dev/null @@ -1,109 +0,0 @@ -destroyCommonRelations($shelf); - $shelf->delete(); - } - - /** - * Remove a book from the system. - * @throws NotifyException - * @throws BindingResolutionException - */ - public function destroyBook(Book $book) - { - foreach ($book->pages as $page) { - $this->destroyPage($page); - } - - foreach ($book->chapters as $chapter) { - $this->destroyChapter($chapter); - } - - $this->destroyCommonRelations($book); - $book->delete(); - } - - /** - * Remove a page from the system. - * @throws NotifyException - */ - public function destroyPage(Page $page) - { - // Check if set as custom homepage & remove setting if not used or throw error if active - $customHome = setting('app-homepage', '0:'); - if (intval($page->id) === intval(explode(':', $customHome)[0])) { - if (setting('app-homepage-type') === 'page') { - throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl()); - } - setting()->remove('app-homepage'); - } - - $this->destroyCommonRelations($page); - - // Delete Attached Files - $attachmentService = app(AttachmentService::class); - foreach ($page->attachments as $attachment) { - $attachmentService->deleteFile($attachment); - } - - $page->delete(); - } - - /** - * Remove a chapter from the system. - * @throws Exception - */ - public function destroyChapter(Chapter $chapter) - { - if (count($chapter->pages) > 0) { - foreach ($chapter->pages as $page) { - $page->chapter_id = 0; - $page->save(); - } - } - - $this->destroyCommonRelations($chapter); - $chapter->delete(); - } - - /** - * Update entity relations to remove or update outstanding connections. - */ - protected function destroyCommonRelations(Entity $entity) - { - Activity::removeEntity($entity); - $entity->views()->delete(); - $entity->permissions()->delete(); - $entity->tags()->delete(); - $entity->comments()->delete(); - $entity->jointPermissions()->delete(); - $entity->searchTerms()->delete(); - - if ($entity instanceof HasCoverImage && $entity->cover) { - $imageService = app()->make(ImageService::class); - $imageService->destroy($entity->cover); - } - } -} diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php new file mode 100644 index 00000000000..10f04695a5e --- /dev/null +++ b/app/Entities/Models/Book.php @@ -0,0 +1,114 @@ +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); + } +} diff --git a/app/Entities/Models/BookChild.php b/app/Entities/Models/BookChild.php new file mode 100644 index 00000000000..9a8493c3a0a --- /dev/null +++ b/app/Entities/Models/BookChild.php @@ -0,0 +1,25 @@ + + */ + public function book(): BelongsTo + { + return $this->belongsTo(Book::class)->withTrashed(); + } +} diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php new file mode 100644 index 00000000000..320346512e1 --- /dev/null +++ b/app/Entities/Models/Bookshelf.php @@ -0,0 +1,83 @@ +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'); + } +} diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php new file mode 100644 index 00000000000..2dd4cb77f05 --- /dev/null +++ b/app/Entities/Models/Chapter.php @@ -0,0 +1,68 @@ + $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); + } +} diff --git a/app/Entities/Models/ContainerTrait.php b/app/Entities/Models/ContainerTrait.php new file mode 100644 index 00000000000..9ef5ca8d43a --- /dev/null +++ b/app/Entities/Models/ContainerTrait.php @@ -0,0 +1,26 @@ + + */ + public function relatedData(): HasOne + { + return $this->hasOne(EntityContainerData::class, 'entity_id', 'id') + ->where('entity_type', '=', $this->getMorphClass()); + } +} diff --git a/app/Entities/Models/DeletableInterface.php b/app/Entities/Models/DeletableInterface.php new file mode 100644 index 00000000000..f771d9c6913 --- /dev/null +++ b/app/Entities/Models/DeletableInterface.php @@ -0,0 +1,14 @@ +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, '/')); + } +} diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php new file mode 100644 index 00000000000..27cfccaa836 --- /dev/null +++ b/app/Entities/Models/Entity.php @@ -0,0 +1,485 @@ +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(), + default => throw new \InvalidArgumentException("Invalid entity type: {$type}"), + }; + } +} diff --git a/app/Entities/Models/EntityContainerData.php b/app/Entities/Models/EntityContainerData.php new file mode 100644 index 00000000000..21bace7513f --- /dev/null +++ b/app/Entities/Models/EntityContainerData.php @@ -0,0 +1,52 @@ +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; + } +} diff --git a/app/Entities/Models/EntityPageData.php b/app/Entities/Models/EntityPageData.php new file mode 100644 index 00000000000..a98b1a9823c --- /dev/null +++ b/app/Entities/Models/EntityPageData.php @@ -0,0 +1,25 @@ +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(); + } +} diff --git a/app/Entities/Models/EntityScope.php b/app/Entities/Models/EntityScope.php new file mode 100644 index 00000000000..c77b75a1624 --- /dev/null +++ b/app/Entities/Models/EntityScope.php @@ -0,0 +1,28 @@ +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()); + }); + } + } +} diff --git a/app/Entities/Models/EntityTable.php b/app/Entities/Models/EntityTable.php new file mode 100644 index 00000000000..5780162d1d2 --- /dev/null +++ b/app/Entities/Models/EntityTable.php @@ -0,0 +1,69 @@ +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'); + } +} diff --git a/app/Entities/Models/HasCoverInterface.php b/app/Entities/Models/HasCoverInterface.php new file mode 100644 index 00000000000..a4e79e9004d --- /dev/null +++ b/app/Entities/Models/HasCoverInterface.php @@ -0,0 +1,18 @@ + + */ + public function cover(): BelongsTo; +} diff --git a/app/Entities/Models/HasDefaultTemplateInterface.php b/app/Entities/Models/HasDefaultTemplateInterface.php new file mode 100644 index 00000000000..f3af0da48ab --- /dev/null +++ b/app/Entities/Models/HasDefaultTemplateInterface.php @@ -0,0 +1,10 @@ + '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'); + } +} diff --git a/app/Entities/Models/PageRevision.php b/app/Entities/Models/PageRevision.php new file mode 100644 index 00000000000..4409afdc222 --- /dev/null +++ b/app/Entities/Models/PageRevision.php @@ -0,0 +1,95 @@ +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}"; + } +} diff --git a/app/Entities/Models/SlugHistory.php b/app/Entities/Models/SlugHistory.php new file mode 100644 index 00000000000..4041cedd959 --- /dev/null +++ b/app/Entities/Models/SlugHistory.php @@ -0,0 +1,28 @@ +hasMany(JointPermission::class, 'entity_id', 'sluggable_id') + ->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type'); + } +} diff --git a/app/Entities/Page.php b/app/Entities/Page.php deleted file mode 100644 index 32ba2981d80..00000000000 --- a/app/Entities/Page.php +++ /dev/null @@ -1,123 +0,0 @@ -toArray(), array_flip($this->simpleAttributes)); - $array['url'] = $this->getUrl(); - return $array; - } - - /** - * Get the parent item - */ - public function parent(): Entity - { - return $this->chapter_id ? $this->chapter : $this->book; - } - - /** - * Get the chapter that this page is in, If applicable. - * @return BelongsTo - */ - public function chapter() - { - return $this->belongsTo(Chapter::class); - } - - /** - * Check if this page has a chapter. - * @return bool - */ - public function hasChapter() - { - return $this->chapter()->count() > 0; - } - - /** - * Get the associated page revisions, ordered by created date. - * @return mixed - */ - public function revisions() - { - return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc')->orderBy('id', 'desc'); - } - - /** - * Get the attachments assigned to this page. - * @return HasMany - */ - public function attachments() - { - return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc'); - } - - /** - * Get the url for this page. - * @param string|bool $path - * @return string - */ - public function getUrl($path = false) - { - $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug; - $midText = $this->draft ? '/draft/' : '/page/'; - $idComponent = $this->draft ? $this->id : urlencode($this->slug); - - $url = '/books/' . urlencode($bookSlug) . $midText . $idComponent; - if ($path !== false) { - $url .= '/' . trim($path, '/'); - } - - return url($url); - } - - /** - * Get the current revision for the page if existing - * @return PageRevision|null - */ - public function getCurrentRevision() - { - return $this->revisions()->first(); - } -} diff --git a/app/Entities/PageRevision.php b/app/Entities/PageRevision.php deleted file mode 100644 index 13dc713ba43..00000000000 --- a/app/Entities/PageRevision.php +++ /dev/null @@ -1,84 +0,0 @@ -belongsTo(User::class, 'created_by'); - } - - /** - * Get the page this revision originates from. - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function page() - { - return $this->belongsTo(Page::class); - } - - /** - * Get the url for this revision. - * @param null|string $path - * @return string - */ - public function getUrl($path = null) - { - $url = $this->page->getUrl() . '/revisions/' . $this->id; - if ($path) { - return $url . '/' . trim($path, '/'); - } - return $url; - } - - /** - * Get the previous revision for the same page if existing - * @return \BookStack\Entities\PageRevision|null - */ - public function getPrevious() - { - $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) - * @param $type - * @return bool - */ - public static function isA($type) - { - return $type === 'revision'; - } -} diff --git a/app/Entities/Queries/BookQueries.php b/app/Entities/Queries/BookQueries.php new file mode 100644 index 00000000000..a466f37bc0f --- /dev/null +++ b/app/Entities/Queries/BookQueries.php @@ -0,0 +1,83 @@ + + */ +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'); + } +} diff --git a/app/Entities/Queries/BookshelfQueries.php b/app/Entities/Queries/BookshelfQueries.php new file mode 100644 index 00000000000..3fe0a2afcef --- /dev/null +++ b/app/Entities/Queries/BookshelfQueries.php @@ -0,0 +1,88 @@ + + */ +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'); + } +} diff --git a/app/Entities/Queries/ChapterQueries.php b/app/Entities/Queries/ChapterQueries.php new file mode 100644 index 00000000000..9ddeb9b5896 --- /dev/null +++ b/app/Entities/Queries/ChapterQueries.php @@ -0,0 +1,78 @@ + + */ +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'); + } +} diff --git a/app/Entities/Queries/EntityQueries.php b/app/Entities/Queries/EntityQueries.php new file mode 100644 index 00000000000..3ffa0adf3db --- /dev/null +++ b/app/Entities/Queries/EntityQueries.php @@ -0,0 +1,125 @@ +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; + } +} diff --git a/app/Entities/Queries/PageQueries.php b/app/Entities/Queries/PageQueries.php new file mode 100644 index 00000000000..f4ecee2dc08 --- /dev/null +++ b/app/Entities/Queries/PageQueries.php @@ -0,0 +1,130 @@ + + */ +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'); + }]); + } +} diff --git a/app/Entities/Queries/PageRevisionQueries.php b/app/Entities/Queries/PageRevisionQueries.php new file mode 100644 index 00000000000..6e017a742dc --- /dev/null +++ b/app/Entities/Queries/PageRevisionQueries.php @@ -0,0 +1,44 @@ +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'); + } +} diff --git a/app/Entities/Queries/ProvidesEntityQueries.php b/app/Entities/Queries/ProvidesEntityQueries.php new file mode 100644 index 00000000000..674e96afa24 --- /dev/null +++ b/app/Entities/Queries/ProvidesEntityQueries.php @@ -0,0 +1,45 @@ + + */ + 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; +} diff --git a/app/Entities/Queries/QueryPopular.php b/app/Entities/Queries/QueryPopular.php new file mode 100644 index 00000000000..065ae82ef82 --- /dev/null +++ b/app/Entities/Queries/QueryPopular.php @@ -0,0 +1,42 @@ +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(); + } +} diff --git a/app/Entities/Queries/QueryRecentlyViewed.php b/app/Entities/Queries/QueryRecentlyViewed.php new file mode 100644 index 00000000000..f28b8f8652f --- /dev/null +++ b/app/Entities/Queries/QueryRecentlyViewed.php @@ -0,0 +1,43 @@ +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(); + } +} diff --git a/app/Entities/Queries/QueryTopFavourites.php b/app/Entities/Queries/QueryTopFavourites.php new file mode 100644 index 00000000000..6340e35ef18 --- /dev/null +++ b/app/Entities/Queries/QueryTopFavourites.php @@ -0,0 +1,45 @@ +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(); + } +} diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 7c25e49813e..44baeaccfdc 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -2,118 +2,172 @@ namespace BookStack\Entities\Repos; -use BookStack\Actions\TagRepo; -use BookStack\Entities\Book; -use BookStack\Entities\Entity; -use BookStack\Entities\HasCoverImage; +use BookStack\Activity\TagRepo; +use BookStack\Entities\Models\BookChild; +use BookStack\Entities\Models\HasCoverInterface; +use BookStack\Entities\Models\HasDescriptionInterface; +use BookStack\Entities\Models\Entity; +use BookStack\Entities\Queries\PageQueries; +use BookStack\Entities\Tools\SlugGenerator; +use BookStack\Entities\Tools\SlugHistory; use BookStack\Exceptions\ImageUploadException; +use BookStack\References\ReferenceStore; +use BookStack\References\ReferenceUpdater; +use BookStack\Sorting\BookSorter; use BookStack\Uploads\ImageRepo; +use BookStack\Util\HtmlDescriptionFilter; +use BookStack\Util\HtmlToPlainText; use Illuminate\Http\UploadedFile; -use Illuminate\Support\Collection; class BaseRepo { - - protected $tagRepo; - protected $imageRepo; - - - /** - * BaseRepo constructor. - * @param $tagRepo - */ - public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo) - { - $this->tagRepo = $tagRepo; - $this->imageRepo = $imageRepo; + public function __construct( + protected TagRepo $tagRepo, + protected ImageRepo $imageRepo, + protected ReferenceUpdater $referenceUpdater, + protected ReferenceStore $referenceStore, + protected PageQueries $pageQueries, + protected BookSorter $bookSorter, + protected SlugGenerator $slugGenerator, + protected SlugHistory $slugHistory, + ) { } /** - * Create a new entity in the system + * Create a new entity in the system. + * @template T of Entity + * @param T $entity + * @return T */ - public function create(Entity $entity, array $input) + public function create(Entity $entity, array $input): Entity { + $entity = (clone $entity)->refresh(); $entity->fill($input); $entity->forceFill([ 'created_by' => user()->id, 'updated_by' => user()->id, + 'owned_by' => user()->id, ]); - $entity->refreshSlug(); + $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) + public function update(Entity $entity, array $input): Entity { + $oldUrl = $entity->getUrl(); + $entity->fill($input); $entity->updated_by = user()->id; - if ($entity->isDirty('name')) { - $entity->refreshSlug(); + 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->rebuildPermissions(); $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. + * Update the given items' cover image or clear it. + * * @throws ImageUploadException * @throws \Exception */ - public function updateCoverImage(HasCoverImage $entity, ?UploadedFile $coverImage, bool $removeImage = false) + public function updateCoverImage(Entity&HasCoverInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false): void { if ($coverImage) { - $this->imageRepo->destroyImage($entity->cover); - $image = $this->imageRepo->saveNew($coverImage, 'cover_book', $entity->id, 512, 512, true); - $entity->cover()->associate($image); + $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->cover); - $entity->image_id = 0; + $this->imageRepo->destroyImage($entity->coverInfo()->getImage()); + $entity->coverInfo()->setImage(null); $entity->save(); } } /** - * Update the permissions of an entity. + * 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 updatePermissions(Entity $entity, bool $restricted, Collection $permissions = null) + public function sortParent(Entity $entity): void { - $entity->restricted = $restricted; - $entity->permissions()->delete(); - - if (!is_null($permissions)) { - $entityPermissionData = $permissions->flatMap(function ($restrictions, $roleId) { - return collect($restrictions)->keys()->map(function ($action) use ($roleId) { - return [ - 'role_id' => $roleId, - 'action' => strtolower($action), - ] ; - }); - }); - - $entity->permissions()->createMany($entityPermissionData); + if ($entity instanceof BookChild) { + $book = $entity->book; + $this->bookSorter->runBookAutoSort($book); } + } - $entity->save(); - $entity->rebuildPermissions(); + /** + * 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'])) { + $plainTextConverter = new HtmlToPlainText(); + $entity->descriptionInfo()->set( + HtmlDescriptionFilter::filterFromString($input['description_html']), + $plainTextConverter->convert($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); } } diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index 70db0fa6575..b4244b9bb77 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -1,134 +1,93 @@ -baseRepo = $baseRepo; - $this->tagRepo = $tagRepo; - $this->imageRepo = $imageRepo; + public function __construct( + protected BaseRepo $baseRepo, + protected TagRepo $tagRepo, + protected ImageRepo $imageRepo, + protected TrashCan $trashCan, + ) { } /** - * Get all books in a paginated format. + * Create a new book in the system. */ - public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator + public function create(array $input): Book { - return Book::visible()->orderBy($sort, $order)->paginate($count); - } + return (new DatabaseTransaction(function () use ($input) { + $book = $this->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); - /** - * Get the books that were most recently viewed by this user. - */ - public function getRecentlyViewed(int $count = 20): Collection - { - return Book::visible()->withLastView() - ->having('last_viewed_at', '>', 0) - ->orderBy('last_viewed_at', 'desc') - ->take($count)->get(); - } + $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); + if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) { + $book->sort_rule_id = $defaultBookSortSetting; + } - /** - * Get the most popular books in the system. - */ - public function getPopular(int $count = 20): Collection - { - return Book::visible()->withViewCount() - ->having('view_count', '>', 0) - ->orderBy('view_count', 'desc') - ->take($count)->get(); - } + $book->save(); - /** - * Get the most recently created books from the system. - */ - public function getRecentlyCreated(int $count = 20): Collection - { - return Book::visible()->orderBy('created_at', 'desc') - ->take($count)->get(); + return $book; + }))->run(); } /** - * Get a book by its slug. + * Update the given book. */ - public function getBySlug(string $slug): Book + public function update(Book $book, array $input): Book { - $book = Book::visible()->where('slug', '=', $slug)->first(); + $book = $this->baseRepo->update($book, $input); - if ($book === null) { - throw new NotFoundException(trans('errors.book_not_found')); + if (array_key_exists('default_template_id', $input)) { + $book->defaultTemplate()->setFromId(intval($input['default_template_id'])); } - return $book; - } + if (array_key_exists('image', $input)) { + $this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null); + } - /** - * Create a new book in the system - */ - public function create(array $input): Book - { - $book = new Book(); - $this->baseRepo->create($book, $input); - return $book; - } + $book->save(); + Activity::add(ActivityType::BOOK_UPDATE, $book); - /** - * Update the given book. - */ - public function update(Book $book, array $input): Book - { - $this->baseRepo->update($book, $input); return $book; } /** - * Update the given book's cover image, or clear it. + * Update the given book's cover image or clear it. + * * @throws ImageUploadException * @throws Exception */ - public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false) + public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false): void { $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage); } - /** - * Update the permissions of a book. - */ - public function updatePermissions(Book $book, bool $restricted, Collection $permissions = null) - { - $this->baseRepo->updatePermissions($book, $restricted, $permissions); - } - /** * Remove a book from the system. - * @throws NotifyException - * @throws BindingResolutionException + * + * @throws Exception */ - public function destroy(Book $book) + public function destroy(Book $book): void { - $trashCan = new TrashCan(); - $trashCan->destroyBook($book); + $this->trashCan->softDestroyBook($book); + Activity::add(ActivityType::BOOK_DELETE, $book); + + $this->trashCan->autoClearOld(); } } diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php index ba687c6f6e7..bb84b51fd5e 100644 --- a/app/Entities/Repos/BookshelfRepo.php +++ b/app/Entities/Repos/BookshelfRepo.php @@ -1,82 +1,22 @@ -baseRepo = $baseRepo; - } - - /** - * Get all bookshelves in a paginated format. - */ - public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator - { - return Bookshelf::visible() - ->with('visibleBooks') - ->orderBy($sort, $order) - ->paginate($count); - } - - /** - * Get the bookshelves that were most recently viewed by this user. - */ - public function getRecentlyViewed(int $count = 20): Collection - { - return Bookshelf::visible()->withLastView() - ->having('last_viewed_at', '>', 0) - ->orderBy('last_viewed_at', 'desc') - ->take($count)->get(); - } - - /** - * Get the most popular bookshelves in the system. - */ - public function getPopular(int $count = 20): Collection - { - return Bookshelf::visible()->withViewCount() - ->having('view_count', '>', 0) - ->orderBy('view_count', 'desc') - ->take($count)->get(); - } - - /** - * Get the most recently created bookshelves from the system. - */ - public function getRecentlyCreated(int $count = 20): Collection - { - return Bookshelf::visible()->orderBy('created_at', 'desc') - ->take($count)->get(); - } - - /** - * Get a shelf by its slug. - */ - public function getBySlug(string $slug): Bookshelf - { - $shelf = Bookshelf::visible()->where('slug', '=', $slug)->first(); - - if ($shelf === null) { - throw new NotFoundException(trans('errors.bookshelf_not_found')); - } - - return $shelf; + public function __construct( + protected BaseRepo $baseRepo, + protected BookQueries $bookQueries, + protected TrashCan $trashCan, + ) { } /** @@ -84,96 +24,81 @@ public function getBySlug(string $slug): Bookshelf */ public function create(array $input, array $bookIds): Bookshelf { - $shelf = new Bookshelf(); - $this->baseRepo->create($shelf, $input); - $this->updateBooks($shelf, $bookIds); - return $shelf; + return (new DatabaseTransaction(function () use ($input, $bookIds) { + $shelf = $this->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(); } /** - * Create a new shelf in the system. + * Update an existing shelf in the system using the given input. */ public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf { - $this->baseRepo->update($shelf, $input); + $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 books are visible to the current user and existing. + * 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) + protected function updateBooks(Bookshelf $shelf, array $bookIds): void { $numericIDs = collect($bookIds)->map(function ($id) { return intval($id); }); - $syncData = Book::visible() + $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) - ->get(['id'])->pluck('id')->mapWithKeys(function ($bookId) use ($numericIDs) { - return [$bookId => ['order' => $numericIDs->search($bookId)]]; - }); + ->pluck('id') + ->toArray(); - $shelf->books()->sync($syncData); - } + $maxNewIndex = max($numericIDs->keys()->toArray() ?: [0]); - /** - * Update the given shelf cover image, or clear it. - * @throws ImageUploadException - * @throws Exception - */ - public function updateCoverImage(Bookshelf $shelf, ?UploadedFile $coverImage, bool $removeImage = false) - { - $this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage); - } - - /** - * Update the permissions of a bookshelf. - */ - public function updatePermissions(Bookshelf $shelf, bool $restricted, Collection $permissions = null) - { - $this->baseRepo->updatePermissions($shelf, $restricted, $permissions); - } + $syncData = []; + foreach ($newIdsToAssign as $id) { + $syncData[$id] = ['order' => $numericIDs->search($id)]; + } - /** - * Copy down the permissions of the given shelf to all child books. - */ - public function copyDownPermissions(Bookshelf $shelf, $checkUserPermissions = true): int - { - $shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray(); - $shelfBooks = $shelf->books()->get(['id', 'restricted']); - $updatedBookCount = 0; - - /** @var Book $book */ - foreach ($shelfBooks as $book) { - if ($checkUserPermissions && !userCan('restrictions-manage', $book)) { - continue; - } - $book->permissions()->delete(); - $book->restricted = $shelf->restricted; - $book->permissions()->createMany($shelfPermissions); - $book->save(); - $book->rebuildPermissions(); - $updatedBookCount++; + foreach ($nonVisibleExistingBookIds as $index => $id) { + $syncData[$id] = ['order' => $maxNewIndex + ($index + 1)]; } - return $updatedBookCount; + $shelf->books()->sync($syncData); } /** * Remove a bookshelf from the system. + * * @throws Exception */ - public function destroy(Bookshelf $shelf) + public function destroy(Bookshelf $shelf): void { - $trashCan = new TrashCan(); - $trashCan->destroyShelf($shelf); + $this->trashCan->softDestroyShelf($shelf); + Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf); + $this->trashCan->autoClearOld(); } } diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index c6f3a2d2f0f..a528eece092 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -1,56 +1,51 @@ -baseRepo = $baseRepo; + public function __construct( + protected BaseRepo $baseRepo, + protected EntityQueries $entityQueries, + protected TrashCan $trashCan, + protected ParentChanger $parentChanger, + ) { } /** - * Get a chapter via the slug. - * @throws NotFoundException + * Create a new chapter in the system. */ - public function getBySlug(string $bookSlug, string $chapterSlug): Chapter + public function create(array $input, Book $parentBook): Chapter { - $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->first(); + return (new DatabaseTransaction(function () use ($input, $parentBook) { + $chapter = new Chapter(); + $chapter->book_id = $parentBook->id; + $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1; - if ($chapter === null) { - throw new NotFoundException(trans('errors.chapter_not_found')); - } + $chapter = $this->baseRepo->create($chapter, $input); + $chapter->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null)); - return $chapter; - } + $chapter->save(); + Activity::add(ActivityType::CHAPTER_CREATE, $chapter); - /** - * Create a new chapter in the system. - */ - public function create(array $input, Book $parentBook): Chapter - { - $chapter = new Chapter(); - $chapter->book_id = $parentBook->id; - $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1; - $this->baseRepo->create($chapter, $input); - return $chapter; + $this->baseRepo->sortParent($chapter); + + return $chapter; + }))->run(); } /** @@ -58,51 +53,59 @@ public function create(array $input, Book $parentBook): Chapter */ public function update(Chapter $chapter, array $input): Chapter { - $this->baseRepo->update($chapter, $input); - return $chapter; - } + $chapter = $this->baseRepo->update($chapter, $input); - /** - * Update the permissions of a chapter. - */ - public function updatePermissions(Chapter $chapter, bool $restricted, Collection $permissions = null) - { - $this->baseRepo->updatePermissions($chapter, $restricted, $permissions); + 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) + public function destroy(Chapter $chapter): void { - $trashCan = new TrashCan(); - $trashCan->destroyChapter($chapter); + $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) + * 'book:' (book:5). + * * @throws MoveOperationException + * @throws PermissionsException */ public function move(Chapter $chapter, string $parentIdentifier): Book { - $stringExploded = explode(':', $parentIdentifier); - $entityType = $stringExploded[0]; - $entityId = intval($stringExploded[1]); - - if ($entityType !== 'book') { - throw new MoveOperationException('Chapters can only be moved into books'); + $parent = $this->entityQueries->findVisibleByStringIdentifier($parentIdentifier); + if (!$parent instanceof Book) { + throw new MoveOperationException('Book to move chapter into not found'); } - $parent = Book::visible()->where('id', '=', $entityId)->first(); - if ($parent === null) { - 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'); } - $chapter->changeBook($parent->id); - $chapter->rebuildPermissions(); - return $parent; + 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(); } } diff --git a/app/Entities/Repos/DeletionRepo.php b/app/Entities/Repos/DeletionRepo.php new file mode 100644 index 00000000000..5b67e5e6b52 --- /dev/null +++ b/app/Entities/Repos/DeletionRepo.php @@ -0,0 +1,34 @@ +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); + } +} diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index e5f13463c38..375bf1d2bc1 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -1,135 +1,56 @@ -baseRepo = $baseRepo; - } - - /** - * Get a page by ID. - * @throws NotFoundException - */ - public function getById(int $id): Page - { - $page = Page::visible()->with(['book'])->find($id); - - if (!$page) { - throw new NotFoundException(trans('errors.page_not_found')); - } - - return $page; - } - - /** - * Get a page its book and own slug. - * @throws NotFoundException - */ - public function getBySlug(string $bookSlug, string $pageSlug): Page - { - $page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->first(); - - if (!$page) { - throw new NotFoundException(trans('errors.page_not_found')); - } - - return $page; - } - - /** - * Get a page by its old slug but checking the revisions table - * for the last revision that matched the given page and book slug. - */ - public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page - { - $revision = PageRevision::query() - ->whereHas('page', function (Builder $query) { - $query->visible(); - }) - ->where('slug', '=', $pageSlug) - ->where('type', '=', 'version') - ->where('book_slug', '=', $bookSlug) - ->orderBy('created_at', 'desc') - ->with('page') - ->first(); - return $revision ? $revision->page : null; - } - - /** - * Get pages that have been marked as a template. - */ - public function getTemplates(int $count = 10, int $page = 1, string $search = ''): LengthAwarePaginator - { - $query = Page::visible() - ->where('template', '=', true) - ->orderBy('name', 'asc') - ->skip(($page - 1) * $count) - ->take($count); - - if ($search) { - $query->where('name', 'like', '%' . $search . '%'); - } - - $paginator = $query->paginate($count, ['*'], 'page', $page); - $paginator->withPath('/templates'); - - return $paginator; - } - - /** - * Get a parent item via slugs. - */ - public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity - { - if ($chapterSlug !== null) { - return $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail(); - } - - return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail(); - } - - /** - * Get the draft copy of the given page for the current user. - */ - public function getUserDraft(Page $page): ?PageRevision - { - $revision = $this->getUserDraftQuery($page)->first(); - return $revision; + public function __construct( + protected BaseRepo $baseRepo, + protected RevisionRepo $revisionRepo, + protected EntityQueries $entityQueries, + protected ReferenceStore $referenceStore, + protected ReferenceUpdater $referenceUpdater, + protected TrashCan $trashCan, + protected ParentChanger $parentChanger, + ) { } /** * Get a new draft page belonging to the given parent entity. */ - public function getNewDraftPage(Entity $parent) + public function getNewDraftPage(Entity $parent): Page { $page = (new Page())->forceFill([ - 'name' => trans('entities.pages_initial_name'), + 'name' => trans('entities.pages_initial_name'), 'created_by' => user()->id, + 'owned_by' => user()->id, 'updated_by' => user()->id, - 'draft' => true, + 'draft' => true, + 'editor' => PageEditorType::getSystemDefault()->value, + 'html' => '', + 'markdown' => '', + 'text' => '', ]); if ($parent instanceof Chapter) { @@ -139,8 +60,20 @@ public function getNewDraftPage(Entity $parent) $page->book_id = $parent->id; } - $page->save(); - $page->refresh()->rebuildPermissions(); + $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; } @@ -149,22 +82,35 @@ public function getNewDraftPage(Entity $parent) */ public function publishDraft(Page $draft, array $input): Page { - $this->baseRepo->update($draft, $input); - if (isset($input['template']) && userCan('templates-manage')) { - $draft->template = ($input['template'] === 'true'); - } + return (new DatabaseTransaction(function () use ($draft, $input) { + $draft->draft = false; + $draft->revision_count = 1; + $draft->priority = $this->getNewPriority($draft); + $this->updateTemplateStatusAndContentFromInput($draft, $input); - $pageContent = new PageContent($draft); - $pageContent->setNewHTML($input['html']); - $draft->draft = false; - $draft->revision_count = 1; - $draft->priority = $this->getNewPriority($draft); - $draft->refreshSlug(); - $draft->save(); + $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); - $this->savePageRevision($draft, trans('entities.pages_initial_revision')); - $draft->indexForSearch(); - return $draft->refresh(); + 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, []); } /** @@ -173,98 +119,108 @@ public function publishDraft(Page $draft, array $input): Page public function update(Page $page, array $input): Page { // Hold the old details to compare later - $oldHtml = $page->html; $oldName = $page->name; + $oldHtml = $page->html; + $oldMarkdown = $page->markdown; - if (isset($input['template']) && userCan('templates-manage')) { - $page->template = ($input['template'] === 'true'); - } - - $pageContent = new PageContent($page); - $pageContent->setNewHTML($input['html']); - $this->baseRepo->update($page, $input); + $this->updateTemplateStatusAndContentFromInput($page, $input); + $page = $this->baseRepo->update($page, $input); // Update with new details $page->revision_count++; - - if (setting('app-editor') !== 'markdown') { - $page->markdown = ''; - } - $page->save(); - // Remove all update drafts for this user & page. - $this->getUserDraftQuery($page)->delete(); + // Remove all update drafts for this user and page. + $this->revisionRepo->deleteDraftsForCurrentUser($page); // Save a revision after updating - $summary = $input['summary'] ?? null; - if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $summary !== null) { - $this->savePageRevision($page, $summary); + $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; } - /** - * Saves a page revision into the system. - */ - protected function savePageRevision(Page $page, string $summary = null) + protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void { - $revision = new PageRevision($page->getAttributes()); + if (isset($input['template']) && userCan(Permission::TemplatesManage)) { + $page->template = ($input['template'] === 'true'); + } - if (setting('app-editor') !== 'markdown') { - $revision->markdown = ''; + $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()); } - $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; + 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) + public function updatePageDraft(Page $page, array $input): Page|PageRevision { - // If the page itself is a draft simply update that + // If the page itself is a draft, simply update that if ($page->draft) { - $page->fill($input); - if (isset($input['html'])) { - $content = new PageContent($page); - $content->setNewHTML($input['html']); - } + $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->getPageRevisionToUpdate($page); + // Otherwise, save the data to a revision + $draft = $this->revisionRepo->getNewDraftForCurrentUser($page); $draft->fill($input); - if (setting('app-editor') !== 'markdown') { + + 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 NotifyException + * + * @throws Exception */ - public function destroy(Page $page) + public function destroy(Page $page): void { - $trashCan = new TrashCan(); - $trashCan->destroyPage($page); + $this->trashCan->softDestroyPage($page); + Activity::add(ActivityType::PAGE_DELETE, $page); + $this->trashCan->autoClearOld(); } /** @@ -272,189 +228,88 @@ public function destroy(Page $page) */ public function restoreRevision(Page $page, int $revisionId): Page { + $oldUrl = $page->getUrl(); $page->revision_count++; - $this->savePageRevision($page); + /** @var PageRevision $revision */ $revision = $page->revisions()->where('id', '=', $revisionId)->first(); + $page->fill($revision->toArray()); $content = new PageContent($page); - $content->setNewHTML($revision->html); + + if (!empty($revision->markdown)) { + $content->setNewMarkdown($revision->markdown, user()); + } else { + $content->setNewHTML($revision->html, user()); + } + $page->updated_by = user()->id; - $page->refreshSlug(); + $this->baseRepo->refreshSlug($page); $page->save(); - $page->indexForSearch(); - return $page; - } + $this->referenceStore->updateForEntity($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): Book - { - $parent = $this->findParentByIdentifier($parentIdentifier); - if ($parent === null) { - throw new MoveOperationException('Book or chapter to move page into not found'); - } + $summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]); + $this->revisionRepo->storeNewForPage($page, $summary); - if (!userCan('page-create', $parent)) { - throw new PermissionsException('User does not have permission to create a page within the new parent'); + if ($oldUrl !== $page->getUrl()) { + $this->referenceUpdater->updateEntityReferences($page, $oldUrl); } - $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null; - $page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id); - $page->rebuildPermissions(); + Activity::add(ActivityType::PAGE_RESTORE, $page); + Activity::add(ActivityType::REVISION_RESTORE, $revision); - return ($parent instanceof Book ? $parent : $parent->book); + $this->baseRepo->sortParent($page); + + return $page; } /** - * Copy an existing page in the system. - * Optionally providing a new parent via string identifier and a new name. + * 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 copy(Page $page, string $parentIdentifier = null, string $newName = null): Page + public function move(Page $page, string $parentIdentifier): Entity { - $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->parent(); - if ($parent === null) { + $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('page-create', $parent)) { + if (!userCan(Permission::PageCreate, $parent)) { throw new PermissionsException('User does not have permission to create a page within the new parent'); } - $copyPage = $this->getNewDraftPage($parent); - $pageData = $page->getAttributes(); - - // Update name - if (!empty($newName)) { - $pageData['name'] = $newName; - } - - // Copy tags from previous page if set - if ($page->tags) { - $pageData['tags'] = []; - foreach ($page->tags as $tag) { - $pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value]; - } - } - - return $this->publishDraft($copyPage, $pageData); - } - - /** - * Find a page parent entity via a identifier string in the format: - * {type}:{id} - * Example: (book:5) - * @throws MoveOperationException - */ - protected function findParentByIdentifier(string $identifier): ?Entity - { - $stringExploded = explode(':', $identifier); - $entityType = $stringExploded[0]; - $entityId = intval($stringExploded[1]); - - if ($entityType !== 'book' && $entityType !== 'chapter') { - throw new MoveOperationException('Pages can only be in books or chapters'); - } - - $parentClass = $entityType === 'book' ? Book::class : Chapter::class; - return $parentClass::visible()->where('id', '=', $entityId)->first(); - } - - /** - * Update the permissions of a page. - */ - public function updatePermissions(Page $page, bool $restricted, Collection $permissions = null) - { - $this->baseRepo->updatePermissions($page, $restricted, $permissions); - } - - /** - * Change the page's parent to the given entity. - */ - protected function changeParent(Page $page, Entity $parent) - { - $book = ($parent instanceof Book) ? $parent : $parent->book; - $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0; - $page->save(); - - if ($page->book->id !== $book->id) { - $page->changeBook($book->id); - } - - $page->load('book'); - $book->rebuildPermissions(); - } - - /** - * Get a page revision to update for the given page. - * Checks for an existing revisions before providing a fresh one. - */ - protected function getPageRevisionToUpdate(Page $page): PageRevision - { - $drafts = $this->getUserDraftQuery($page)->get(); - if ($drafts->count() > 0) { - return $drafts->first(); - } + 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(); - $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; - } + Activity::add(ActivityType::PAGE_MOVE, $page); - /** - * Delete old revisions, for the given page, from the system. - */ - protected function deleteOldRevisions(Page $page) - { - $revisionLimit = config('app.revision_limit'); - if ($revisionLimit === false) { - return; - } + $this->baseRepo->sortParent($page); - $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(); - } + return $parent; + }))->run(); } /** - * Get a new priority for a page + * Get a new priority for a page. */ protected function getNewPriority(Page $page): int { - if ($page->parent() instanceof Chapter) { - $lastPage = $page->parent()->pages('desc')->first(); + $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; } - - /** - * Get the query to find the user's draft copies of the given page. - */ - protected function getUserDraftQuery(Page $page) - { - return PageRevision::query()->where('created_by', '=', user()->id) - ->where('type', 'update_draft') - ->where('page_id', '=', $page->id) - ->orderBy('created_at', 'desc'); - } } diff --git a/app/Entities/Repos/RevisionRepo.php b/app/Entities/Repos/RevisionRepo.php new file mode 100644 index 00000000000..2d1371b63bd --- /dev/null +++ b/app/Entities/Repos/RevisionRepo.php @@ -0,0 +1,93 @@ +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(); + } + } +} diff --git a/app/Entities/SearchService.php b/app/Entities/SearchService.php deleted file mode 100644 index ee9b87786a5..00000000000 --- a/app/Entities/SearchService.php +++ /dev/null @@ -1,537 +0,0 @@ -=', '=', '<', '>', 'like', '!=']; - - /** - * SearchService constructor. - * @param SearchTerm $searchTerm - * @param EntityProvider $entityProvider - * @param Connection $db - * @param PermissionService $permissionService - */ - public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider, Connection $db, PermissionService $permissionService) - { - $this->searchTerm = $searchTerm; - $this->entityProvider = $entityProvider; - $this->db = $db; - $this->permissionService = $permissionService; - } - - /** - * Set the database connection - * @param Connection $connection - */ - public function setConnection(Connection $connection) - { - $this->db = $connection; - } - - /** - * Search all entities in the system. - * @param string $searchString - * @param string $entityType - * @param int $page - * @param int $count - Count of each entity to search, Total returned could can be larger and not guaranteed. - * @param string $action - * @return array[int, Collection]; - */ - public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20, $action = 'view') - { - $terms = $this->parseSearchString($searchString); - $entityTypes = array_keys($this->entityProvider->all()); - $entityTypesToSearch = $entityTypes; - - if ($entityType !== 'all') { - $entityTypesToSearch = $entityType; - } else if (isset($terms['filters']['type'])) { - $entityTypesToSearch = explode('|', $terms['filters']['type']); - } - - $results = collect(); - $total = 0; - $hasMore = false; - - foreach ($entityTypesToSearch as $entityType) { - if (!in_array($entityType, $entityTypes)) { - continue; - } - $search = $this->searchEntityTable($terms, $entityType, $page, $count, $action); - $entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, $action, true); - if ($entityTotal > $page * $count) { - $hasMore = true; - } - $total += $entityTotal; - $results = $results->merge($search); - } - - return [ - 'total' => $total, - 'count' => count($results), - 'has_more' => $hasMore, - 'results' => $results->sortByDesc('score')->values() - ]; - } - - - /** - * Search a book for entities - * @param integer $bookId - * @param string $searchString - * @return Collection - */ - public function searchBook($bookId, $searchString) - { - $terms = $this->parseSearchString($searchString); - $entityTypes = ['page', 'chapter']; - $entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes; - - $results = collect(); - foreach ($entityTypesToSearch as $entityType) { - if (!in_array($entityType, $entityTypes)) { - continue; - } - $search = $this->buildEntitySearchQuery($terms, $entityType)->where('book_id', '=', $bookId)->take(20)->get(); - $results = $results->merge($search); - } - return $results->sortByDesc('score')->take(20); - } - - /** - * Search a book for entities - * @param integer $chapterId - * @param string $searchString - * @return Collection - */ - public function searchChapter($chapterId, $searchString) - { - $terms = $this->parseSearchString($searchString); - $pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get(); - return $pages->sortByDesc('score'); - } - - /** - * Search across a particular entity type. - * @param array $terms - * @param string $entityType - * @param int $page - * @param int $count - * @param string $action - * @param bool $getCount Return the total count of the search - * @return \Illuminate\Database\Eloquent\Collection|int|static[] - */ - public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $action = 'view', $getCount = false) - { - $query = $this->buildEntitySearchQuery($terms, $entityType, $action); - if ($getCount) { - return $query->count(); - } - - $query = $query->skip(($page-1) * $count)->take($count); - return $query->get(); - } - - /** - * Create a search query for an entity - * @param array $terms - * @param string $entityType - * @param string $action - * @return EloquentBuilder - */ - protected function buildEntitySearchQuery($terms, $entityType = 'page', $action = 'view') - { - $entity = $this->entityProvider->get($entityType); - $entitySelect = $entity->newQuery(); - - // Handle normal search terms - if (count($terms['search']) > 0) { - $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score')); - $subQuery->where('entity_type', '=', $entity->getMorphClass()); - $subQuery->where(function (Builder $query) use ($terms) { - foreach ($terms['search'] as $inputTerm) { - $query->orWhere('term', 'like', $inputTerm .'%'); - } - })->groupBy('entity_type', 'entity_id'); - $entitySelect->join(\DB::raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) { - $join->on('id', '=', 'entity_id'); - })->selectRaw($entity->getTable().'.*, s.score')->orderBy('score', 'desc'); - $entitySelect->mergeBindings($subQuery); - } - - // Handle exact term matching - if (count($terms['exact']) > 0) { - $entitySelect->where(function (EloquentBuilder $query) use ($terms, $entity) { - foreach ($terms['exact'] as $inputTerm) { - $query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) { - $query->where('name', 'like', '%'.$inputTerm .'%') - ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%'); - }); - } - }); - } - - // Handle tag searches - foreach ($terms['tags'] as $inputTerm) { - $this->applyTagSearch($entitySelect, $inputTerm); - } - - // Handle filters - foreach ($terms['filters'] as $filterTerm => $filterValue) { - $functionName = Str::camel('filter_' . $filterTerm); - if (method_exists($this, $functionName)) { - $this->$functionName($entitySelect, $entity, $filterValue); - } - } - - return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action); - } - - - /** - * Parse a search string into components. - * @param $searchString - * @return array - */ - protected function parseSearchString($searchString) - { - $terms = [ - 'search' => [], - 'exact' => [], - 'tags' => [], - 'filters' => [] - ]; - - $patterns = [ - 'exact' => '/"(.*?)"/', - 'tags' => '/\[(.*?)\]/', - 'filters' => '/\{(.*?)\}/' - ]; - - // Parse special terms - foreach ($patterns as $termType => $pattern) { - $matches = []; - preg_match_all($pattern, $searchString, $matches); - if (count($matches) > 0) { - $terms[$termType] = $matches[1]; - $searchString = preg_replace($pattern, '', $searchString); - } - } - - // Parse standard terms - foreach (explode(' ', trim($searchString)) as $searchTerm) { - if ($searchTerm !== '') { - $terms['search'][] = $searchTerm; - } - } - - // Split filter values out - $splitFilters = []; - foreach ($terms['filters'] as $filter) { - $explodedFilter = explode(':', $filter, 2); - $splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : ''; - } - $terms['filters'] = $splitFilters; - - return $terms; - } - - /** - * Get the available query operators as a regex escaped list. - * @return mixed - */ - protected function getRegexEscapedOperators() - { - $escapedOperators = []; - foreach ($this->queryOperators as $operator) { - $escapedOperators[] = preg_quote($operator); - } - return join('|', $escapedOperators); - } - - /** - * Apply a tag search term onto a entity query. - * @param EloquentBuilder $query - * @param string $tagTerm - * @return mixed - */ - protected function applyTagSearch(EloquentBuilder $query, $tagTerm) - { - preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit); - $query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) { - $tagName = $tagSplit[1]; - $tagOperator = count($tagSplit) > 2 ? $tagSplit[3] : ''; - $tagValue = count($tagSplit) > 3 ? $tagSplit[4] : ''; - $validOperator = in_array($tagOperator, $this->queryOperators); - if (!empty($tagOperator) && !empty($tagValue) && $validOperator) { - if (!empty($tagName)) { - $query->where('name', '=', $tagName); - } - if (is_numeric($tagValue) && $tagOperator !== 'like') { - // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will - // search the value as a string which prevents being able to do number-based operations - // on the tag values. We ensure it has a numeric value and then cast it just to be sure. - $tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'"); - $query->whereRaw("value ${tagOperator} ${tagValue}"); - } else { - $query->where('value', $tagOperator, $tagValue); - } - } else { - $query->where('name', '=', $tagName); - } - }); - return $query; - } - - /** - * Index the given entity. - * @param Entity $entity - */ - public function indexEntity(Entity $entity) - { - $this->deleteEntityTerms($entity); - $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor); - $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor); - $terms = array_merge($nameTerms, $bodyTerms); - foreach ($terms as $index => $term) { - $terms[$index]['entity_type'] = $entity->getMorphClass(); - $terms[$index]['entity_id'] = $entity->id; - } - $this->searchTerm->newQuery()->insert($terms); - } - - /** - * Index multiple Entities at once - * @param \BookStack\Entities\Entity[] $entities - */ - protected function indexEntities($entities) - { - $terms = []; - foreach ($entities as $entity) { - $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor); - $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor); - foreach (array_merge($nameTerms, $bodyTerms) as $term) { - $term['entity_id'] = $entity->id; - $term['entity_type'] = $entity->getMorphClass(); - $terms[] = $term; - } - } - - $chunkedTerms = array_chunk($terms, 500); - foreach ($chunkedTerms as $termChunk) { - $this->searchTerm->newQuery()->insert($termChunk); - } - } - - /** - * Delete and re-index the terms for all entities in the system. - */ - public function indexAllEntities() - { - $this->searchTerm->truncate(); - - foreach ($this->entityProvider->all() as $entityModel) { - $selectFields = ['id', 'name', $entityModel->textField]; - $entityModel->newQuery()->select($selectFields)->chunk(1000, function ($entities) { - $this->indexEntities($entities); - }); - } - } - - /** - * Delete related Entity search terms. - * @param Entity $entity - */ - public function deleteEntityTerms(Entity $entity) - { - $entity->searchTerms()->delete(); - } - - /** - * Create a scored term array from the given text. - * @param $text - * @param float|int $scoreAdjustment - * @return array - */ - protected function generateTermArrayFromText($text, $scoreAdjustment = 1) - { - $tokenMap = []; // {TextToken => OccurrenceCount} - $splitChars = " \n\t.,!?:;()[]{}<>`'\""; - $token = strtok($text, $splitChars); - - while ($token !== false) { - if (!isset($tokenMap[$token])) { - $tokenMap[$token] = 0; - } - $tokenMap[$token]++; - $token = strtok($splitChars); - } - - $terms = []; - foreach ($tokenMap as $token => $count) { - $terms[] = [ - 'term' => $token, - 'score' => $count * $scoreAdjustment - ]; - } - return $terms; - } - - - - - /** - * Custom entity search filters - */ - - protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input) - { - try { - $date = date_create($input); - } catch (\Exception $e) { - return; - } - $query->where('updated_at', '>=', $date); - } - - protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input) - { - try { - $date = date_create($input); - } catch (\Exception $e) { - return; - } - $query->where('updated_at', '<', $date); - } - - protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input) - { - try { - $date = date_create($input); - } catch (\Exception $e) { - return; - } - $query->where('created_at', '>=', $date); - } - - protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, $input) - { - try { - $date = date_create($input); - } catch (\Exception $e) { - return; - } - $query->where('created_at', '<', $date); - } - - protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input) - { - if (!is_numeric($input) && $input !== 'me') { - return; - } - if ($input === 'me') { - $input = user()->id; - } - $query->where('created_by', '=', $input); - } - - protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, $input) - { - if (!is_numeric($input) && $input !== 'me') { - return; - } - if ($input === 'me') { - $input = user()->id; - } - $query->where('updated_by', '=', $input); - } - - protected function filterInName(EloquentBuilder $query, Entity $model, $input) - { - $query->where('name', 'like', '%' .$input. '%'); - } - - protected function filterInTitle(EloquentBuilder $query, Entity $model, $input) - { - $this->filterInName($query, $model, $input); - } - - protected function filterInBody(EloquentBuilder $query, Entity $model, $input) - { - $query->where($model->textField, 'like', '%' .$input. '%'); - } - - protected function filterIsRestricted(EloquentBuilder $query, Entity $model, $input) - { - $query->where('restricted', '=', true); - } - - protected function filterViewedByMe(EloquentBuilder $query, Entity $model, $input) - { - $query->whereHas('views', function ($query) { - $query->where('user_id', '=', user()->id); - }); - } - - protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, $input) - { - $query->whereDoesntHave('views', function ($query) { - $query->where('user_id', '=', user()->id); - }); - } - - protected function filterSortBy(EloquentBuilder $query, Entity $model, $input) - { - $functionName = Str::camel('sort_by_' . $input); - if (method_exists($this, $functionName)) { - $this->$functionName($query, $model); - } - } - - - /** - * Sorting filter options - */ - - protected function sortByLastCommented(EloquentBuilder $query, Entity $model) - { - $commentsTable = $this->db->getTablePrefix() . 'comments'; - $morphClass = str_replace('\\', '\\\\', $model->getMorphClass()); - $commentQuery = $this->db->raw('(SELECT c1.entity_id, c1.entity_type, c1.created_at as last_commented FROM '.$commentsTable.' c1 LEFT JOIN '.$commentsTable.' c2 ON (c1.entity_id = c2.entity_id AND c1.entity_type = c2.entity_type AND c1.created_at < c2.created_at) WHERE c1.entity_type = \''. $morphClass .'\' AND c2.created_at IS NULL) as comments'); - - $query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id')->orderBy('last_commented', 'desc'); - } -} diff --git a/app/Entities/SearchTerm.php b/app/Entities/SearchTerm.php deleted file mode 100644 index 886c4dbc1fe..00000000000 --- a/app/Entities/SearchTerm.php +++ /dev/null @@ -1,19 +0,0 @@ -morphTo('entity'); - } -} diff --git a/app/Entities/SlugGenerator.php b/app/Entities/SlugGenerator.php deleted file mode 100644 index 459a5264a42..00000000000 --- a/app/Entities/SlugGenerator.php +++ /dev/null @@ -1,62 +0,0 @@ -entity = $entity; - } - - /** - * Generate a fresh slug for the given entity. - * The slug will generated so it does not conflict within the same parent item. - */ - public function generate(): string - { - $slug = $this->formatNameAsSlug($this->entity->name); - while ($this->slugInUse($slug)) { - $slug .= '-' . substr(md5(rand(1, 500)), 0, 3); - } - return $slug; - } - - /** - * Format a name as a url slug. - */ - protected function formatNameAsSlug(string $name): string - { - $slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name)); - $slug = preg_replace('/\s{2,}/', ' ', $slug); - $slug = str_replace(' ', '-', $slug); - if ($slug === "") { - $slug = substr(md5(rand(1, 500)), 0, 5); - } - return $slug; - } - - /** - * Check if a slug is already in-use for this - * type of model within the same parent. - */ - protected function slugInUse(string $slug): bool - { - $query = $this->entity->newQuery()->where('slug', '=', $slug); - - if ($this->entity instanceof BookChild) { - $query->where('book_id', '=', $this->entity->book_id); - } - - if ($this->entity->id) { - $query->where('id', '!=', $this->entity->id); - } - - return $query->count() > 0; - } -} diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php new file mode 100644 index 00000000000..4bbab626520 --- /dev/null +++ b/app/Entities/Tools/BookContents.php @@ -0,0 +1,105 @@ +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(); + } +} diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php new file mode 100644 index 00000000000..64c48c351ae --- /dev/null +++ b/app/Entities/Tools/Cloner.php @@ -0,0 +1,201 @@ +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; + } +} diff --git a/app/Entities/Tools/EntityCover.php b/app/Entities/Tools/EntityCover.php new file mode 100644 index 00000000000..1e8fce201dd --- /dev/null +++ b/app/Entities/Tools/EntityCover.php @@ -0,0 +1,75 @@ +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; + } + } +} diff --git a/app/Entities/Tools/EntityDefaultTemplate.php b/app/Entities/Tools/EntityDefaultTemplate.php new file mode 100644 index 00000000000..d36c3f270e8 --- /dev/null +++ b/app/Entities/Tools/EntityDefaultTemplate.php @@ -0,0 +1,60 @@ +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; + } +} diff --git a/app/Entities/Tools/EntityHtmlDescription.php b/app/Entities/Tools/EntityHtmlDescription.php new file mode 100644 index 00000000000..052088c04d6 --- /dev/null +++ b/app/Entities/Tools/EntityHtmlDescription.php @@ -0,0 +1,67 @@ +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; + } +} diff --git a/app/Entities/Tools/EntityHydrator.php b/app/Entities/Tools/EntityHydrator.php new file mode 100644 index 00000000000..87e39d222ec --- /dev/null +++ b/app/Entities/Tools/EntityHydrator.php @@ -0,0 +1,140 @@ +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); + } + } + } +} diff --git a/app/Entities/Tools/HierarchyTransformer.php b/app/Entities/Tools/HierarchyTransformer.php new file mode 100644 index 00000000000..c58d29bd073 --- /dev/null +++ b/app/Entities/Tools/HierarchyTransformer.php @@ -0,0 +1,84 @@ +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; + } +} diff --git a/app/Entities/Tools/Markdown/CheckboxConverter.php b/app/Entities/Tools/Markdown/CheckboxConverter.php new file mode 100644 index 00000000000..6d872330afb --- /dev/null +++ b/app/Entities/Tools/Markdown/CheckboxConverter.php @@ -0,0 +1,28 @@ +getAttribute('type')) === 'checkbox') { + $isChecked = $element->getAttribute('checked') === 'checked'; + + return $isChecked ? ' [x] ' : ' [ ] '; + } + + return $element->getValue(); + } + + /** + * @return string[] + */ + public function getSupportedTags(): array + { + return ['input']; + } +} diff --git a/app/Entities/Tools/Markdown/CustomDivConverter.php b/app/Entities/Tools/Markdown/CustomDivConverter.php new file mode 100644 index 00000000000..48606239094 --- /dev/null +++ b/app/Entities/Tools/Markdown/CustomDivConverter.php @@ -0,0 +1,20 @@ +getAttribute('drawio-diagram'); + if ($drawIoDiagram) { + return "
{$element->getValue()}
\n\n"; + } + + return parent::convert($element); + } +} diff --git a/app/Entities/Tools/Markdown/CustomImageConverter.php b/app/Entities/Tools/Markdown/CustomImageConverter.php new file mode 100644 index 00000000000..6642b292a69 --- /dev/null +++ b/app/Entities/Tools/Markdown/CustomImageConverter.php @@ -0,0 +1,25 @@ +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); + } +} diff --git a/app/Entities/Tools/Markdown/CustomListItemRenderer.php b/app/Entities/Tools/Markdown/CustomListItemRenderer.php new file mode 100644 index 00000000000..0c506d7f9b5 --- /dev/null +++ b/app/Entities/Tools/Markdown/CustomListItemRenderer.php @@ -0,0 +1,43 @@ +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; + } +} diff --git a/app/Entities/Tools/Markdown/CustomParagraphConverter.php b/app/Entities/Tools/Markdown/CustomParagraphConverter.php new file mode 100644 index 00000000000..db36042cd71 --- /dev/null +++ b/app/Entities/Tools/Markdown/CustomParagraphConverter.php @@ -0,0 +1,19 @@ +getAttribute('class')); + if (strpos($class, 'callout') !== false) { + return "<{$element->getTagName()} class=\"{$class}\">{$element->getValue()}getTagName()}>\n\n"; + } + + return parent::convert($element); + } +} diff --git a/app/Entities/Tools/Markdown/CustomStrikeThroughExtension.php b/app/Entities/Tools/Markdown/CustomStrikeThroughExtension.php new file mode 100644 index 00000000000..ee4e9339751 --- /dev/null +++ b/app/Entities/Tools/Markdown/CustomStrikeThroughExtension.php @@ -0,0 +1,17 @@ +addDelimiterProcessor(new StrikethroughDelimiterProcessor()); + $environment->addRenderer(Strikethrough::class, new CustomStrikethroughRenderer()); + } +} diff --git a/app/Entities/Tools/Markdown/CustomStrikethroughRenderer.php b/app/Entities/Tools/Markdown/CustomStrikethroughRenderer.php new file mode 100644 index 00000000000..01b09377bb7 --- /dev/null +++ b/app/Entities/Tools/Markdown/CustomStrikethroughRenderer.php @@ -0,0 +1,24 @@ + 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())); + } +} diff --git a/app/Entities/Tools/Markdown/HtmlToMarkdown.php b/app/Entities/Tools/Markdown/HtmlToMarkdown.php new file mode 100644 index 00000000000..473435c7f0c --- /dev/null +++ b/app/Entities/Tools/Markdown/HtmlToMarkdown.php @@ -0,0 +1,93 @@ +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