зеркало из https://github.com/nextcloud/forms.git
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:
Родитель
19d59846c6
Коммит
1533d5253c
|
@ -46,6 +46,13 @@ return [
|
|||
'verb' => 'GET'
|
||||
],
|
||||
|
||||
// Embedded View
|
||||
[
|
||||
'name' => 'page#embedded_form_view',
|
||||
'url' => '/embed/{hash}',
|
||||
'verb' => 'GET'
|
||||
],
|
||||
|
||||
// Internal views
|
||||
[
|
||||
'name' => 'page#views',
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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]);
|
||||
}
|
||||
}
|
Загрузка…
Ссылка в новой задаче