Skip to content

Commit 5e5f9cc

Browse files
authored
Merge pull request #1353 from nextcloud/feat/embedded
Allow embedding forms within other websites
2 parents 7d6ce87 + b21dfab commit 5e5f9cc

16 files changed

Lines changed: 484 additions & 141 deletions

appinfo/routes.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@
4646
'verb' => 'GET'
4747
],
4848

49+
// Embedded View
50+
[
51+
'name' => 'page#embedded_form_view',
52+
'url' => '/embed/{hash}',
53+
'verb' => 'GET'
54+
],
55+
4956
// Internal views
5057
[
5158
'name' => 'page#views',

css/embedded.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* @copyright Copyright (c) 2022 Ferdinand Thiessen <[email protected]>
3+
*
4+
* @license AGPL-3.0-or-later
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as
8+
* published by the Free Software Foundation, either version 3 of the
9+
* License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Affero General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
*
19+
*/
20+
21+
/* Remove background for embedded view */
22+
body {
23+
background-color: var(--color-main-background) !important;
24+
background-image: none !important;
25+
}

docs/Embedding.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Embedding
2+
Besides sharing and using the [API](./API.md) for custom forms it is possible to embed forms inside external
3+
websites.
4+
5+
## Obtaining the embedding code
6+
7+
For embedding a form it is **required** to create a *public share link*.\
8+
The embedding code can be copied from the *sharing sidebar* or crafted manually by using the public share link:
9+
10+
If the public share link looks like this:\
11+
`https://SERVER_DOMAIN/apps/forms/s/SHARE_HASH`
12+
13+
The embeddable URL looks like this:\
14+
`https://SERVER_DOMAIN/apps/forms/embed/SHARE_HASH`
15+
16+
Using the copy-embedding-code button on the *sharing sidebar* will automatically generate ready-to-use HTML code for embedding which looks like this:
17+
```html
18+
<iframe src="EMBEDDABLE_URL" width="750" height="900"></iframe>
19+
```
20+
The size parameters are based on our default forms styling.
21+
22+
## window message events
23+
The embedded view provides a `MessageEvent` to communicate its size with its parent window.
24+
This is done as accessing the document within an `iframe` is not possible if not on the same domain.
25+
26+
### Auto resizing the `iframe`
27+
28+
The emitted message on the embedded view looks like this:
29+
```json
30+
{
31+
"type": "resize-iframe",
32+
"payload": {
33+
"width": 750,
34+
"height": 900,
35+
},
36+
}
37+
```
38+
39+
To receive this information on your parent site:
40+
```js
41+
window.addEventListener("message", (event) => {
42+
if (event.origin !== "http://your-nextcloud-server.com") {
43+
return;
44+
}
45+
46+
if (event.data.type !== "resize-iframe") {
47+
return;
48+
}
49+
50+
const { width, height } = event.data.payload;
51+
52+
iframe.width = width;
53+
iframe.height = height;
54+
}, false);
55+
```
56+
57+
### Form submitted
58+
When the form is submitted a message event like this is emitted:
59+
60+
The emitted message on the embedded view looks like this:
61+
```json
62+
{
63+
"type": "form-saved",
64+
"payload": {
65+
"id": 1234,
66+
},
67+
}
68+
```
69+
70+
## Custom styling
71+
To apply custom styles on the embedded forms the [Custom CSS App](https://apps.nextcloud.com/apps/theming_customcss) can be used.
72+
73+
The embedded form provides the `app-forms-embedded` class, so you can apply your styles.\
74+
For example if you want the form to be displayed without margins you can use this:
75+
```css
76+
#content-vue.app-forms-embedded {
77+
width: 100%;
78+
height: 100%;
79+
border-radius: 0;
80+
margin: 0;
81+
}
82+
```
83+
84+
Or if you want the form to fill the screen:
85+
```css
86+
#content-vue.app-forms-embedded .app-content header,
87+
#content-vue.app-forms-embedded .app-content form {
88+
max-width: unset;
89+
}
90+
```

lib/AppInfo/Application.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
use OCA\Forms\Capabilities;
3232
use OCA\Forms\FormsMigrator;
3333
use OCA\Forms\Listener\UserDeletedListener;
34-
use OCA\Forms\Middleware\PublicCorsMiddleware;
3534
use OCP\AppFramework\App;
3635
use OCP\AppFramework\Bootstrap\IBootContext;
3736
use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -60,7 +59,6 @@ public function register(IRegistrationContext $context): void {
6059
$context->registerCapability(Capabilities::class);
6160
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
6261
$context->registerUserMigrator(FormsMigrator::class);
63-
$context->registerMiddleware(PublicCorsMiddleware::class);
6462
}
6563

6664
/**

lib/Constants.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,12 +154,15 @@ class Constants {
154154
public const PERMISSION_RESULTS = 'results';
155155
public const PERMISSION_RESULTS_DELETE = 'results_delete';
156156
public const PERMISSION_SUBMIT = 'submit';
157+
/** Special internal permissions to allow embedding a form (share) into external websites */
158+
public const PERMISSION_EMBED = 'embed';
157159

158160
public const PERMISSION_ALL = [
159161
self::PERMISSION_EDIT,
160162
self::PERMISSION_RESULTS,
161163
self::PERMISSION_RESULTS_DELETE,
162164
self::PERMISSION_SUBMIT,
165+
self::PERMISSION_EMBED,
163166
];
164167

165168
/**

lib/Controller/ApiController.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,8 +1035,8 @@ private function storeAnswersForQuestion($submissionId, array $question, array $
10351035

10361036
/**
10371037
* @CORS
1038-
* @PublicCORSFix
10391038
* @NoAdminRequired
1039+
* @NoCSRFRequired
10401040
* @PublicPage
10411041
*
10421042
* Process a new submission
@@ -1104,7 +1104,7 @@ public function insertSubmission(int $formId, array $answers, string $shareHash
11041104
$submission->setFormId($formId);
11051105
$submission->setTimestamp(time());
11061106

1107-
// If not logged in or anonymous use anonID
1107+
// If not logged in, anonymous, or embedded use anonID
11081108
if (!$this->currentUser || $form->getIsAnonymous()) {
11091109
$anonID = "anon-user-". hash('md5', strval(time() + rand()));
11101110
$submission->setUserId($anonID);

lib/Controller/PageController.php

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
use OCP\Accounts\IAccountManager;
3737
use OCP\AppFramework\Controller;
3838
use OCP\AppFramework\Db\DoesNotExistException;
39+
use OCP\AppFramework\Http\ContentSecurityPolicy;
3940
use OCP\AppFramework\Http\RedirectResponse;
4041
use OCP\AppFramework\Http\Response;
4142
use OCP\AppFramework\Http\Template\PublicTemplateResponse;
@@ -152,46 +153,87 @@ public function internalLinkView(string $hash): Response {
152153
* @return TemplateResponse Public template.
153154
*/
154155
public function publicLinkView(string $hash): Response {
155-
// Inject style on all templates
156-
Util::addStyle($this->appName, 'forms');
156+
try {
157+
$share = $this->shareMapper->findPublicShareByHash($hash);
158+
$form = $this->formMapper->findById($share->getFormId());
159+
} catch (DoesNotExistException $e) {
160+
return $this->provideEmptyContent(Constants::EMPTY_NOTFOUND);
161+
}
157162

163+
return $this->createPublicSubmitView($form, $hash);
164+
}
165+
166+
/**
167+
* @NoAdminRequired
168+
* @PublicPage
169+
* @NoCSRFRequired
170+
*
171+
* @param string $hash
172+
* @return Response
173+
*/
174+
public function embeddedFormView(string $hash): Response {
158175
try {
159176
$share = $this->shareMapper->findPublicShareByHash($hash);
177+
// Check if the form is allwed to be embedded
178+
if (!in_array(Constants::PERMISSION_EMBED, $share->getPermissions())) {
179+
throw new DoesNotExistException('Shared form not allowed to be embedded');
180+
}
181+
160182
$form = $this->formMapper->findById($share->getFormId());
161183
} catch (DoesNotExistException $e) {
162184
return $this->provideEmptyContent(Constants::EMPTY_NOTFOUND);
185+
// We do not handle the MultipleObjectsReturnedException as this will automatically result in a 500 error as expected
163186
}
164187

188+
Util::addStyle($this->appName, 'embedded');
189+
$response = $this->createPublicSubmitView($form, $hash)
190+
->renderAs(TemplateResponse::RENDER_AS_BASE);
191+
192+
$this->initialState->provideInitialState('isEmbedded', true);
193+
194+
return $this->setEmbeddedCSP($response);
195+
}
196+
197+
/**
198+
* Create a TemplateResponse for a given public form
199+
* This sets all needed headers, initial state, loads scripts and styles
200+
*/
201+
protected function createPublicSubmitView(Form $form, string $hash): TemplateResponse {
165202
// Has form expired
166203
if ($this->formsService->hasFormExpired($form)) {
167204
return $this->provideEmptyContent(Constants::EMPTY_EXPIRED, $form);
168205
}
169206

207+
$this->insertHeaderOnIos();
208+
209+
// Inject style on all templates
210+
Util::addStyle($this->appName, 'forms');
170211
// Main Template to fill the form
171212
Util::addScript($this->appName, 'forms-submit');
172-
$this->insertHeaderOnIos();
213+
173214
$this->initialState->provideInitialState('form', $this->formsService->getPublicForm($form));
174215
$this->initialState->provideInitialState('isLoggedIn', $this->userSession->isLoggedIn());
175216
$this->initialState->provideInitialState('shareHash', $hash);
176217
$this->initialState->provideInitialState('maxStringLengths', Constants::MAX_STRING_LENGTHS);
177218
return $this->provideTemplate(self::TEMPLATE_MAIN, $form, ['id-app-navigation' => null]);
178219
}
179220

180-
public function provideEmptyContent(string $renderAs, ?Form $form = null): TemplateResponse {
221+
/**
222+
* Provide empty content message response for a form
223+
*/
224+
protected function provideEmptyContent(string $renderAs, ?Form $form = null): TemplateResponse {
181225
Util::addScript($this->appName, 'forms-emptyContent');
182226
$this->initialState->provideInitialState('renderAs', $renderAs);
183227
return $this->provideTemplate(self::TEMPLATE_MAIN, $form);
184228
}
185229

186230
/**
187-
* @NoAdminRequired
188-
* @NoCSRFRequired
189-
* @PublicPage
231+
* Helper function to create a template response from a form
190232
* @param string $template
191233
* @param Form $form Necessary to set header on public forms, not necessary for 'notfound'-template
192234
* @return TemplateResponse
193235
*/
194-
public function provideTemplate(string $template, ?Form $form = null, array $options = []): TemplateResponse {
236+
protected function provideTemplate(string $template, ?Form $form = null, array $options = []): TemplateResponse {
195237
Util::addStyle($this->appName, 'forms-style');
196238
// If not logged in, use PublicTemplate
197239
if (!$this->userSession->isLoggedIn()) {
@@ -217,7 +259,6 @@ public function provideTemplate(string $template, ?Form $form = null, array $opt
217259
}
218260
}
219261
}
220-
221262
return $response;
222263
}
223264

@@ -230,7 +271,7 @@ public function provideTemplate(string $template, ?Form $form = null, array $opt
230271
/**
231272
* Insert the extended viewport Header on iPhones to prevent automatic zooming.
232273
*/
233-
public function insertHeaderOnIos(): void {
274+
protected function insertHeaderOnIos(): void {
234275
$USER_AGENT_IPHONE_SAFARI = '/^Mozilla\/5\.0 \(iPhone[^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\) Version\/[0-9.]+ Mobile\/[0-9.A-Z]+ Safari\/[0-9.A-Z]+$/';
235276
if (preg_match($USER_AGENT_IPHONE_SAFARI, $this->request->getHeader('User-Agent'))) {
236277
Util::addHeader('meta', [
@@ -239,4 +280,17 @@ public function insertHeaderOnIos(): void {
239280
]);
240281
}
241282
}
283+
284+
/**
285+
* Set CSP options to allow the page be embedded using <iframe>
286+
*/
287+
protected function setEmbeddedCSP(TemplateResponse $response) {
288+
$policy = new ContentSecurityPolicy();
289+
$policy->addAllowedFrameAncestorDomain('*');
290+
291+
$response->addHeader('X-Frame-Options', 'ALLOW');
292+
$response->setContentSecurityPolicy($policy);
293+
294+
return $response;
295+
}
242296
}

lib/Controller/ShareApiController.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,12 @@ protected function validatePermissions(array $permissions, int $shareType): bool
325325
case IShare::TYPE_GROUP:
326326
case IShare::TYPE_CIRCLE:
327327
break;
328+
case IShare::TYPE_LINK:
329+
// For link shares we only allow the embedding permission
330+
if (count($sanitizedPermissions) > 2 || !in_array(Constants::PERMISSION_EMBED, $sanitizedPermissions)) {
331+
return false;
332+
}
333+
break;
328334
default:
329335
// e.g. link shares ...
330336
return false;

0 commit comments

Comments
 (0)