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
4 changes: 4 additions & 0 deletions docs/API_v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -166,6 +168,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",
Expand Down Expand Up @@ -281,6 +284,7 @@ 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`
- 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.

```
Expand Down
2 changes: 2 additions & 0 deletions docs/DataStructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -282,6 +284,7 @@ public function getForm(int $formId): DataResponse {
* @param int $formId FormId of form to update
* @param array<string, mixed> $keyValuePairs Array of key=>value pairs to update.
* @return DataResponse<Http::STATUS_OK, int|string, array{}>
* @throws OCSBadRequestException Invalid opening date or opening date not before expiration date
Comment thread
Chartman123 marked this conversation as resolved.
Resolved
* @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.
Expand Down Expand Up @@ -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->checkOpeningDateUpdate($form, $keyValuePairs);

$previousAccess = $form->getAccess();
$previousState = $form->getState();
$previousSettings = $this->getResponseSettings($form);
Expand Down Expand Up @@ -1950,6 +1955,28 @@ private function checkForbiddenKeys(array $keyValuePairs): void {
}
}

/**
* Throws if the opening date is invalid or not before the expiration date
*/
private function checkOpeningDateUpdate(Form $form, array $keyValuePairs): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the same check for expiration date?

if (!array_key_exists('opens', $keyValuePairs) && !array_key_exists('expires', $keyValuePairs)) {
return;
}

if (array_key_exists('opens', $keyValuePairs)
&& (!is_int($keyValuePairs['opens']) || $keyValuePairs['opens'] < 0)) {

@AndyScherzinger AndyScherzinger Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Partly corrected after a live check (see the reply below): the opens/expires asymmetry is confirmed; the expires: null part is not, because it is stored as 0.

The validation is lopsided. opens is rejected unless it is a strict PHP int, so a numeric string from a form-encoded client gets a 400. expires gets no type or sign check at all: {"expires": -5} passes this check and is stored as -5. {"expires": null} passes this check (null falls back to the stored value through ??), and then reaches setExpires. Either validate both keys the same way, or cast opens the way the entity addType(integer) already does.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial correction after a live check (test instance built from dd588c85):

  • confirmed: opens sent as a numeric string ("1793384000") gets 400 "Invalid opening date", while expires: -5 is accepted and stored as -5;
  • not reproduced: {opens: <now+1h>, expires: null} returns 200, but the stored expires is 0, not null, so the form is not treated as expired. Please ignore that part of the comment.

The point that stands is the asymmetry: expires gets no type or sign check, while opens does.

$this->logger->info('Invalid opening date');
throw new OCSBadRequestException('Invalid opening date');
}

$opens = $keyValuePairs['opens'] ?? $form->getOpens();
$expires = $keyValuePairs['expires'] ?? $form->getExpires();
if ($opens > 0 && $expires > 0 && $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
*/
Expand Down Expand Up @@ -2103,6 +2130,7 @@ private function getResponseSettings(Form $form): array {
'submitMultiple' => $form->getSubmitMultiple(),
'allowEditSubmissions' => $form->getAllowEditSubmissions(),
'expires' => $form->getExpires(),
'opens' => $form->getOpens(),
'maxSubmissions' => $form->getMaxSubmissions(),
];
}
Expand Down
5 changes: 5 additions & 0 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ protected function createPublicSubmitView(Form $form, string $hash): TemplateRes
return $this->provideEmptyContent(Constants::EMPTY_EXPIRED, $form);
}

