Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/API_v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,10 @@ Update a single or multiple properties of a question-object.
| Parameter | Type | Description |
|-----------|---------|-------------|
| _keyValuePairs_ | Array | Array of key-value pairs to update |
- Restrictions: It is **not allowed** to update one of the following key-value pairs: _id, formId, order_.
- Restrictions:
- It is **not allowed** to update one of the following key-value pairs: _id, formId, order_.
- `extraSettings.confirmationRecipient` can only be enabled for short questions with `extraSettings.validationType` set to `email`.
- `extraSettings.requireEmailVerification` can only be enabled for short questions with `extraSettings.validationType` set to `email` and `extraSettings.confirmationRecipient` set to `true`.
- Response: **Status-Code OK**, as well as the id of the updated question.

```
Expand Down Expand Up @@ -901,6 +904,8 @@ Store Submission to Database
- An **array** of values as value --> Even for short Text Answers, wrapped into Array.
- For Question-Types with pre-defined answers (`multiple`, `multiple_unique`, `dropdown`), the array contains the corresponding option-IDs.
- For File-Uploads, the array contains the objects with key `uploadedFileId` (value from Upload a file endpoint).
- To send a respondent confirmation email, set the corresponding short question to `validationType = email` and `confirmationRecipient = true`.
- If the same question also sets `requireEmailVerification = true`, the submission stays pending until the recipient visits the verification link, and the confirmation email is only sent after verification succeeds.

```
{
Expand Down
4 changes: 4 additions & 0 deletions docs/DataStructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ A submission-object describes a single submission by a user to a form.
| formId | Integer | | The id of the form, the submission belongs to |
| userId | String | | The nextcloud userId of the submitting user. If submission is anonymous, this contains `anon-user-<hash>` |
| timestamp | unix timestamp | | When the user submitted |
| isVerified | Boolean | | Whether the submission has completed email-address verification, if required |
| answers | Array of [Answers](#answer) | | Array of the actual user answers, belonging to this submission.
| userDisplayName | String | | Display name of the nextcloud-user, derived from `userId`. Contains `Anonymous user` if submitted anonymously. Not stored in DB.

Expand All @@ -157,6 +158,7 @@ A submission-object describes a single submission by a user to a form.
"formId": 3,
"userId": "jonas",
"timestamp": 1611274433,
"isVerified": true,
"answers": [],
"userDisplayName": "jonas"
}
Expand Down Expand Up @@ -240,6 +242,8 @@ Optional extra settings for some [Question Types](#question-types)
| `optionsLimitMin` | `multiple` | Integer | - | Minimum number of options that must be selected |
| `validationType` | `short` | string | `null, 'phone', 'email', 'regex', 'number'` | Custom validation for checking a submission |
| `validationRegex` | `short` | string | regular expression | if `validationType` is 'regex' this defines the regular expression to apply |
| `confirmationRecipient` | `short` | Boolean | `true/false` | Marks an email question as recipient for respondent confirmation emails |
| `requireEmailVerification` | `short` | Boolean | `true/false` | Requires respondents to verify the confirmation-recipient email before the submission is treated as verified |
| `allowedFileTypes` | `file` | Array of strings | `'image', 'x-office/document'` | Allowed file types for file upload |
| `allowedFileExtensions` | `file` | Array of strings | `'jpg', 'png'` | Allowed file extensions for file upload |
| `maxAllowedFilesCount` | `file` | Integer | - | Maximum number of files that can be uploaded, 0 means no limit |
Expand Down
5 changes: 5 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@

use OCA\Analytics\Datasource\DatasourceEvent;
use OCA\Forms\Capabilities;
use OCA\Forms\Events\FormSubmittedEvent;
use OCA\Forms\FormsMigrator;
use OCA\Forms\Listener\AnalyticsDatasourceListener;
use OCA\Forms\Listener\ConfirmationEmailListener;
use OCA\Forms\Listener\SubmissionVerificationListener;
use OCA\Forms\Listener\UserDeletedListener;
use OCA\Forms\Middleware\ThrottleFormAccessMiddleware;
use OCA\Forms\Search\SearchProvider;
Expand Down Expand Up @@ -43,6 +46,8 @@ public function register(IRegistrationContext $context): void {

$context->registerCapability(Capabilities::class);
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerEventListener(FormSubmittedEvent::class, SubmissionVerificationListener::class);
$context->registerEventListener(FormSubmittedEvent::class, ConfirmationEmailListener::class);
$context->registerEventListener(DatasourceEvent::class, AnalyticsDatasourceListener::class);
$context->registerMiddleware(ThrottleFormAccessMiddleware::class);
$context->registerSearchProvider(SearchProvider::class);
Expand Down
2 changes: 2 additions & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ class Constants {
public const EXTRA_SETTINGS_SHORT = [
'validationType' => ['string'],
'validationRegex' => ['string'],
'confirmationRecipient' => ['boolean'],
'requireEmailVerification' => ['boolean'],
];

public const EXTRA_SETTINGS_FILE = [
Expand Down
45 changes: 43 additions & 2 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use OCA\Forms\Db\SubmissionMapper;
use OCA\Forms\Db\UploadedFile;
use OCA\Forms\Db\UploadedFileMapper;
use OCA\Forms\Events\FormSubmittedEvent;
use OCA\Forms\Exception\NoSuchFormException;
use OCA\Forms\ResponseDefinitions;
use OCA\Forms\Service\ConfigService;
Expand Down Expand Up @@ -546,6 +547,13 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype
$questionData = $sourceQuestion->read();
unset($questionData['id']);
$questionData['order'] = end($allQuestions)->getOrder() + 1;
if (is_array($questionData['extraSettings'] ?? null)
&& ($questionData['extraSettings']['confirmationRecipient'] ?? false) === true) {
$questionData['extraSettings']['confirmationRecipient'] = false;
if (($questionData['extraSettings']['requireEmailVerification'] ?? false) === true) {
$questionData['extraSettings']['requireEmailVerification'] = false;
}
}

$newQuestion = Question::fromParams($questionData);
$this->questionMapper->insert($newQuestion);
Expand Down Expand Up @@ -648,6 +656,12 @@ public function updateQuestion(int $formId, int $questionId, array $keyValuePair
if (key_exists('extraSettings', $keyValuePairs) && !$this->formsService->areExtraSettingsValid($keyValuePairs['extraSettings'], $question->getType())) {
throw new OCSBadRequestException('Invalid extraSettings, will not update.');
}
$this->assertSingleConfirmationRecipientQuestion(
$formId,
$questionId,
$question->getType(),
is_array($keyValuePairs['extraSettings'] ?? null) ? $keyValuePairs['extraSettings'] : null,
);

// Create QuestionEntity with given Params & Id.
$question = Question::fromParams($keyValuePairs);
Expand Down Expand Up @@ -1360,6 +1374,7 @@ public function newSubmission(int $formId, array $answers, string $shareHash = '
$submission = new Submission();
$submission->setFormId($formId);
$submission->setTimestamp(time());
$submission->setIsVerified(true);

// If not logged in, anonymous, or embedded use anonID
if (!$this->currentUser || $form->getIsAnonymous()) {
Expand Down Expand Up @@ -1405,7 +1420,7 @@ public function newSubmission(int $formId, array $answers, string $shareHash = '
$this->formMapper->update($form);

//Create Activity
$this->formsService->notifyNewSubmission($form, $submission);
$this->formsService->notifyNewSubmission($form, $submission, FormSubmittedEvent::TRIGGER_CREATED);

if ($form->getFileId() !== null) {
$this->jobList->add(SyncSubmissionsWithLinkedFileJob::class, ['form_id' => $form->getId()]);
Expand Down Expand Up @@ -1487,7 +1502,7 @@ public function updateSubmission(int $formId, int $submissionId, array $answers)
}

//Create Activity
$this->formsService->notifyNewSubmission($form, $submission);
$this->formsService->notifyNewSubmission($form, $submission, FormSubmittedEvent::TRIGGER_UPDATED);

return new DataResponse($submissionId);
}
Expand Down Expand Up @@ -1827,6 +1842,32 @@ private function checkAccessUpdate(array $keyValuePairs): void {
}
}

/**
* Ensure only one short-email question can be used as confirmation recipient in a form.
*
* @param array<string, mixed>|null $extraSettings
*/
private function assertSingleConfirmationRecipientQuestion(int $formId, int $questionId, string $questionType, ?array $extraSettings): void {
if ($questionType !== Constants::ANSWER_TYPE_SHORT || !is_array($extraSettings) || ($extraSettings['confirmationRecipient'] ?? false) !== true) {
return;
}

$formQuestions = $this->questionMapper->findByForm($formId);
foreach ($formQuestions as $formQuestion) {
if ($formQuestion->getId() === $questionId) {
continue;
}

$existingSettings = $formQuestion->getExtraSettings();
$isExistingConfirmationRecipient = $formQuestion->getType() === Constants::ANSWER_TYPE_SHORT
&& ($existingSettings['validationType'] ?? null) === 'email'
&& ($existingSettings['confirmationRecipient'] ?? false) === true;
if ($isExistingConfirmationRecipient) {
throw new OCSBadRequestException('Only one confirmation recipient question is allowed per form');
}
}
}

/**
* Checks if the current user is allowed to archive/unarchive the form
*/
Expand Down
23 changes: 23 additions & 0 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OCA\Forms\Db\SubmissionMapper;
use OCA\Forms\Service\ConfigService;
use OCA\Forms\Service\FormsService;
use OCA\Forms\Service\SubmissionVerificationService;

use OCP\Accounts\IAccountManager;
use OCP\AppFramework\Controller;
Expand Down Expand Up @@ -49,6 +50,7 @@ public function __construct(
private SubmissionMapper $submissionMapper,
private ConfigService $configService,
private FormsService $formsService,
private SubmissionVerificationService $submissionVerificationService,
private IAccountManager $accountManager,
private IInitialState $initialState,
private IL10N $l10n,
Expand Down Expand Up @@ -118,6 +120,27 @@ public function submitViewWithSubmission(string $hash, int $submissionId): Templ
return $this->formMapper->findByHash($hash)->getAllowEditSubmissions() ? $this->index($hash, $submissionId) : $this->index($hash);
}

#[NoAdminRequired()]
#[NoCSRFRequired()]
#[PublicPage()]
#[FrontpageRoute(verb: 'GET', url: '/verify/{token}', requirements: ['token' => '[a-f0-9]{48}'])]
public function verifySubmissionEmail(string $token): PublicTemplateResponse {
$isVerified = $this->submissionVerificationService->verifyToken($token);

$response = new PublicTemplateResponse($this->appName, 'verify', [
'verified' => $isVerified,
'headline' => $isVerified
? $this->l10n->t('Email address verified')
: $this->l10n->t('Email verification failed'),
'message' => $isVerified
? $this->l10n->t('Your email address has been verified successfully. You can close this page now.')
: $this->l10n->t('The verification link is invalid or expired.'),
]);
$response->setHeaderTitle($this->l10n->t('Forms'));

return $response;
}

/**
* @param string $hash
* @return RedirectResponse|TemplateResponse Redirect to login or internal view.
Expand Down
6 changes: 6 additions & 0 deletions lib/Db/Submission.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,22 @@
* @method void setUserId(string $value)
* @method int getTimestamp()
* @method void setTimestamp(integer $value)
* @method bool getIsVerified()
* @method void setIsVerified(bool $value)
*/
class Submission extends Entity {
protected $formId;
protected $userId;
protected $timestamp;
protected $isVerified;

/**
* Submission constructor.
*/
public function __construct() {
$this->addType('formId', 'integer');
$this->addType('timestamp', 'integer');
$this->addType('isVerified', 'boolean');
}

/**
Expand All @@ -38,6 +42,7 @@ public function __construct() {
* formId: int,
* userId: string,
* timestamp: int,
* isVerified: bool,
* }
*/
public function read(): array {
Expand All @@ -46,6 +51,7 @@ public function read(): array {
'formId' => $this->getFormId(),
'userId' => $this->getUserId(),
'timestamp' => $this->getTimestamp(),
'isVerified' => (bool)$this->getIsVerified(),
];
}
}
59 changes: 59 additions & 0 deletions lib/Db/SubmissionVerification.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Forms\Db;

use OCP\AppFramework\Db\Entity;

/**
* @method int getSubmissionId()
* @method void setSubmissionId(int $value)
* @method string getRecipientEmailHash()
* @method void setRecipientEmailHash(string $value)
* @method string getTokenHash()
* @method void setTokenHash(string $value)
* @method int getExpires()
* @method void setExpires(int $value)
* @method int|null getUsed()
* @method void setUsed(?int $value)
*/
class SubmissionVerification extends Entity {
protected $submissionId;
protected $recipientEmailHash;
protected $tokenHash;
protected $expires;
protected $used;

public function __construct() {
$this->addType('submissionId', 'integer');
$this->addType('expires', 'integer');
$this->addType('used', 'integer');
}

/**
* @return array{
* id: int,
* submissionId: int,
* recipientEmailHash: string,
* tokenHash: string,
* expires: int,
* used: int|null,
* }
*/
public function read(): array {
return [
'id' => $this->getId(),
'submissionId' => $this->getSubmissionId(),
'recipientEmailHash' => $this->getRecipientEmailHash(),
'tokenHash' => $this->getTokenHash(),
'expires' => $this->getExpires(),
'used' => $this->getUsed(),
];
}
}
55 changes: 55 additions & 0 deletions lib/Db/SubmissionVerificationMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Forms\Db;

use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;

/**
* @extends QBMapper<SubmissionVerification>
*/
class SubmissionVerificationMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'forms_v2_submission_verify', SubmissionVerification::class);
}

/**
* @throws \OCP\AppFramework\Db\DoesNotExistException
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
*/
public function findBySubmissionId(int $submissionId): SubmissionVerification {
$qb = $this->db->getQueryBuilder();

$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('submission_id', $qb->createNamedParameter($submissionId, IQueryBuilder::PARAM_INT))
);

return $this->findEntity($qb);
}

/**
* @throws \OCP\AppFramework\Db\DoesNotExistException
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
*/
public function findByTokenHash(string $tokenHash): SubmissionVerification {
$qb = $this->db->getQueryBuilder();

$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('token_hash', $qb->createNamedParameter($tokenHash, IQueryBuilder::PARAM_STR))
);

return $this->findEntity($qb);
}
}
Loading