feat: Allow embedding forms within other websites

* Add embedded endpoint for page controller and allow
inserting submissions without CSFR as anonymous submissions
for public shares.
* Added submenu entry for copying the embedding code to the clipboard
and added documentation on how to use the embedded view.
* Switched to `vue-clipboard2` to allow copying to clipboard
from submenu entry (allows setting a container for the copy action).

Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2022-09-20 12:18:19 +02:00 коммит произвёл Chartman123
Родитель 19d59846c6
Коммит 1533d5253c
10 изменённых файлов: 270 добавлений и 4 удалений

Просмотреть файл

@ -46,6 +46,13 @@ return [
'verb' => 'GET'
],
// Embedded View
[
'name' => 'page#embedded_form_view',
'url' => '/embed/{hash}',
'verb' => 'GET'
],
// Internal views
[
'name' => 'page#views',

25
css/embedded.css Normal file
Просмотреть файл

@ -0,0 +1,25 @@
/**
* @copyright Copyright (c) 2022 Ferdinand Thiessen <rpm@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/
/* Remove background for embedded view */
body {
background-color: var(--color-main-background) !important;
background-image: none !important;
}

42
docs/Embedding.md Normal file
Просмотреть файл

@ -0,0 +1,42 @@
# Embedding
Besides sharing and using the [API](./API.md) for custom forms it is possible to embed forms inside external
websites.
## Obtaining the embedding code
For embedding a form it is **required** to create a *public share link*.\
The embedding code can be copied from the *sharing sidebar* or crafted manually by using the public share link:
If the public share link looks like this:\
`https://SERVER_DOMAIN/apps/forms/s/SHARE_HASH`
The embeddable URL looks like this:\
`https://SERVER_DOMAIN/apps/forms/embed/SHARE_HASH`
Using the copy-embedding-code button on the *sharing sidebar* will automatically generate ready-to-use HTML code for embedding which looks like this:
```html
<iframe src="EMBEDDABLE_URL" width="750" height="900"></iframe>
```
The size parameters are based on our default forms styling.
## Custom styling
To apply custom styles on the embedded forms the [Custom CSS App](https://apps.nextcloud.com/apps/theming_customcss) can be used.
The embedded form provides the `app-forms-embedded` class, so you can apply your styles.\
For example if you want the form to be displayed without margins you can use this:
```css
#content-vue.app-forms-embedded {
width: 100%;
height: 100%;
border-radius: 0;
margin: 0;
}
```
Or if you want the form to fill the screen:
```css
#content-vue.app-forms-embedded .app-content header,
#content-vue.app-forms-embedded .app-content form {
max-width: unset;
}
```

Просмотреть файл

@ -1104,7 +1104,7 @@ class ApiController extends OCSController {
$submission->setFormId($formId);
$submission->setTimestamp(time());
// If not logged in or anonymous use anonID
// If not logged in, anonymous, or embedded use anonID
if (!$this->currentUser || $form->getIsAnonymous()) {
$anonID = "anon-user-". hash('md5', strval(time() + rand()));
$submission->setUserId($anonID);

Просмотреть файл

@ -36,6 +36,7 @@ use OCA\Forms\Service\FormsService;
use OCP\Accounts\IAccountManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\Template\PublicTemplateResponse;
@ -217,7 +218,6 @@ class PageController extends Controller {
}
}
}
return $response;
}
@ -239,4 +239,31 @@ class PageController extends Controller {
]);
}
}
/**
* @NoAdminRequired
* @PublicPage
* @NoCSRFRequired
*
* @param string $hash
* @return Response
*/
public function embeddedFormView(string $hash): Response {
Util::addStyle($this->appName, 'embedded');
$response = $this->publicLinkView($hash)->renderAs(TemplateResponse::RENDER_AS_BASE);
$this->initialState->provideInitialState('isEmbedded', true);
return $this->setEmbeddedCSP($response);
}
protected function setEmbeddedCSP(TemplateResponse $response) {
$policy = new ContentSecurityPolicy();
$policy->addAllowedFrameAncestorDomain('*');
$response->addHeader('X-Frame-Options', 'ALLOW');
$response->setContentSecurityPolicy($policy);
return $response;
}
}

Просмотреть файл

@ -21,7 +21,7 @@
-->
<template>
<NcContent app-name="forms">
<NcContent app-name="forms" :class="{'app-forms-embedded': isEmbedded}">
<Submit :form="form"
:public-view="true"
:share-hash="shareHash"
@ -46,6 +46,7 @@ export default {
return {
form: loadState('forms', 'form'),
isLoggedIn: loadState('forms', 'isLoggedIn'),
isEmbedded: loadState('forms', 'isEmbedded', false),
shareHash: loadState('forms', 'shareHash'),
}
},

Просмотреть файл