if (!$this->formsService->hasFormOpened($form)) {

@AndyScherzinger AndyScherzinger Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Verified live at dd588c85: before the opening date, a respondent the form is shared with gets 200 with all questions from GET /forms/{id} (submitting returns 403). The Submit view's own request carries the question text too.

This check only protects the rendered page (and loadFormForSubmission protects the submit endpoints). ApiController::getForm (GET /api/v3/forms/{formId}, line 275) only calls getFormIfAllowed($formId, Constants::PERMISSION_SUBMIT) and never looks at opens, so before the opening date anyone the form is shared with still gets the full form, including all questions and options.

Scenario: an owner shares a quiz or exam form directly with users and sets it to open tomorrow. A shared user calls GET /ocs/v2.php/apps/forms/api/v3/forms/{id} (or just looks at the Submit view's network request) and reads every question before the form opens. The "Form not yet open" empty content only hides them in the UI.

If keeping questions confidential until the opening date is part of the feature's promise, getForm should not return the questions to users with only submit permission while !hasFormOpened($form). Owners and users with edit or results permission still need them. Otherwise it's worth stating explicitly that the opening date only gates submissions, not visibility.

$this->initialState->provideInitialState('opens', $form->getOpens());
return $this->provideEmptyContent(Constants::EMPTY_NOTYETOPEN, $form);
}

$this->insertHeaderOnIos();

// Inject style on all templates
Expand Down
6 changes: 6 additions & 0 deletions lib/Db/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -74,6 +76,7 @@ class Form extends Entity {
protected $accessEnum;
protected $created;
protected $expires;
protected $opens;
protected $isAnonymous;
protected $submitMultiple;
protected $allowEditSubmissions;
Expand All @@ -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');
Expand Down Expand Up @@ -172,6 +176,7 @@ public function setAccess(array $access): void {
* created: int,
* access: FormsAccess,
* expires: int,
* opens: int,
* isAnonymous: bool,
* submitMultiple: bool,
* allowEditSubmissions: bool,
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions lib/FormsMigrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface
$form->setOwnerId($user->getUID());
$form->setAccess($formData['access']);
$form->setExpires($formData['expires']);
$form->setOpens($formData['opens'] ?? 0);

@AndyScherzinger AndyScherzinger Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Not verified live: the test instance has no user_migration app, so this comes from the code only.

The new invariant (opens >= 0 and opens < expires when both are set) is enforced only in ApiController::checkOpeningDateUpdate. Import goes through here unchecked, so an export with "opens": 1800000000, "expires": 1700000000 creates a form that can never open. Moving the invariant into a shared helper (for example in FormsService), and calling it from both the update and import paths, would close this gap.

$form->setIsAnonymous($formData['isAnonymous']);
$form->setSubmitMultiple($formData['submitMultiple']);
$form->setAllowEditSubmissions($formData['allowEditSubmissions']);
Expand Down
41 changes: 41 additions & 0 deletions lib/Migration/Version050500Date20261030000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

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

namespace OCA\Forms\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\Attributes\AddColumn;
use OCP\Migration\Attributes\ColumnType;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;

#[AddColumn(table: 'forms_v2_forms', name: 'opens', type: ColumnType::INTEGER, description: 'Timestamp from which a form accepts responses')]
class Version050500Date20261030000000 extends SimpleMigrationStep {
#[Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('forms_v2_forms');

if ($table->hasColumn('opens')) {
return null;
}

$table->addColumn('opens', Types::INTEGER, [

@AndyScherzinger AndyScherzinger Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Verified live at dd588c85 (MariaDB 10.6): a 2039 opening date returns 500 with SQLSTATE[22003] (see the reply below).

Types::INTEGER is a 32-bit signed column, and the date picker allows any future date. Choosing an opening date after 2038-01-19 overflows the column (on MariaDB 10.6 this is a 500 with an out-of-range error; other databases were not tested). expires has the same limit, but a new column is the cheap moment to use Types::BIGINT.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked this on a running instance (MariaDB 10.6). It is worse than a silent clamp: PATCH {opens: 2200000000} (2039-09-17) returns 500 Internal Server Error with SQLSTATE[22003]: Numeric value out of range: 1264 Out of range value for column 'opens', and the date picker lets the owner pick that date. Using Types::BIGINT for the new column avoids it. Pre-existing expires has the same limit, but there's no reason to add a second such column.

'notnull' => true,
'default' => 0,
'comment' => 'Timestamp from which the form accepts responses, 0 means immediately',
]);

return $schema;
}
}
2 changes: 2 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
* hash: string,
* title: string,
* expires: int,
* opens: int,
* permissions: list<FormsPermission>,
* partial: true,
* state: int,
Expand All @@ -123,6 +124,7 @@
* created: int,
* access: FormsAccess,
* expires: int,
* opens: int,
* fileFormat: ?string,
* fileId: ?int,
* filePath?: ?string,
Expand Down
12 changes: 12 additions & 0 deletions lib/Service/FormsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)) {

@AndyScherzinger AndyScherzinger Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Verified live at dd588c85: after a future opening date was set, the respondent's /{hash}/submit/{submissionId} page shows "Form not yet open", and PUT /submissions/{id} returns 403.

loadFormForSubmission is used by newSubmission, updateSubmission (ApiController.php:1546) and uploadFiles (:1742), so this check also blocks editing existing submissions while a form is not yet open.

Scenario: a live form with allowEditSubmissions = true has already received responses. The owner sets opens to next Monday to pause it. Respondents opening /{hash}/submit/{submissionId} now see "Form not yet open", and a PUT to update their submission returns 403 "This form is not yet taking answers".

That may be intended ("paused means paused"), but it is a behaviour change for existing respondents and isn't covered by a test either way. If it's not intended, the check should apply only to new submissions (and their uploads), not to updateSubmission.

throw new OCSForbiddenException('This form is not yet taking answers');
}

