diff options
-rw-r--r-- | css/html-response.css | 7 | ||||
-rwxr-xr-x | lib/Controller/MessagesController.php | 6 | ||||
-rw-r--r-- | lib/Http/HtmlResponse.php | 27 | ||||
-rw-r--r-- | package-lock.json | 5 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | src/components/Message.vue | 7 | ||||
-rw-r--r-- | src/components/MessageHTMLBody.vue | 38 | ||||
-rw-r--r-- | src/components/NewMessageDetail.vue | 2 | ||||
-rw-r--r-- | src/components/Thread.vue | 1 | ||||
-rw-r--r-- | src/components/ThreadEnvelope.vue | 10 | ||||
-rw-r--r-- | src/html-response.js | 38 | ||||
-rw-r--r-- | tests/Unit/Controller/MessagesControllerTest.php | 31 | ||||
-rw-r--r-- | tests/Unit/Http/HtmlResponseTest.php | 16 | ||||
-rw-r--r-- | webpack.common.js | 3 |
14 files changed, 161 insertions, 31 deletions
diff --git a/css/html-response.css b/css/html-response.css new file mode 100644 index 000000000..2b5c86d04 --- /dev/null +++ b/css/html-response.css @@ -0,0 +1,7 @@ +* { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', Arial, 'Noto Color Emoji', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; +} + +body { + color: var(--color-main-text); +} diff --git a/lib/Controller/MessagesController.php b/lib/Controller/MessagesController.php index b9882970b..9c4dbb62c 100755 --- a/lib/Controller/MessagesController.php +++ b/lib/Controller/MessagesController.php @@ -336,12 +336,13 @@ class MessagesController extends Controller { * @TrapError * * @param int $id + * @param bool $plain do not inject scripts if true (default=false) * * @return HtmlResponse|TemplateResponse * * @throws ClientException */ - public function getHtmlBody(int $id): Response { + public function getHtmlBody(int $id, bool $plain=false): Response { try { try { $message = $this->mailManager->getMessage($this->currentUserId, $id); @@ -364,7 +365,8 @@ class MessagesController extends Controller { true )->getHtmlBody( $id - ) + ), + $plain ); // Harden the default security policy diff --git a/lib/Http/HtmlResponse.php b/lib/Http/HtmlResponse.php index 9fbc6cdc3..5918dc16a 100644 --- a/lib/Http/HtmlResponse.php +++ b/lib/Http/HtmlResponse.php @@ -25,6 +25,7 @@ declare(strict_types=1); namespace OCA\Mail\Http; +use OCP\Util; use OCP\AppFramework\Http\Response; class HtmlResponse extends Response { @@ -32,22 +33,32 @@ class HtmlResponse extends Response { /** @var string */ private $content; - private $injectedStyles = <<<EOF -* { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', Arial, 'Noto Color Emoji', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; } -EOF; + /** @var bool */ + private $plain; - - public function __construct(string $content) { + /** + * @param string $content message html content + * @param bool $plain do not inject scripts if true (default=false) + */ + public function __construct(string $content, bool $plain=false) { parent::__construct(); $this->content = $content; + $this->plain = $plain; } /** - * Simply sets the headers and returns the file contents + * Inject scripts if not plain and return message html content. * - * @return string the file contents + * @return string message html content */ public function render(): string { - return '<style>' . $this->injectedStyles . '</style>' . $this->content; + if ($this->plain) { + return $this->content; + } + + $nonce = \OC::$server->getContentSecurityPolicyNonceManager()->getNonce(); + $scriptSrc = Util::linkToAbsolute('mail', 'js/htmlresponse.js'); + return '<script nonce="' . $nonce. '" src="' . $scriptSrc . '"></script>' + . $this->content; } } diff --git a/package-lock.json b/package-lock.json index eef5b54a7..0dd110791 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8024,6 +8024,11 @@ "integrity": "sha1-xg7taebY/bazEEofy8ocGS3FtQE=", "dev": true }, + "iframe-resizer": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/iframe-resizer/-/iframe-resizer-4.2.11.tgz", + "integrity": "sha512-fj5vX5kkpRbMb5Qje6veIDzqoJpnCEqUDdSOwASOeQHYmb8hLYX6Ev2yXf3jjMs2MclwcYY3chyZ3diGKcr8DA==" + }, "ignore": { "version": "5.1.8", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", diff --git a/package.json b/package.json index 381780ca5..9b70f5565 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "dompurify": "^2.2.0", "html-to-text": "^5.1.1", "ical.js": "^1.4.0", + "iframe-resizer": "^4.2.11", "js-base64": "^3.5.2", "lodash": "^4.17.20", "md5": "^2.3.0", diff --git a/src/components/Message.vue b/src/components/Message.vue index 2d5c49dbc..c3fc1fa00 100644 --- a/src/components/Message.vue +++ b/src/components/Message.vue @@ -24,7 +24,7 @@ <div v-if="message.itineraries.length > 0" class="message-itinerary"> <Itinerary :entries="message.itineraries" :message-id="message.messageId" /> </div> - <MessageHTMLBody v-if="message.hasHtmlBody" :url="htmlUrl" /> + <MessageHTMLBody v-if="message.hasHtmlBody" :url="htmlUrl" :full-height="fullHeight" /> <MessageEncryptedBody v-else-if="isEncrypted" :body="message.body" :from="from" /> <MessagePlainTextBody v-else :body="message.body" :signature="message.signature" /> <Popover v-if="message.attachments[0]" class="attachment-popover"> @@ -74,6 +74,11 @@ export default { required: true, type: Object, }, + fullHeight: { + required: false, + type: Boolean, + default: false, + }, }, computed: { from() { diff --git a/src/components/MessageHTMLBody.vue b/src/components/MessageHTMLBody.vue index 180027cb8..b0af31512 100644 --- a/src/components/MessageHTMLBody.vue +++ b/src/components/MessageHTMLBody.vue @@ -7,9 +7,9 @@ </button> </div> <div v-if="loading" class="icon-loading" /> - <div id="message-container" :class="{hidden: loading}"> - <iframe id="message-frame" - ref="iframe" + <div id="message-container" :class="{hidden: loading, scroll: !fullHeight}"> + <iframe ref="iframe" + class="message-frame" :title="t('mail', 'Message frame')" :src="url" seamless @@ -19,6 +19,7 @@ </template> <script> +import { iframeResizer } from 'iframe-resizer' import PrintScout from 'printscout' import logger from '../logger' @@ -31,6 +32,11 @@ export default { type: String, required: true, }, + fullHeight: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -42,9 +48,26 @@ export default { scout.on('beforeprint', this.onBeforePrint) scout.on('afterprint', this.onAfterPrint) }, + mounted() { + iframeResizer({ + onInit: () => { + const getCssVar = (key) => ({ + [key]: getComputedStyle(document.documentElement).getPropertyValue(key), + }) + + // send css vars to client page + this.$refs.iframe.iFrameResizer.sendMessage({ + cssVars: { + ...getCssVar('--color-main-text'), + }, + }) + }, + }, this.$refs.iframe) + }, beforeDestroy() { scout.off('beforeprint', this.onBeforePrint) scout.off('afterprint', this.onAfterPrint) + this.$refs.iframe.iFrameResizer.close() }, methods: { getIframeDoc() { @@ -97,11 +120,16 @@ export default { #message-container { flex: 1; - min-height: 50vh; display: flex; + + // TODO: collapse quoted text and remove inner scrollbar + &.scroll { + max-height: 50vh; + overflow-y: auto; + } } -#message-frame { +.message-frame { width: 100%; } </style> diff --git a/src/components/NewMessageDetail.vue b/src/components/NewMessageDetail.vue index 71885c4cc..66e88a392 100644 --- a/src/components/NewMessageDetail.vue +++ b/src/components/NewMessageDetail.vue @@ -253,7 +253,7 @@ export default { if (message.hasHtmlBody) { logger.debug('original message has HTML body') const resp = await Axios.get( - generateUrl('/apps/mail/api/messages/{id}/html', { + generateUrl('/apps/mail/api/messages/{id}/html?plain=true', { id, }) ) diff --git a/src/components/Thread.vue b/src/components/Thread.vue index 0b6833153..553737d02 100644 --- a/src/components/Thread.vue +++ b/src/components/Thread.vue @@ -20,6 +20,7 @@ :envelope="env" :mailbox-id="$route.params.mailboxId" :expanded="expandedThreads.includes(env.databaseId)" + :full-height="thread.length === 1" @move="onMove(env.databaseId)" @toggleExpand="toggleExpand(env.databaseId)" /> </template> diff --git a/src/components/ThreadEnvelope.vue b/src/components/ThreadEnvelope.vue index 088eea73d..d6de1f9e4 100644 --- a/src/components/ThreadEnvelope.vue +++ b/src/components/ThreadEnvelope.vue @@ -155,7 +155,10 @@ </div> </div> <Loading v-if="loading" /> - <Message v-else-if="message" :envelope="envelope" :message="message" /> + <Message v-else-if="message" + :envelope="envelope" + :message="message" + :full-height="fullHeight" /> <Error v-else-if="error" :error="error && error.message ? error.message : t('mail', 'Not found')" :message="errorMessage" @@ -218,6 +221,11 @@ export default { type: Boolean, default: false, }, + fullHeight: { + required: false, + type: Boolean, + default: false, + }, }, data() { return { diff --git a/src/html-response.js b/src/html-response.js new file mode 100644 index 000000000..ccdff4476 --- /dev/null +++ b/src/html-response.js @@ -0,0 +1,38 @@ +/** + * @copyright 2020 Richard Steinmetz <richard@steinmetz.cloud> + * + * @author 2020 Richard Steinmetz <richard@steinmetz.cloud> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +// injected styles +import '../css/html-response.css' + +// iframe-resizer client script +import 'iframe-resizer/js/iframeResizer.contentWindow.js' +window.iFrameResizer = { + onMessage: (message) => { + if (!message.cssVars) { + return + } + + // inject received css vars + Object.entries(message.cssVars).forEach(([key, val]) => { + document.documentElement.style.setProperty(key, val) + }) + }, +} diff --git a/tests/Unit/Controller/MessagesControllerTest.php b/tests/Unit/Controller/MessagesControllerTest.php index 409497b0c..65fdd89e3 100644 --- a/tests/Unit/Controller/MessagesControllerTest.php +++ b/tests/Unit/Controller/MessagesControllerTest.php @@ -177,26 +177,33 @@ class MessagesControllerTest extends TestCase { $message->setUid(123); $mailbox->setAccountId($accountId); $mailbox->setName($folderId); - $this->mailManager->expects($this->once()) + $this->mailManager->expects($this->exactly(3)) ->method('getMessage') ->with($this->userId, $messageId) ->willReturn($message); - $this->mailManager->expects($this->once()) + $this->mailManager->expects($this->exactly(3)) ->method('getMailbox') ->with($this->userId, $mailboxId) ->willReturn($mailbox); - $this->accountService->expects($this->once()) + $this->accountService->expects($this->exactly(3)) ->method('find') ->with($this->equalTo($this->userId), $this->equalTo($accountId)) ->will($this->returnValue($this->account)); $imapMessage = $this->createMock(IMAPMessage::class); - $this->mailManager->expects($this->once()) + $this->mailManager->expects($this->exactly(3)) ->method('getImapMessage') ->with($this->account, $mailbox, 123, true) ->willReturn($imapMessage); - $expectedResponse = new HtmlResponse(''); - $expectedResponse->cacheFor(3600); + $expectedDefaultResponse = new HtmlResponse(''); + $expectedDefaultResponse->cacheFor(3600); + + $expectedPlainResponse = new HtmlResponse('', true); + $expectedPlainResponse->cacheFor(3600); + + $expectedRichResponse = new HtmlResponse('', false); + $expectedRichResponse->cacheFor(3600); + if (class_exists('\OCP\AppFramework\Http\ContentSecurityPolicy')) { $policy = new ContentSecurityPolicy(); $policy->allowEvalScript(false); @@ -204,12 +211,18 @@ class MessagesControllerTest extends TestCase { $policy->disallowConnectDomain('\'self\''); $policy->disallowFontDomain('\'self\''); $policy->disallowMediaDomain('\'self\''); - $expectedResponse->setContentSecurityPolicy($policy); + $expectedDefaultResponse->setContentSecurityPolicy($policy); + $expectedPlainResponse->setContentSecurityPolicy($policy); + $expectedRichResponse->setContentSecurityPolicy($policy); } - $actualResponse = $this->controller->getHtmlBody($messageId); + $actualDefaultResponse = $this->controller->getHtmlBody($messageId); + $actualPlainResponse = $this->controller->getHtmlBody($messageId, true); + $actualRichResponse = $this->controller->getHtmlBody($messageId, false); - $this->assertEquals($expectedResponse, $actualResponse); + $this->assertEquals($expectedDefaultResponse, $actualDefaultResponse); + $this->assertEquals($expectedPlainResponse, $actualPlainResponse); + $this->assertEquals($expectedRichResponse, $actualRichResponse); } public function testDownloadAttachment() { diff --git a/tests/Unit/Http/HtmlResponseTest.php b/tests/Unit/Http/HtmlResponseTest.php index 67c07374e..be36f8088 100644 --- a/tests/Unit/Http/HtmlResponseTest.php +++ b/tests/Unit/Http/HtmlResponseTest.php @@ -24,6 +24,7 @@ namespace OCA\Mail\Tests\Unit\Http; use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Http\HtmlResponse; +use OCP\Util; class HtmlResponseTest extends TestCase { @@ -34,9 +35,18 @@ class HtmlResponseTest extends TestCase { * @param $contentType */ public function testIt($content) { - $resp = new HtmlResponse($content); - $injectedStyles = "<style>* { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', Arial, 'Noto Color Emoji', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; }</style>"; - $this->assertEquals($injectedStyles . $content, $resp->render()); + $defaultResp = new HtmlResponse($content); + $plainResp = new HtmlResponse($content, true); + $richResp = new HtmlResponse($content, false); + + $scriptSrcRegex = preg_quote(Util::linkToAbsolute('mail', 'js/htmlresponse.js'), '/'); + $contentRegex = preg_quote($content, '/'); + $responseRegex = '/<script nonce=".+" src="' . $scriptSrcRegex . '"><\/script>' + . $contentRegex . '/'; + + $this->assertMatchesRegularExpression($responseRegex, $defaultResp->render()); + $this->assertEquals($content, $plainResp->render()); + $this->assertMatchesRegularExpression($responseRegex, $richResp->render()); } public function providesResponseData() { diff --git a/webpack.common.js b/webpack.common.js index e86ce0e3d..0814297d2 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -22,7 +22,8 @@ module.exports = { autoredirect: path.join(__dirname, 'src/autoredirect.js'), dashboard: path.join(__dirname, 'src/main-dashboard.js'), mail: path.join(__dirname, 'src/main.js'), - settings: path.join(__dirname, 'src/main-settings') + settings: path.join(__dirname, 'src/main-settings'), + htmlresponse: path.join(__dirname, 'src/html-response.js'), }, output: { path: path.resolve(__dirname, 'js'), |