Skip to content

Commit

Permalink
Prohibit error response caching (#2118)
Browse files Browse the repository at this point in the history
  • Loading branch information
mvorisek authored Oct 8, 2023
1 parent 1475c66 commit 1264670
Show file tree
Hide file tree
Showing 17 changed files with 185 additions and 68 deletions.
40 changes: 26 additions & 14 deletions demos/_unit-test/late-output-error.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,47 +13,59 @@
/** @var \Atk4\Ui\App $app */
require_once __DIR__ . '/../init-app.php';

$cbH1 = Callback::addTo($app);
$cbH1->setUrlTrigger('err_headers_already_sent_1');
$modalH1 = Modal::addTo($app, ['cb' => $cbH1]);
$modalH1->set(static function () {
$emitLateErrorHFx = static function () {
header('x-unmanaged-header: test');
flush();
});
};

$cbO1 = Callback::addTo($app);
$cbO1->setUrlTrigger('err_unexpected_output_detected_1');
$modalO1 = Modal::addTo($app, ['cb' => $cbO1]);
$modalO1->set(static function () {
$emitLateErrorOFx = static function () {
// unexpected output can be detected only when output buffering is enabled and not flushed
if (ob_get_level() === 0) {
ob_start();
}
echo 'unmanaged output';
});
};

$cbH1 = Callback::addTo($app);
$cbH1->setUrlTrigger('err_headers_already_sent_1');
$modalH1 = Modal::addTo($app, ['cb' => $cbH1]);
$modalH1->set($emitLateErrorHFx);

$cbO1 = Callback::addTo($app);
$cbO1->setUrlTrigger('err_unexpected_output_detected_1');
$modalO1 = Modal::addTo($app, ['cb' => $cbO1]);
$modalO1->set($emitLateErrorOFx);

$cbH2 = CallbackLater::addTo($app);
$cbH2->setUrlTrigger('err_headers_already_sent_2');
$modalH2 = Modal::addTo($app, ['cb' => $cbH2]);
$modalH2->set($modalH1->fx);
$modalH2->set($emitLateErrorHFx);

$cbO2 = CallbackLater::addTo($app);
$cbO2->setUrlTrigger('err_unexpected_output_detected_2');
$modalO2 = Modal::addTo($app, ['cb' => $cbO2]);
$modalO2->set($modalO1->fx);
$modalO2->set($emitLateErrorOFx);

Header::addTo($app, ['content' => 'Before render (/w Callback)']);
Header::addTo($app, ['content' => 'Modal /w Callback']);

$buttonH1 = Button::addTo($app, ['Test LateOutputError I: Headers already sent']);
$buttonH1->on('click', $modalH1->jsShow());

$buttonO1 = Button::addTo($app, ['Test LateOutputError I: Unexpected output detected']);
$buttonO1->on('click', $modalO1->jsShow());

Header::addTo($app, ['content' => 'After render (/w CallbackLater)']);
Header::addTo($app, ['content' => 'Modal /w CallbackLater']);

$buttonH2 = Button::addTo($app, ['Test LateOutputError II: Headers already sent']);
$buttonH2->on('click', $modalH2->jsShow());

$buttonO2 = Button::addTo($app, ['Test LateOutputError II: Unexpected output detected']);
$buttonO2->on('click', $modalO2->jsShow());

Header::addTo($app, ['content' => 'Button callback']);

$buttonH3 = Button::addTo($app, ['Test LateOutputError III: Headers already sent']);
$buttonH3->on('click', $emitLateErrorHFx);

$buttonO3 = Button::addTo($app, ['Test LateOutputError III: Unexpected output detected']);
$buttonO3->on('click', $emitLateErrorOFx);
19 changes: 15 additions & 4 deletions demos/init-db.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,21 @@ protected function isAllowDbModifications(): bool

public function atomic(\Closure $fx)
{
$connection = $this->getModel(true)->getPersistence()->getConnection(); // @phpstan-ignore-line
$eRollback = !$connection->inTransaction()
? new \Exception('Prevent modification')
: null; // TODO replace with atk4/data Connection before commit hook
$eRollback = true;
foreach (array_slice(debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT), 1) as $frame) {
if ($frame['function'] === 'atomic'
&& ($frame['class'] ?? null) === self::class
&& $frame['object']->getModel(true)->getPersistence() === $this->getModel(true)->getPersistence()
) {
$eRollback = null;

break;
}
}
if ($eRollback === true) {
$eRollback = new \Exception('Prevent modification');
}

$res = null;
try {
parent::atomic(function () use ($fx, $eRollback, &$res) {
Expand Down
2 changes: 2 additions & 0 deletions demos/layout/layouts_error.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@
/** @var \Atk4\Ui\App $app */
require_once __DIR__ . '/../init-app.php';

$app->setResponseHeader('Cache-Control', ''); // test if no-store header is sent even if removed

// next line produces exception, which Agile UI will catch and display nicely
View::addTo($app, ['foo' => 'bar']);
4 changes: 2 additions & 2 deletions js/src/helpers/table-dropdown.helper.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import $ from 'external/jquery';
import throttle from 'lodash/throttle';
import lodashThrottle from 'lodash/throttle';

/**
* Simple helper to help displaying Fomantic-UI Dropdown within an atk table.
Expand Down Expand Up @@ -48,7 +48,7 @@ function showTableDropdown() {
}

setCssPosition();
$(window).on('scroll.atktable', throttle(setCssPosition, 10));
$(window).on('scroll.atktable', lodashThrottle(setCssPosition, 10));
$(window).on('resize.atktable', () => {
$that.dropdown('hide');
});
Expand Down
39 changes: 20 additions & 19 deletions js/src/services/api.service.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import $ from 'external/jquery';
import atk from 'atk';
import lodashEscape from 'lodash/escape';

/**
* Handle Fomantic-UI API functionality throughout the app.
Expand Down Expand Up @@ -103,7 +104,7 @@ class ApiService {
throw new Error(response.message);
}
} catch (e) {
atk.apiService.showErrorModal(atk.apiService.getErrorHtml(e.message));
atk.apiService.showErrorModal(atk.apiService.getErrorHtml('API JavaScript Error', e.message));
}
}

Expand All @@ -124,13 +125,13 @@ class ApiService {
atk.apiService.showErrorModal(response.message);
} else {
// check if we have HTML returned by server with <body> content
// TODO test together /w onError using non-200 HTTP AJAX response code
const body = response.match(/<body[^>]*>[\S\s]*<\/body>/gi);
if (body) {
atk.apiService.showErrorModal(body);
} else {
atk.apiService.showErrorModal(response);
}
const body = response.match(/<html[^>]*>.*<body[^>]*>[\S\s]*<\/body>/gi);

atk.apiService.showErrorModal(atk.apiService.getErrorHtml('API Server Error', '') + '<div>' + (
body
? 'body'
: '<pre style="margin-bottom: 0px;"><code style="display: block; padding: 1em; color: #adbac7; background: #22272e;">' + lodashEscape(response) + '</code></pre>'
) + '</div>');
}
}

Expand All @@ -151,7 +152,7 @@ class ApiService {
* Will wrap Fomantic-UI api call into a Promise.
* Can be used to retrieve JSON data from the server.
* Using this will bypass regular successTest i.e. any
* atkjs (javascript) return from server will not be evaluated.
* atkjs (JavaScript) return from server will not be evaluated.
*
* Make sure to control the server output when using
* this function. It must at least return { success: true } in order for
Expand Down Expand Up @@ -193,7 +194,7 @@ class ApiService {
/**
* Display App error in a Fomantic-UI modal.
*/
showErrorModal(errorMsg) {
showErrorModal(contentHtml) {
if (atk.modalService.modals.length > 0) {
const $modal = $(atk.modalService.modals.at(-1));
if ($modal.data('closeOnLoadingError')) {
Expand All @@ -206,18 +207,18 @@ class ApiService {
.appendTo('body')
.addClass('ui scrolling modal')
.css('padding', '1em')
.html(errorMsg);
.html(contentHtml);
m.data('needRemove', true).modal().modal('show');
}

getErrorHtml(error) {
return `<div class="ui negative icon message">
<i class="warning sign icon"></i>
<div class="content">
<div class="header">Javascript Error</div>
<div>${error}</div>
</div>
</div>`;
getErrorHtml(titleHtml, messageHtml) {
return `<div class="ui negative icon message" style="margin: 0px;">
<i class="warning sign icon"></i>
<div class="content">
<div class="header">${titleHtml}</div>
<div>${messageHtml}</div>
</div>
</div>`;
}
}

Expand Down
2 changes: 1 addition & 1 deletion js/src/services/modal.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ class ModalService {
response.success = false;
response.isServiceError = true;
response.message = 'Modal service error: Empty HTML, unable to replace modal content from server response';
} else {
} else if (response.id) {
// content is replace no need to do it in api
response.id = null;
}
Expand Down
99 changes: 79 additions & 20 deletions public/js/atkjs-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -1908,6 +1908,8 @@ __webpack_require__.r(__webpack_exports__);
/* harmony import */ var external_jquery__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! external/jquery */ "external/jquery");
/* harmony import */ var external_jquery__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(external_jquery__WEBPACK_IMPORTED_MODULE_5__);
/* harmony import */ var atk__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! atk */ "./src/setup-atk.js");
/* harmony import */ var lodash_escape__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! lodash/escape */ "./node_modules/lodash/escape.js");




Expand Down Expand Up @@ -2011,7 +2013,7 @@ class ApiService {
throw new Error(response.message);
}
} catch (e) {
atk__WEBPACK_IMPORTED_MODULE_6__["default"].apiService.showErrorModal(atk__WEBPACK_IMPORTED_MODULE_6__["default"].apiService.getErrorHtml(e.message));
atk__WEBPACK_IMPORTED_MODULE_6__["default"].apiService.showErrorModal(atk__WEBPACK_IMPORTED_MODULE_6__["default"].apiService.getErrorHtml('API JavaScript Error', e.message));
}
}

Expand All @@ -2032,13 +2034,8 @@ class ApiService {
atk__WEBPACK_IMPORTED_MODULE_6__["default"].apiService.showErrorModal(response.message);
} else {
// check if we have HTML returned by server with <body> content
// TODO test together /w onError using non-200 HTTP AJAX response code
const body = response.match(/<body[^>]*>[\S\s]*<\/body>/gi);
if (body) {
atk__WEBPACK_IMPORTED_MODULE_6__["default"].apiService.showErrorModal(body);
} else {
atk__WEBPACK_IMPORTED_MODULE_6__["default"].apiService.showErrorModal(response);
}
const body = response.match(/<html[^>]*>.*<body[^>]*>[\S\s]*<\/body>/gi);
atk__WEBPACK_IMPORTED_MODULE_6__["default"].apiService.showErrorModal(atk__WEBPACK_IMPORTED_MODULE_6__["default"].apiService.getErrorHtml('API Server Error', '') + '<div>' + (body ? 'body' : '<pre style="margin-bottom: 0px;"><code style="display: block; padding: 1em; color: #adbac7; background: #22272e;">' + (0,lodash_escape__WEBPACK_IMPORTED_MODULE_7__["default"])(response) + '</code></pre>') + '</div>');
}
}

Expand All @@ -2060,7 +2057,7 @@ class ApiService {
* Will wrap Fomantic-UI api call into a Promise.
* Can be used to retrieve JSON data from the server.
* Using this will bypass regular successTest i.e. any
* atkjs (javascript) return from server will not be evaluated.
* atkjs (JavaScript) return from server will not be evaluated.
*
* Make sure to control the server output when using
* this function. It must at least return { success: true } in order for
Expand Down Expand Up @@ -2100,7 +2097,7 @@ class ApiService {
/**
* Display App error in a Fomantic-UI modal.
*/
showErrorModal(errorMsg) {
showErrorModal(contentHtml) {
if (atk__WEBPACK_IMPORTED_MODULE_6__["default"].modalService.modals.length > 0) {
const $modal = external_jquery__WEBPACK_IMPORTED_MODULE_5___default()(atk__WEBPACK_IMPORTED_MODULE_6__["default"].modalService.modals.at(-1));
if ($modal.data('closeOnLoadingError')) {
Expand All @@ -2109,17 +2106,17 @@ class ApiService {
}

// catch application error and display them in a new modal window
const m = external_jquery__WEBPACK_IMPORTED_MODULE_5___default()('<div>').appendTo('body').addClass('ui scrolling modal').css('padding', '1em').html(errorMsg);
const m = external_jquery__WEBPACK_IMPORTED_MODULE_5___default()('<div>').appendTo('body').addClass('ui scrolling modal').css('padding', '1em').html(contentHtml);
m.data('needRemove', true).modal().modal('show');
}
getErrorHtml(error) {
return `<div class="ui negative icon message">
<i class="warning sign icon"></i>
<div class="content">
<div class="header">Javascript Error</div>
<div>${error}</div>
</div>
</div>`;
getErrorHtml(titleHtml, messageHtml) {
return `<div class="ui negative icon message" style="margin: 0px;">
<i class="warning sign icon"></i>
<div class="content">
<div class="header">${titleHtml}</div>
<div>${messageHtml}</div>
</div>
</div>`;
}
}
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (Object.freeze(new ApiService()));
Expand Down Expand Up @@ -2587,7 +2584,7 @@ class ModalService {
response.success = false;
response.isServiceError = true;
response.message = 'Modal service error: Empty HTML, unable to replace modal content from server response';
} else {
} else if (response.id) {
// content is replace no need to do it in api
response.id = null;
}
Expand Down Expand Up @@ -43142,6 +43139,68 @@ function debounce(func, wait, options) {
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (debounce);


/***/ }),

/***/ "./node_modules/lodash/escape.js":
/*!***************************************!*\
!*** ./node_modules/lodash/escape.js ***!
\***************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "default": () => (__WEBPACK_DEFAULT_EXPORT__)
/* harmony export */ });
/** Used to map characters to HTML entities. */
const htmlEscapes = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}

/** Used to match HTML entities and HTML characters. */
const reUnescapedHtml = /[&<>"']/g
const reHasUnescapedHtml = RegExp(reUnescapedHtml.source)

/**
* Converts the characters "&", "<", ">", '"', and "'" in `string` to their
* corresponding HTML entities.
*
* **Note:** No other characters are escaped. To escape additional
* characters use a third-party library like [_he_](https://mths.be/he).
*
* Though the ">" character is escaped for symmetry, characters like
* ">" and "/" don't need escaping in HTML and have no special meaning
* unless they're part of a tag or unquoted attribute value. See
* [Mathias Bynens's article](https://mathiasbynens.be/notes/ambiguous-ampersands)
* (under "semi-related fun fact") for more details.
*
* When working with HTML you should always
* [quote attribute values](http://wonko.com/post/html-escaping) to reduce
* XSS vectors.
*
* @since 0.1.0
* @category String
* @param {string} [string=''] The string to escape.
* @returns {string} Returns the escaped string.
* @see escapeRegExp, unescape
* @example
*
* escape('fred, barney, & pebbles')
* // => 'fred, barney, &amp; pebbles'
*/
function escape(string) {
return (string && reHasUnescapedHtml.test(string))
? string.replace(reUnescapedHtml, (chr) => htmlEscapes[chr])
: (string || '')
}

/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (escape);


/***/ }),

/***/ "./node_modules/lodash/isObject.js":
Expand Down
2 changes: 1 addition & 1 deletion public/js/atkjs-ui.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/js/atkjs-ui.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/js/atkjs-ui.min.js.map

Large diffs are not rendered by default.

Loading

0 comments on commit 1264670

Please sign in to comment.