@ -59,6 +59,12 @@
</NcActionLink>
</NcActions>
<NcActions>
<NcActionButton @click="copyEmbeddingCode(share.shareWith)">
<template #icon>
<IconCodeBrackets :size="20" />
</template>
{{ t('forms', 'Copy embedding code') }}
</NcActionButton>
<NcActionButton @click="removeShare(share)">
<template #icon>
<IconDelete :size="20" />
@ -169,6 +175,7 @@ import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import IconAccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'
import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
import IconCodeBrackets from 'vue-material-design-icons/CodeBrackets.vue'
import IconDelete from 'vue-material-design-icons/Delete.vue'
import IconLinkVariant from 'vue-material-design-icons/LinkVariant.vue'
import IconPlus from 'vue-material-design-icons/Plus.vue'
@ -187,6 +194,7 @@ export default {
FormsIcon,
IconAccountMultiple,
IconAlertCircleOutline,
IconCodeBrackets,
IconCopyAll,
IconDelete,
IconLinkVariant,

Просмотреть файл

@ -21,6 +21,7 @@
*/
import { generateUrl } from '@nextcloud/router'
import { showError, showSuccess } from '@nextcloud/dialogs'
import logger from '../utils/Logger.js'
export default {
methods: {
@ -57,6 +58,26 @@ export default {
showSuccess(t('forms', 'Form link copied'))
} catch (error) {
showError(t('forms', 'Cannot copy, please copy the link manually'))
logger.error('Copy link failed', { error })
}
// Set back focus as clipboard removes focus
event.target.focus()
},
/**
* Copy code to embed public share inside external websites
*
* @param {string} publicHash Hash of public link-share
*/
async copyEmbeddingCode(publicHash) {
const url = generateUrl(`/apps/forms/embed/${publicHash}`)
const code = `<iframe src="${window.location.protocol}//${window.location.host}${url}" width="750" height="900"></iframe>`
try {
await navigator.clipboard.writeText(code)
showSuccess(t('forms', 'Embedding code copied'))
} catch (error) {
showError(t('forms', 'Cannot copy the code'))
logger.error('Copy embedding code failed', { error })
}
// Set back focus as clipboard removes focus
event.target.focus()

Просмотреть файл

@ -569,7 +569,7 @@ export default {
align-items: center;
flex-direction: column;
&--public {
&--public:not(.app-forms-embedded *) {
// Compensate top-padding for missing topbar
padding-block-start: 50px;
}

Просмотреть файл

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Ferdinand Thiessen <rpm@fthiessen.de>
*
* @author Ferdinand Thiessen <rpm@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/
namespace OCA\Forms\Tests\Unit\Controller;
use OCA\Forms\Controller\PageController;
use OCA\Forms\Db\FormMapper;
use OCA\Forms\Db\ShareMapper;
use OCA\Forms\Service\ConfigService;
use OCA\Forms\Service\FormsService;
use OCP\Accounts\IAccountManager;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IGroupManager;
use OCP\IInitialStateService;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
class PageControllerTest extends TestCase {
/** @var PageController */
private $pageController;
/** @var LoggerInterface|MockObject */
private $logger;
/** @var IRequest|MockObject */
private $request;
/** @var FormMapper|MockObject */
private $formMapper;
/** @var ShareMapper|MockObject */
private $shareMapper;
/** @var ConfigService|MockObject */
private $configService;
/** @var FormsService|MockObject */
private $formsService;
/** @var IAccountManager|MockObject */
private $accountManager;
/** @var IGroupManager|MockObject */
private $groupManager;
/** @var IInitialStateService|MockObject */
private $initialStateService;
/** @var IL10N|MockObject */
private $l10n;
/** @var IURLGenerator|MockObject */
private $urlGenerator;
/** @var IUserManager|MockObject */
private $userManager;
/** @var IUserSession|MockObject */
private $userSession;
public function setUp(): void {
parent::setUp();
$this->request = $this->createMock(IRequest::class);
$this->formMapper = $this->createMock(FormMapper::class);
$this->shareMapper = $this->createMock(ShareMapper::class);
$this->configService = $this->createMock(ConfigService::class);
$this->formsService = $this->createMock(FormsService::class);
$this->accountManager = $this->createMock(IAccountManager::class);
$this->groupManager = $this->createMock(IGroupManager::class);
$this->initialStateService = $this->createMock(IInitialStateService::class);
$this->l10n = $this->createMock(IL10N::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->userManager = $this->createMock(IUserManager::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->pageController = new PageController(
'forms',
$this->request,
$this->formMapper,
$this->shareMapper,
$this->configService,
$this->formsService,
$this->accountManager,
$this->groupManager,
$this->initialStateService,
$this->l10n,
$this->logger,
$this->urlGenerator,
$this->userManager,
$this->userSession
);
}
public function testSetEmbeddedCSP() {
/** @var MockObject */
$response = $this->createMock(TemplateResponse::class);
$response->expects($this->once())->method('addHeader')->with('X-Frame-Options', 'ALLOW');
$response->expects($this->once())
->method('setContentSecurityPolicy')
->with(self::callback(fn (ContentSecurityPolicy $csp): bool => preg_match('/frame-ancestors[^;]* \*[ ;]/', $csp->buildPolicy()) !== false));
TestCase::invokePrivate($this->pageController, 'setEmbeddedCSP', [$response]);
}
}