diff --git a/docs/API_v3.md b/docs/API_v3.md index 9ce0f136a..35aab9810 100644 --- a/docs/API_v3.md +++ b/docs/API_v3.md @@ -88,6 +88,7 @@ Returns condensed objects of all Forms beeing owned by the authenticated user. "hash": "yWeMwcwCwoqRs8T2", "title": "Form 2", "expires": 0, + "opens": 0, "permissions": [ "edit", "results", @@ -103,6 +104,7 @@ Returns condensed objects of all Forms beeing owned by the authenticated user. "hash": "em4djk8B9BpXnkYG", "title": "Form 1", "expires": 0, + "opens": 0, "permissions": [ "edit", "results", @@ -151,6 +153,7 @@ Returns the full-depth object of the requested form (without submissions). |-----------|---------|-------------| | _formId_ | Integer | ID of the form to request | - Response: A full object of the form, including access, questions and options in full depth. + Before the opening date (`opens`) of the form, `questions` is empty for users who can neither edit the form nor see its results. ``` "data": { @@ -166,6 +169,7 @@ Returns the full-depth object of the requested form (without submissions). "showToAllUsers": false }, "expires": 0, + "opens": 0, "fileFormat": "csv", "fileId": 157, "filePath": "foo/bar", @@ -281,6 +285,8 @@ Update a single or multiple properties of a form-object. Concerns **only** the F - To transfer the ownership of a form to another user, you must only send a single _keyValuePair_ containing the key `ownerId` and the user id of the new owner. - To link a file for submissions, the _keyValuePairs_ need to contain the keys `path` and `fileFormat` - To unlink a file for submissions, the _keyValuePairs_ need to contain the keys `fileId` and `fileFormat` need to contain the value `null` + - `opens` and `expires` must be non-negative unix timestamps, `0` means not set + - If both `opens` and `expires` are set (not `0`), `opens` must be before `expires` - Response: **Status-Code OK**, as well as the id of the updated form. ``` @@ -314,6 +320,7 @@ Returns the questions and options of the given form (without submissions). |-----------|---------|-------------| | _formId_ | Integer | ID of the form | - Response: An array of all questions of the form including options. +- Before the opening date (`opens`) of the form, users who can neither edit the form nor see its results get a **Forbidden** response. ``` "data": [ @@ -398,6 +405,7 @@ Returns the requested question and options of the given form (without submission |-----------|---------|-------------| | _formId_ | Integer | ID of the form | | _questionId_ | Integer | ID of the question | +- Before the opening date (`opens`) of the form, users who can neither edit the form nor see its results get a **Forbidden** response. - Response: An object of the requested question including options. ``` @@ -1024,7 +1032,7 @@ This returns in case the Request is not properly set. This can e.g. include: This returns in case the authenticated user is not allowed to access this resource/endpoint. This can e.g. include: - The user has no write access to the form (only form owner is allowed to edit) -- The user is not allowed to submit to the form (access-settings, form expired, already submitted) +- The user is not allowed to submit to the form (access-settings, form not yet open or expired, already submitted) ``` "ocs": { diff --git a/docs/DataStructure.md b/docs/DataStructure.md index 570057b98..d0e9acfa3 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -28,6 +28,7 @@ This document describes the Object-Structure, that is used within the Forms App | created | unix timestamp | | When the form has been created | | access | [Access-Object](#access-object) | | Describing access-settings of the form | | expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ | +| opens | unix-timestamp | | From when the form accepts responses. Timestamp `0` indicates _immediately_ | | isAnonymous | Boolean | | If Answers will be stored anonymously | | state | Integer | [Form state](#form-state) | The state of the form | | lockedBy | String | | The user ID for who has exclusive edit access at the moment | @@ -57,6 +58,7 @@ This document describes the Object-Structure, that is used within the Forms App "created": 1611240961, "access": {}, "expires": 0, + "opens": 0, "isAnonymous": false, "submitMultiple": true, "allowEditSubmissions": false, diff --git a/lib/Constants.php b/lib/Constants.php index 21bed641a..3e465cd0c 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -275,6 +275,7 @@ class Constants { */ public const EMPTY_EXPIRED = 'expired'; public const EMPTY_NOTFOUND = 'notfound'; + public const EMPTY_NOTYETOPEN = 'notyetopen'; /** * Constants related to extra settings for questions diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index c6a4e9107..14bf7ea96 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -178,6 +178,7 @@ public function newForm(?int $fromId = null): DataResponse { $form->setAllowEditSubmissions(false); $form->setShowExpiration(false); $form->setExpires(0); + $form->setOpens(0); $form->setIsAnonymous(false); $this->formMapper->insert($form); @@ -212,6 +213,7 @@ public function newForm(?int $fromId = null): DataResponse { $formData['allowEditSubmissions'] = false; $formData['showExpiration'] = false; $formData['expires'] = 0; + $formData['opens'] = 0; $formData['isAnonymous'] = false; $form = Form::fromParams($formData); @@ -282,6 +284,7 @@ public function getForm(int $formId): DataResponse { * @param int $formId FormId of form to update * @param array $keyValuePairs Array of key=>value pairs to update. * @return DataResponse + * @throws OCSBadRequestException Invalid opening or expiration date, or opening date not before expiration date * @throws OCSBadRequestException Could not find new form owner * @throws OCSForbiddenException Empty keyValuePairs provided * @throws OCSForbiddenException Not allowed to update id, hash, created, fileId or lastUpdated. OwnerId only allowed if no other key provided. @@ -336,6 +339,8 @@ public function updateForm(int $formId, array $keyValuePairs): DataResponse { // Do not allow changing showToAllUsers or permitAllUsers if disabled $this->checkAccessUpdate($keyValuePairs); + $this->checkDatesUpdate($form, $keyValuePairs); + $previousAccess = $form->getAccess(); $previousState = $form->getState(); $previousSettings = $this->getResponseSettings($form); @@ -429,6 +434,7 @@ public function deleteForm(int $formId): DataResponse { * * @param int $formId the form id * @return DataResponse, array{}> + * @throws OCSForbiddenException Form is not yet open * @throws OCSForbiddenException User has no permissions to get this form * @throws OCSNotFoundException Could not find form * @@ -450,6 +456,11 @@ public function getQuestions(int $formId): DataResponse { throw new OCSForbiddenException('User has no permissions to get this form'); } + if (!$this->formsService->canSeeQuestions($form)) { + $this->logger->debug('Form is not yet open'); + throw new OCSForbiddenException('This form is not yet open'); + } + $questionData = $this->formsService->getQuestions($formId); $questionData = array_map(static function (array $question) { if (empty($question['extraSettings'])) { @@ -468,6 +479,7 @@ public function getQuestions(int $formId): DataResponse { * @param int $questionId QuestionId * @return DataResponse * @throws OCSBadRequestException Question doesn\'t belong to given Form + * @throws OCSForbiddenException Form is not yet open * @throws OCSForbiddenException User has no permissions to get this form * @throws OCSNotFoundException Could not find form * @@ -489,6 +501,11 @@ public function getQuestion(int $formId, int $questionId): DataResponse { throw new OCSForbiddenException('User has no permissions to get this form'); } + if (!$this->formsService->canSeeQuestions($form)) { + $this->logger->debug('Form is not yet open'); + throw new OCSForbiddenException('This form is not yet open'); + } + $question = $this->formsService->getQuestion($questionId); if ($question === null) { throw new OCSNotFoundException('Question doesn\'t exist'); @@ -1430,6 +1447,7 @@ public function deleteAllSubmissions(int $formId): DataResponse { * @throws OCSBadRequestException At least one submitted answer is not valid * @throws OCSForbiddenException Already submitted * @throws OCSForbiddenException Not allowed to access this form + * @throws OCSForbiddenException This form is not yet taking answers * @throws OCSForbiddenException This form is no longer taking answers * @throws OCSForbiddenException This form is not owned by the current user and user has no `results_delete` permission * @throws OCSNotFoundException Could not find form @@ -1522,6 +1540,7 @@ public function newSubmission(int $formId, array $answers, string $shareHash = ' * @param array> $answers [question_id => arrayOfString] * @return DataResponse * @throws OCSBadRequestException Can only update submission if allowEditSubmissions is set and the answers are valid + * @throws OCSForbiddenException This form is not yet taking answers * @throws OCSForbiddenException Can only update your own submission * * 200: the id of the updated submission @@ -1707,6 +1726,7 @@ public function exportSubmissionsToCloud(int $formId, string $path, string $file * @throws OCSBadRequestException Failed to upload the file * @throws OCSBadRequestException File size exceeds the maximum allowed size * @throws OCSBadRequestException File type is not allowed + * @throws OCSForbiddenException This form is not yet taking answers * @throws OCSForbiddenException Already submitted * @throws OCSNotFoundException Could not find question * @@ -1950,6 +1970,41 @@ private function checkForbiddenKeys(array $keyValuePairs): void { } } + /** + * Throws if the opening or expiration date is not a timestamp or the form would not open before it expires. + * Numeric strings are converted to integers. + * + * @param array $keyValuePairs + */ + private function checkDatesUpdate(Form $form, array &$keyValuePairs): void { + if (!array_key_exists('opens', $keyValuePairs) && !array_key_exists('expires', $keyValuePairs)) { + return; + } + + foreach (['opens' => 'opening', 'expires' => 'expiration'] as $key => $name) { + if (!array_key_exists($key, $keyValuePairs)) { + continue; + } + + $value = $keyValuePairs[$key]; + if (is_string($value) && ctype_digit($value)) { + $value = (int)$value; + } + if (!is_int($value) || $value < 0) { + $this->logger->info("Invalid {$name} date"); + throw new OCSBadRequestException("Invalid {$name} date"); + } + $keyValuePairs[$key] = $value; + } + + $opens = $keyValuePairs['opens'] ?? (int)$form->getOpens(); + $expires = $keyValuePairs['expires'] ?? (int)$form->getExpires(); + if (!FormsService::areFormDatesValid($opens, $expires)) { + $this->logger->info('Opening date must be before expiration date'); + throw new OCSBadRequestException('Opening date must be before expiration date'); + } + } + /** * Throws if fileId is present in update */ @@ -2103,6 +2158,7 @@ private function getResponseSettings(Form $form): array { 'submitMultiple' => $form->getSubmitMultiple(), 'allowEditSubmissions' => $form->getAllowEditSubmissions(), 'expires' => $form->getExpires(), + 'opens' => $form->getOpens(), 'maxSubmissions' => $form->getMaxSubmissions(), ]; } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 457cb9b3a..ef0b54e5b 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -203,6 +203,11 @@ protected function createPublicSubmitView(Form $form, string $hash): TemplateRes return $this->provideEmptyContent(Constants::EMPTY_EXPIRED, $form); } + if (!$this->formsService->hasFormOpened($form)) { + $this->initialState->provideInitialState('opens', $form->getOpens()); + return $this->provideEmptyContent(Constants::EMPTY_NOTYETOPEN, $form); + } + $this->insertHeaderOnIos(); // Inject style on all templates diff --git a/lib/Db/Form.php b/lib/Db/Form.php index bd071e03d..0bd26232a 100644 --- a/lib/Db/Form.php +++ b/lib/Db/Form.php @@ -31,6 +31,8 @@ * @method void setCreated(int $value) * @method int getExpires() * @method void setExpires(int $value) + * @method int getOpens() + * @method void setOpens(int $value) * @method int getIsAnonymous() * @method void setIsAnonymous(bool $value) * @method int getSubmitMultiple() @@ -74,6 +76,7 @@ class Form extends Entity { protected $accessEnum; protected $created; protected $expires; + protected $opens; protected $isAnonymous; protected $submitMultiple; protected $allowEditSubmissions; @@ -96,6 +99,7 @@ class Form extends Entity { public function __construct() { $this->addType('created', 'integer'); $this->addType('expires', 'integer'); + $this->addType('opens', 'integer'); $this->addType('isAnonymous', 'boolean'); $this->addType('submitMultiple', 'boolean'); $this->addType('allowEditSubmissions', 'boolean'); @@ -172,6 +176,7 @@ public function setAccess(array $access): void { * created: int, * access: FormsAccess, * expires: int, + * opens: int, * isAnonymous: bool, * submitMultiple: bool, * allowEditSubmissions: bool, @@ -201,6 +206,7 @@ public function read() { 'created' => $this->getCreated(), 'access' => $this->getAccess(), 'expires' => (int)$this->getExpires(), + 'opens' => (int)$this->getOpens(), 'isAnonymous' => (bool)$this->getIsAnonymous(), 'submitMultiple' => (bool)$this->getSubmitMultiple(), 'allowEditSubmissions' => (bool)$this->getAllowEditSubmissions(), diff --git a/lib/FormsMigrator.php b/lib/FormsMigrator.php index 91b4ad64b..b0fc92855 100644 --- a/lib/FormsMigrator.php +++ b/lib/FormsMigrator.php @@ -144,6 +144,12 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface $form->setOwnerId($user->getUID()); $form->setAccess($formData['access']); $form->setExpires($formData['expires']); + $opens = (int)($formData['opens'] ?? 0); + if ($opens !== 0 && !FormsService::areFormDatesValid($opens, (int)$formData['expires'])) { + $output->writeln('Ignoring invalid opening date of form "' . $formData['title'] . '"'); + $opens = 0; + } + $form->setOpens($opens); $form->setIsAnonymous($formData['isAnonymous']); $form->setSubmitMultiple($formData['submitMultiple']); $form->setAllowEditSubmissions($formData['allowEditSubmissions']); diff --git a/lib/Migration/Version050500Date20261030000000.php b/lib/Migration/Version050500Date20261030000000.php new file mode 100644 index 000000000..f4096217f --- /dev/null +++ b/lib/Migration/Version050500Date20261030000000.php @@ -0,0 +1,41 @@ +getTable('forms_v2_forms'); + + if ($table->hasColumn('opens')) { + return null; + } + + $table->addColumn('opens', Types::BIGINT, [ + 'notnull' => true, + 'default' => 0, + 'comment' => 'Timestamp from which the form accepts responses, 0 means immediately', + ]); + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 4d658b910..15ed97324 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -106,6 +106,7 @@ * hash: string, * title: string, * expires: int, + * opens: int, * permissions: list, * partial: true, * state: int, @@ -123,6 +124,7 @@ * created: int, * access: FormsAccess, * expires: int, + * opens: int, * fileFormat: ?string, * fileId: ?int, * filePath?: ?string, diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 65505267e..197ababbc 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -212,7 +212,7 @@ public function getShares(int $formId): array { */ public function getForm(Form $form): array { $result = $form->read(); - $result['questions'] = $this->getQuestions($form->getId()); + $result['questions'] = $this->canSeeQuestions($form) ? $this->getQuestions($form->getId()) : []; $result['shares'] = $this->getShares($form->getId()); // Append permissions for current user. @@ -266,6 +266,7 @@ public function getPartialFormArrays(array $forms): array { 'hash' => $form->getHash(), 'title' => $form->getTitle(), 'expires' => $form->getExpires(), + 'opens' => $form->getOpens(), 'lastUpdated' => $form->getLastUpdated(), 'permissions' => $permissions, 'partial' => true, @@ -407,6 +408,10 @@ public function loadFormForSubmission(int $formId, string $shareHash): Form { throw new OCSForbiddenException('This form is no longer taking answers'); } + if (!$this->hasFormOpened($form)) { + throw new OCSForbiddenException('This form is not yet taking answers'); + } + return $form; } @@ -462,6 +467,20 @@ public function getPermissions(Form $form): array { return array_values(array_unique($permissions)); } + /** + * Can the current user see the questions of a form. + * Before the opening date only users who can edit the form or see its results can see them. + */ + public function canSeeQuestions(Form $form): bool { + if ($this->hasFormOpened($form)) { + return true; + } + + $permissions = $this->getPermissions($form); + return in_array(Constants::PERMISSION_EDIT, $permissions, true) + || in_array(Constants::PERMISSION_RESULTS, $permissions, true); + } + /** * Can the current user edit a form * @@ -671,6 +690,24 @@ public function hasFormExpired(Form $form): bool { return ($form->getExpires() !== 0 && $form->getExpires() < time()); } + /** + * Check that the opening and expiration date are valid timestamps + * and the form opens before it expires, `0` means not set. + */ + public static function areFormDatesValid(int $opens, int $expires): bool { + if ($opens < 0 || $expires < 0) { + return false; + } + return $opens === 0 || $expires === 0 || $opens < $expires; + } + + /** + * Check if the opening date of the form has been reached. + */ + public function hasFormOpened(Form $form): bool { + return $form->getOpens() <= time(); + } + /** * Get DisplayNames to Shares * diff --git a/openapi.json b/openapi.json index 694617425..4336a8c92 100644 --- a/openapi.json +++ b/openapi.json @@ -103,6 +103,7 @@ "created", "access", "expires", + "opens", "fileFormat", "fileId", "isAnonymous", @@ -154,6 +155,10 @@ "type": "integer", "format": "int64" }, + "opens": { + "type": "integer", + "format": "int64" + }, "fileFormat": { "type": "string", "nullable": true @@ -345,6 +350,7 @@ "hash", "title", "expires", + "opens", "permissions", "partial", "state", @@ -367,6 +373,10 @@ "type": "integer", "format": "int64" }, + "opens": { + "type": "integer", + "format": "int64" + }, "permissions": { "type": "array", "items": { diff --git a/playwright/e2e/form-settings.spec.ts b/playwright/e2e/form-settings.spec.ts index 50d92b41b..708e71c7c 100644 --- a/playwright/e2e/form-settings.spec.ts +++ b/playwright/e2e/form-settings.spec.ts @@ -77,6 +77,50 @@ test.describe('Form settings', () => { await expect(submitView.submitButton).toBeVisible() }) + test('Opening date in the future blocks submissions', async ({ + page, + topBar, + appNavigation, + }) => { + const saved = waitForApiResponse(page, 'PATCH') + await page + .getByRole('switch', { name: /Set opening date/ }) + .click({ force: true }) + await saved + + await expect(appNavigation.getOwnForm(/Settings test form/)).toContainText( + /Opens in/, + ) + + await topBar.toggleView(FormsView.View) + + const main = page.getByRole('main') + await expect(main.getByText('Form not yet open')).toBeVisible() + await expect(main.getByText(/This form will open on/)).toBeVisible() + }) + + test('Removing the opening date allows submissions', async ({ + page, + topBar, + submitView, + }) => { + const set = waitForApiResponse(page, 'PATCH') + await page + .getByRole('switch', { name: /Set opening date/ }) + .click({ force: true }) + await set + + const removed = waitForApiResponse(page, 'PATCH') + await page + .getByRole('switch', { name: /Set opening date/ }) + .click({ force: true }) + await removed + + await topBar.toggleView(FormsView.View) + + await expect(submitView.submitButton).toBeVisible() + }) + test('Anonymous mode shows anonymous message on submit view', async ({ page, topBar, diff --git a/src/FormsEmptyContent.vue b/src/FormsEmptyContent.vue index d9dd1e32a..8f7caf848 100644 --- a/src/FormsEmptyContent.vue +++ b/src/FormsEmptyContent.vue @@ -19,8 +19,9 @@