return $form;
}

Expand Down Expand Up @@ -671,6 +676,13 @@ public function hasFormExpired(Form $form): bool {
return ($form->getExpires() !== 0 && $form->getExpires() < time());
}

/**
* 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
*
Expand Down
10 changes: 10 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
"created",
"access",
"expires",
"opens",
"fileFormat",
"fileId",
"isAnonymous",
Expand Down Expand Up @@ -154,6 +155,10 @@
"type": "integer",
"format": "int64"
},
"opens": {
"type": "integer",
"format": "int64"
},
"fileFormat": {
"type": "string",
"nullable": true
Expand Down Expand Up @@ -345,6 +350,7 @@
"hash",
"title",
"expires",
"opens",
"permissions",
"partial",
"state",
Expand All @@ -367,6 +373,10 @@
"type": "integer",
"format": "int64"
},
"opens": {
"type": "integer",
"format": "int64"
},
"permissions": {
"type": "array",
"items": {
Expand Down
44 changes: 44 additions & 0 deletions playwright/e2e/form-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

await form.addQuestion(QuestionType.ShortAnswer)
const questions = await form.getQuestions()
await questions[0].fillTitle('Your answer')

Check failure on line 34 in playwright/e2e/form-settings.spec.ts

View workflow job for this annotation

GitHub Actions / Playwright tests 1 / 2

[chromium] › playwright/e2e/form-settings.spec.ts:139:2 › Form settings › Non-anonymous mode shows account-connected message on edit view

2) [chromium] › playwright/e2e/form-settings.spec.ts:139:2 › Form settings › Non-anonymous mode shows account-connected message on edit view TypeError: Cannot read properties of undefined (reading 'fillTitle') 32 | await form.addQuestion(QuestionType.ShortAnswer) 33 | const questions = await form.getQuestions() > 34 | await questions[0].fillTitle('Your answer') | ^ 35 | 36 | // Open sidebar and switch to Settings tab 37 | await page.getByRole('button', { name: /Share/ }).click() at /home/runner/actions-runner/_work/forms/forms/playwright/e2e/form-settings.spec.ts:34:22

Check failure on line 34 in playwright/e2e/form-settings.spec.ts

View workflow job for this annotation

GitHub Actions / Playwright tests 1 / 2

[chromium] › playwright/e2e/form-settings.spec.ts:102:2 › Form settings › Removing the opening date allows submissions

1) [chromium] › playwright/e2e/form-settings.spec.ts:102:2 › Form settings › Removing the opening date allows submissions TypeError: Cannot read properties of undefined (reading 'fillTitle') 32 | await form.addQuestion(QuestionType.ShortAnswer) 33 | const questions = await form.getQuestions() > 34 | await questions[0].fillTitle('Your answer') | ^ 35 | 36 | // Open sidebar and switch to Settings tab 37 | await page.getByRole('button', { name: /Share/ }).click() at /home/runner/actions-runner/_work/forms/forms/playwright/e2e/form-settings.spec.ts:34:22

// Open sidebar and switch to Settings tab
await page.getByRole('button', { name: /Share/ }).click()
Expand Down Expand Up @@ -77,6 +77,50 @@
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,
Expand Down
10 changes: 10 additions & 0 deletions src/FormsEmptyContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@

<script lang="ts">
import IconCheck from '@material-symbols/svg-400/outlined/check.svg?raw'
import IconSchedule from '@material-symbols/svg-400/outlined/schedule.svg?raw'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import moment from '@nextcloud/moment'
import { computed, defineComponent } from 'vue'
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
import NcContent from '@nextcloud/vue/components/NcContent'
Expand Down Expand Up @@ -52,6 +54,14 @@ const renderModels: Record<
),
icon: IconCheck,
},

notyetopen: {
title: t('forms', 'Form not yet open'),
description: t('forms', 'This form will open on {date}', {
date: moment(loadState(formsAppName, 'opens', 0), 'X').format('LLL'),
}),
icon: IconSchedule,
},
}

export default defineComponent({
Expand Down
Loading
Loading