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 @@
):
?>
= $this->Form->label($fn, $label) ?>
-
-
= $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,