diff --git a/app/config/app.php b/app/config/app.php index e666069b8..cacdd6cde 100644 --- a/app/config/app.php +++ b/app/config/app.php @@ -522,7 +522,11 @@ 'defaults' => 'php', // Switch cookie name to avoid conflict with older versions of Registry // Note this name must match the name used in webroot/auth/*/* - 'cookie' => 'REGISTRYPECAKEPHP' + 'cookie' => 'REGISTRYPECAKEPHP', + // Allow caching: since the introduction of Mostly Static Resources, v5.3.0 + 'ini' => [ + 'session.cache_limiter' => '', + ], ], /** diff --git a/app/config/routes.php b/app/config/routes.php index b38497204..2b0f330fb 100644 --- a/app/config/routes.php +++ b/app/config/routes.php @@ -167,6 +167,8 @@ function (RouteBuilder $builder) { // Main application routes $routes->scope('/', function (RouteBuilder $builder) { // Register scoped middleware for in scopes. + $builder->registerMiddleware('postMaxSizeCheck', new \App\Middleware\PostMaxSizeCheckMiddleware()); + $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware([ 'httponly' => true, ])); @@ -178,8 +180,10 @@ function (RouteBuilder $builder) { /* * Apply a middleware to the current route scope. * Requires middleware to be registered through `Application::routes()` with `registerMiddleware()` + * Check for post_max_size overage first: PHP wipes the POST data when the threshold is crossed + * and will cause a CSRF failure if not caught (because the crsf token will be missing from the POST). */ - $builder->applyMiddleware('csrf'); + $builder->applyMiddleware('postMaxSizeCheck', 'csrf'); /* * Here, we are connecting '/' (base path) to a controller called 'Pages', diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index 2ed785f12..272454d38 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -493,8 +493,17 @@ msgstr "Unknown value \"{0}\"" msgid "unknown.identifier" msgstr "Unknown Identifier \"{0}\"" +msgid "upload.failed" +msgstr "File upload failed" + msgid "upload.maxsize" -msgstr "File size {0} exceeds maximum permitted {1}" +msgstr "File size {0} exceeds maximum permitted by CO Settings: {1}" + +msgid "upload.php.uploadmaxfilesize" +msgstr "File size exceeds maximum permitted by PHP settings (maximum upload size is {0})" + +msgid "upload.php.postmaxsize" +msgstr "The file size you attempted to upload is too large for this server to accept (maximum POST size is {0})." msgid "Verifications.already" msgstr "Email Address is already verified" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index 56c7e4843..6fd1d4d9a 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -236,6 +236,9 @@ msgstr "Metadata" msgid "middle" msgstr "Middle" +msgid "mime_type" +msgstr "Mime Type" + msgid "modifiable" msgstr "Modifiable" @@ -297,6 +300,12 @@ msgstr "IP Address" msgid "required" msgstr "Required" +msgid "resource.image" +msgstr "Resource Image" + +msgid "resource.url" +msgstr "Resource URL" + msgid "element_fallback" msgstr "Element ID not provided" @@ -894,7 +903,7 @@ msgid "MostlyStaticResources.name" msgstr "Slug" msgid "MostlyStaticResources.name.desc" -msgstr "The URL fragment for this Resource, which must be unique and use only lowercase alphanumeric characters and dashes (-)" +msgstr "The URL fragment for this Resource, which must be unique and use only lowercase alphanumeric characters, dashes (-), underscores (_), and periods (.)" msgid "Notifications.actor_person_id" msgstr "Actor" diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index 0ea3cec5d..bdd0a2530 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -217,6 +217,12 @@ public function beforeFilter(\Cake\Event\EventInterface $event) { $this->getAppPrefs(); + // Authenticated pages are not cached. Placing this here allows us to override + // caching in other contexts, such as for Mostly Static Resources. Cache-control + // was once a static header in the element/httpHeaders.php template file. + // This replaces that approach as of v5.3.0. + $this->response = $this->response->withDisabledCache(); + parent::beforeFilter($event); } @@ -706,8 +712,9 @@ protected function getTheme() { if(isset($theme)) { $htmlSanitizer = new HtmlSanitizer( // Allow all elements from the W3C Sanitizer API. This is more permissive than "allowSafeElements()". + // Also allow relative references, such as image src attributes to Mostly Static Resources. // See: https://github.com/symfony/symfony/blob/7.2/src/Symfony/Component/HtmlSanitizer/Reference/W3CReference.php - (new HtmlSanitizerConfig())->allowStaticElements() + (new HtmlSanitizerConfig())->allowStaticElements()->allowRelativeMedias() ); $theme->set([ diff --git a/app/src/Controller/PagesController.php b/app/src/Controller/PagesController.php index 8d563c341..35019e45b 100644 --- a/app/src/Controller/PagesController.php +++ b/app/src/Controller/PagesController.php @@ -57,6 +57,7 @@ public function initialize(): void * @since COmanage Registry v5.3.0 * @param string $coid CO ID * @param string $name MSR Name (slug) + * @return \Cake\Http\Response|null */ public function deliver(string $coid, string $name) { @@ -70,12 +71,14 @@ public function deliver(string $coid, string $name) { if(!$CoSettings->uploadsEnabled()) { $this->Flash->error(__d('error', 'MostlyStaticResources.disabled')); - return $this->redirect(StringUtilities::pagesUrl($coId, "error-landing")); + return $this->redirect(StringUtilities::pagesUrl($coid, "error-landing")); } $MSRTable = TableRegistry::getTableLocator()->get("MostlyStaticResources"); - + + // Begin by pulling only the necessary data to determine if the resource was changed. $msr = $MSRTable->find() + ->select(['id','mime_type','modified']) ->where([ 'co_id' => (int)$coid, 'name' => $name, @@ -83,15 +86,36 @@ public function deliver(string $coid, string $name) { ]) ->first(); + // Return an error if not found. if(empty($msr)) { $this->Flash->error(__d('error', 'notfound', $name)); - return $this->redirect(StringUtilities::pagesUrl($coId, "error-landing")); + return $this->redirect(StringUtilities::pagesUrl($coid, "error-landing")); + } + + // Generate an etag based on the id and modified date. + $etag = $msr->id . '-' . $msr->modified->getTimestamp(); + + $response = $this->response + ->withType($msr->mime_type) + ->withEtag($etag, true) + ->withCache($msr->modified->getTimestamp(), '+1 year'); + + // If there are no modifications, just return the response without pulling the file content. + if ($response->isNotModified($this->request)) { + return $response->withNotModified(); } - $fileContent = stream_get_contents($msr->file_content); + // There are modifications - get the content. + $msrContent = $MSRTable->find() + ->select(['file_content']) + ->where(['id' => $msr->id]) + ->first(); + + $fileContent = stream_get_contents($msrContent->file_content); + fclose($msrContent->file_content); - return $this->response->withType($msr->mime_type)->withStringBody($fileContent); + return $response->withStringBody($fileContent); } /** diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index ceb2b5b87..6da23d3f1 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -34,6 +34,7 @@ use App\Lib\Traits\IndexQueryTrait; use Cake\Database\Schema\TableSchemaInterface; use Cake\Datasource\ConnectionManager; +use Cake\I18n\Number; use Cake\ORM\TableRegistry; use Cake\Utility\Hash; use Cake\Utility\Inflector; @@ -830,13 +831,33 @@ protected function processFileUpload(TableSchemaInterface $schema, array &$data) if($CoSettings->uploadsEnabled()) { $upload = $data['file_content']; - if(!empty($upload) && $upload->getSize() > 0) { - // First verify size is within the configured limit + if(!empty($upload) && $upload->getError() !== UPLOAD_ERR_NO_FILE) { + // First ensure that the file upload didn't produce a PHP error, starting with upload_max_filesize. + if($upload->getError() === UPLOAD_ERR_INI_SIZE || $upload->getError() === UPLOAD_ERR_FORM_SIZE) { + throw new \InvalidArgumentException( + __d('error', 'upload.php.uploadmaxfilesize', [ + ini_get('upload_max_filesize') + ]) + ); + } + + // Any other PHP failure? partial upload, etc. + if($upload->getError() !== UPLOAD_ERR_OK) { + throw new \InvalidArgumentException( + __d('error', 'upload.failed') + ); + } + // Now verify that the file size is within the CO Settings configured limit $size = $upload->getSize(); if($size > $CoSettings->getUploadMaxSize()) { - throw new \InvalidArgumentException(__d('error', 'upload.maxsize', [$size, $CoSettings->getMsrMaxSize()])); + throw new \InvalidArgumentException( + __d('error', 'upload.maxsize', [ + Number::toReadableSize($size), + Number::toReadableSize($CoSettings->getUploadMaxSize()) + ]) + ); } // Next parse the mime-type. We can't rely on the client provided value, diff --git a/app/src/Middleware/PostMaxSizeCheckMiddleware.php b/app/src/Middleware/PostMaxSizeCheckMiddleware.php new file mode 100644 index 000000000..ad6018995 --- /dev/null +++ b/app/src/Middleware/PostMaxSizeCheckMiddleware.php @@ -0,0 +1,88 @@ +getMethod() === 'POST') { + $contentLength = (int)$request->getHeaderLine('Content-Length'); + $postMaxBytes = $this->iniSizeToBytes(ini_get('post_max_size')); + + if ($contentLength > 0 && $postMaxBytes > 0 && $contentLength > $postMaxBytes) { + // Route the user back to where they came from with an error message if possible. + $referer = $request->getHeaderLine('Referer'); + $session = $request->getAttribute('session'); + + if(empty($referer) || empty($session)) { + // There's no referer to reference (and possibly no session), so just show the more abrupt white-screen error. + throw new BadRequestException( + __d('error', 'upload.php.postmaxsize', [ini_get('post_max_size')]) + ); + } + + // We have a referer, so set the flash message and return. + $flash = new FlashMessage($session); + $flash->error(__d('error', 'upload.php.postmaxsize', [ini_get('post_max_size')])); + return (new Response())->withStatus(302)->withHeader('Location', $referer); + } + } + + return $handler->handle($request); + } + + protected function iniSizeToBytes(string $val): int { + $val = trim($val); + if ($val === '') { + return 0; + } + + $unit = strtolower($val[strlen($val) - 1]); + $num = (int)$val; + + // Convert the value to bytes by letting it fall through the switch statement starting at + // the correct unit. If we start with "g", the value will be multiplied by 1024 three times. + switch ($unit) { + case 'g': $num *= 1024; + case 'm': $num *= 1024; + case 'k': $num *= 1024; + } + + return $num; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/CoSettingsTable.php b/app/src/Model/Table/CoSettingsTable.php index 3e38b1abb..5333f7120 100644 --- a/app/src/Model/Table/CoSettingsTable.php +++ b/app/src/Model/Table/CoSettingsTable.php @@ -267,8 +267,8 @@ public function addDefaults(int $coId): int { 'platform_env_mfa_enable_eg' => false, 'platform_upload_enable' => false, // In general storing large files in the database is not performant, this number - // probably shouldn't be increased. - 'platform_upload_max_size' => 10000000 + // probably shouldn't be increased. This is 10MB in bytes. + 'platform_upload_max_size' => 10485760 // XXX to add new settings, set a default here, then add a validation rule below // also update data model documentation // 'disable_expiration' => false, diff --git a/app/src/Model/Table/MostlyStaticResourcesTable.php b/app/src/Model/Table/MostlyStaticResourcesTable.php index 05eeb9c60..72319bafe 100644 --- a/app/src/Model/Table/MostlyStaticResourcesTable.php +++ b/app/src/Model/Table/MostlyStaticResourcesTable.php @@ -125,10 +125,10 @@ public function validationDefault(Validator $validator): Validator { $this->registerStringValidation($validator, $schema, 'name', true); // AR-MostlyStaticResource-2 A Mostly Static Resource name may consist only of - // lowercase alphanumeric characters and dashes + // lowercase alphanumeric characters, dashes, underscores, and periods (for allowing extensions) $validator->add('name', [ 'slugfilter' => [ - 'rule' => ['custom', '/^[a-z0-9-]+$/'], + 'rule' => ['custom', '/^[a-z0-9._\-]+$/'], 'message' => __d('error', 'MostlyStaticPages.slug.invalid') ] ]); diff --git a/app/templates/CoSettings/fields.inc b/app/templates/CoSettings/fields.inc index bbc92a87b..c862a0a0d 100644 --- a/app/templates/CoSettings/fields.inc +++ b/app/templates/CoSettings/fields.inc @@ -83,7 +83,7 @@ if($vv_obj->co->isCOmanageCO()) { 'platform_upload_enable', // XXX maybe only show if platform_msr_enable=true? 'platform_upload_max_size' => [ - 'default' => 10000000 + 'default' => 10485760 // 10MB in bytes ] ]); } diff --git a/app/templates/MostlyStaticPages/fields.inc b/app/templates/MostlyStaticPages/fields.inc index 90893fa4c..49b2a8fa6 100644 --- a/app/templates/MostlyStaticPages/fields.inc +++ b/app/templates/MostlyStaticPages/fields.inc @@ -60,7 +60,7 @@ if($vv_action == 'edit') { + + \ No newline at end of file diff --git a/app/templates/element/form/nameDiv.php b/app/templates/element/form/nameDiv.php index 375436646..0610dc13f 100644 --- a/app/templates/element/form/nameDiv.php +++ b/app/templates/element/form/nameDiv.php @@ -90,8 +90,6 @@ ): ?> Form->label($fn, $label) ?> - - diff --git a/app/templates/element/httpHeaders.php b/app/templates/element/httpHeaders.php index e062a0db1..cab1ff895 100644 --- a/app/templates/element/httpHeaders.php +++ b/app/templates/element/httpHeaders.php @@ -25,9 +25,6 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ - // As a general rule, all Registry pages are post-login and so shouldn't be cached - header("Cache-Control: no-store, no-cache, max-age=0, must-revalidate"); - // CakePHP adds inline event handlers ("oninput" and "oninvalid") to fields as part of FormHelper. // So as not to throw CSP errors, we must include "script-src-attr 'unsafe-inline'". // To use VueJS as we do, we must also include "script-src 'unsafe-eval'" diff --git a/app/webroot/css/co-base.css b/app/webroot/css/co-base.css index 696489899..edeee3c28 100644 --- a/app/webroot/css/co-base.css +++ b/app/webroot/css/co-base.css @@ -2516,7 +2516,7 @@ body.termsandconditions ul.form-list li.fields-url { .tc-agree-dialog .modal-body h6 { margin-bottom: 0.5em; } -/* MOSTLY STATIC PAGES */ +/* MOSTLY STATIC PAGES and RESOURCES */ body.pages.logged-out #top-bar, body.pages.logged-out #breadcrumbs { display: none; @@ -2527,6 +2527,25 @@ body.pages.logged-out .page-title-container { .page-body { margin-top: 1em; } +#msr-image { + max-width: 100%; + height: auto; +} +#msr-image-container { + margin-bottom: 1rem; +} +#msr-url-container { + display: flex; + align-items: center; + gap: 1rem; +} +.field.msr-media-field { + border-left: 1px solid var(--cmg-color-bg-005); + border-right: 1px solid var(--cmg-color-bg-005); +} +.field.msr-media-image-field { + border-top: 1px solid var(--cmg-color-bg-005); +} /* GENERAL */ .hidden, .invisible,