Skip to content

Commit 9014225

Browse files
authored
Merge pull request #3492 from nextcloud/fix/1854-missing-formid-uploadedfileid
fix: improve user validation on file question type
2 parents 3ee727d + 3b5a4b2 commit 9014225

11 files changed

Lines changed: 187 additions & 23 deletions

File tree

docs/API_v3.md

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ This file contains the API-Documentation. For more information on the returned D
5454
- `GET /api/v3/forms/{formId}/questions/{questionId}` to get a single question
5555
- `POST /api/v3/forms/{formId}/questions/{questionId}/options` does now accept more options at once
5656
- `PATCH /api/v3/forms/{formId}/questions/{questionId}/options` to reorder the options (request body `newOrder`, optional `optionType`)
57-
- `POST /api/v3/forms/{formId}/submissions/files/{questionId}` to upload a file to a file question before submitting the form
57+
- `POST /api/v3/forms/{formId}/submissions/files/{questionId}` to upload a file to a file question before submitting the form (response includes `uploadToken`; submissions must include it with `uploadedFileId`)
5858
- `GET /api/v3/forms/{formId}/submissions/{submissionId}` to get a single submission
5959
- `PUT /api/v3/forms/{formId}/submissions/{submissionId}` to update an existing submission
6060
- In API version 2.5 the following endpoints were introduced:
@@ -858,25 +858,34 @@ Delete all Submissions to a form
858858

859859
### Upload a file
860860

861-
Upload a file to an answer before form submission
861+
Upload a file to a file question before form submission. Each uploaded file is stored temporarily and bound to the given `formId` and `questionId`. The response includes an `uploadToken` that must be sent back when submitting the form, together with `uploadedFileId`.
862862

863863
- Endpoint: `/api/v3/forms/{formId}/submissions/files/{questionId}`
864864
- Method: `POST`
865865
- Url-Parameters:
866866
| Parameter | Type | Description |
867867
|--------------|----------------|-------------|
868868
| _formId_ | Integer | ID of the form to upload the file to |
869-
| _questionId_ | Integer | ID of the question to upload the file to |
869+
| _questionId_ | Integer | ID of the file question to upload the file to |
870870
- Parameters:
871871
| Parameter | Type | Description |
872872
|--------------|----------------|-------------|
873873
| _files_ | Array of files | Files to upload |
874-
- Response: **Status-Code OK**, as well as the id of the uploaded file and it's name.
874+
| _shareHash_ | String | optional, only necessary for uploads on a public share link |
875+
- Response: **Status-Code OK**, as well as a list of uploaded files.
875876

876877
```
877-
"data": {"uploadedFileId": integer, "fileName": "string"}
878+
"data": [
879+
{
880+
"uploadedFileId": 42,
881+
"fileName": "document.pdf",
882+
"uploadToken": "a1b2c3d4e5f6..."
883+
}
884+
]
878885
```
879886

887+
When submitting the form, each file answer must include both `uploadedFileId` and `uploadToken` from this response for the same `questionId`. Uploads from other forms, questions, or sessions are rejected.
888+
880889
### Get a specific submission
881890

882891
Get a specific submission of a form. Viewing another user's submission requires the `results` permission; otherwise only the submission owner may view it.
@@ -933,15 +942,15 @@ Store Submission to Database
933942
- QuestionID as key
934943
- An **array** of values as value --> Even for short Text Answers, wrapped into Array.
935944
- For Question-Types with pre-defined answers (`multiple`, `multiple_unique`, `dropdown`), the array contains the corresponding option-IDs.
936-
- For File-Uploads, the array contains the objects with key `uploadedFileId` (value from Upload a file endpoint).
945+
- For File-Uploads, the array contains objects with `uploadedFileId` and `uploadToken` (both from the [Upload a file] endpoint for the same `questionId`).
937946

938947
```
939948
{
940949
"1":[27,32], // dropdown or multiple
941950
"2":["ShortTextAnswer"], // All Text-Based Question-Types
942951
"3":[ // File-Upload
943-
{"uploadedFileId": integer},
944-
{"uploadedFileId": integer}
952+
{"uploadedFileId": 42, "uploadToken": "a1b2c3d4e5f6..."},
953+
{"uploadedFileId": 43, "uploadToken": "f6e5d4c3b2a1..."}
945954
],
946955
}
947956
```

lib/Controller/ApiController.php

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
use OCP\IUser;
5454
use OCP\IUserManager;
5555
use OCP\IUserSession;
56+
use OCP\Security\ISecureRandom;
5657
use Psr\Log\LoggerInterface;
5758

5859
/**
@@ -90,6 +91,7 @@ public function __construct(
9091
private readonly UploadedFileMapper $uploadedFileMapper,
9192
private readonly IMimeTypeDetector $mimeTypeDetector,
9293
private readonly IJobList $jobList,
94+
private readonly ISecureRandom $secureRandom,
9395
) {
9496
parent::__construct($appName, $request);
9597
$this->currentUser = $userSession->getUser();
@@ -1428,7 +1430,7 @@ public function newSubmission(int $formId, array $answers, string $shareHash = '
14281430
$questions = $this->formsService->getQuestions($formId);
14291431
try {
14301432
// Is the submission valid
1431-
$this->submissionService->validateSubmission($questions, $answers, $form->getOwnerId());
1433+
$this->submissionService->validateSubmission($questions, $answers, $form->getOwnerId(), $formId);
14321434
} catch (\InvalidArgumentException $e) {
14331435
throw new OCSBadRequestException($e->getMessage());
14341436
}
@@ -1524,7 +1526,7 @@ public function updateSubmission(int $formId, int $submissionId, array $answers)
15241526
$questions = $this->formsService->getQuestions($formId);
15251527
try {
15261528
// Is the submission valid
1527-
$this->submissionService->validateSubmission($questions, $answers, $form->getOwnerId());
1529+
$this->submissionService->validateSubmission($questions, $answers, $form->getOwnerId(), $formId);
15281530
} catch (\InvalidArgumentException $e) {
15291531
throw new OCSBadRequestException($e->getMessage());
15301532
}
@@ -1773,8 +1775,11 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = ''
17731775
$fileName = $folder->getNonExistingName($uploadedFile['name']);
17741776
$file = $folder->newFile($fileName, file_get_contents($uploadedFile['tmp_name']));
17751777

1778+
$uploadToken = $this->secureRandom->generate(32, ISecureRandom::CHAR_ALPHANUMERIC);
17761779
$uploadedFileEntity = new UploadedFile();
17771780
$uploadedFileEntity->setFormId($formId);
1781+
$uploadedFileEntity->setQuestionId($questionId);
1782+
$uploadedFileEntity->setUploadToken($uploadToken);
17781783
$uploadedFileEntity->setOriginalFileName($fileName);
17791784
$uploadedFileEntity->setFileId($file->getId());
17801785
$uploadedFileEntity->setCreated(time());
@@ -1783,6 +1788,7 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = ''
17831788
$response[] = [
17841789
'uploadedFileId' => $uploadedFileEntity->getId(),
17851790
'fileName' => $fileName,
1791+
'uploadToken' => $uploadToken,
17861792
];
17871793
}
17881794

@@ -1797,7 +1803,7 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = ''
17971803
* @param Form $form
17981804
* @param int $submissionId
17991805
* @param array $question
1800-
* @param string[]|array<array{uploadedFileId: string, uploadedFileName: string}> $answerArray
1806+
* @param string[]|array<array{uploadedFileId: string, fileName?: string, uploadToken: string}> $answerArray
18011807
*/
18021808
private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray): void {
18031809
if ($question['type'] === Constants::ANSWER_TYPE_GRID || $question['type'] === Constants::ANSWER_TYPE_RANKING) {
@@ -1833,7 +1839,12 @@ private function storeAnswersForQuestion(Form $form, $submissionId, array $quest
18331839
$answerText = str_replace(Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX, '', $answer);
18341840
}
18351841
} elseif ($question['type'] === Constants::ANSWER_TYPE_FILE) {
1836-
$uploadedFile = $this->uploadedFileMapper->getByUploadedFileId($answer['uploadedFileId']);
1842+
$uploadedFile = $this->uploadedFileMapper->getForSubmission(
1843+
(int)$answer['uploadedFileId'],
1844+
$form->getId(),
1845+
$question['id'],
1846+
$answer['uploadToken'] ?? '',
1847+
);
18371848
$answerEntity->setFileId($uploadedFile->getFileId());
18381849

18391850
$userFolder = $this->rootFolder->getUserFolder($form->getOwnerId());

lib/Db/UploadedFile.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
/**
1515
* @method int getFormId()
1616
* @method void setFormId(int $value)
17+
* @method ?int getQuestionId()
18+
* @method void setQuestionId(?int $value)
19+
* @method ?string getUploadToken()
20+
* @method void setUploadToken(?string $value)
1721
* @method string getOriginalFileName()
1822
* @method void setOriginalFileName(string $value)
1923
* @method int getFileId()
@@ -23,6 +27,8 @@
2327
*/
2428
class UploadedFile extends Entity {
2529
protected $formId;
30+
protected ?int $questionId = null;
31+
protected ?string $uploadToken = null;
2632
protected $originalFileName;
2733
protected $fileId;
2834
protected $created;
@@ -32,6 +38,8 @@ class UploadedFile extends Entity {
3238
*/
3339
public function __construct() {
3440
$this->addType('formId', 'integer');
41+
$this->addType('questionId', 'integer');
42+
$this->addType('uploadToken', 'string');
3543
$this->addType('originalFileName', 'string');
3644
$this->addType('fileId', 'integer');
3745
$this->addType('created', 'integer');
@@ -41,6 +49,7 @@ public function read(): array {
4149
return [
4250
'id' => $this->getId(),
4351
'formId' => $this->getFormId(),
52+
'questionId' => $this->getQuestionId(),
4453
'originalFileName' => $this->getOriginalFileName(),
4554
'fileId' => $this->getFileId(),
4655
'created' => $this->getCreated(),

lib/Db/UploadedFileMapper.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,30 @@ public function getByUploadedFileId(string $uploadedFileId): UploadedFile {
5555
return $uploadedFile;
5656
}
5757

58+
/**
59+
* find all uploaded files for a given form and question and compare with the given upload token.
60+
*
61+
* @throws DoesNotExistException
62+
*/
63+
public function getForSubmission(int $uploadedFileId, int $formId, int $questionId, string $uploadToken): UploadedFile {
64+
$qb = $this->db->getQueryBuilder();
65+
66+
$qb->select('*')
67+
->from($this->getTableName())
68+
->where($qb->expr()->eq('id', $qb->createNamedParameter($uploadedFileId, IQueryBuilder::PARAM_INT)))
69+
->andWhere($qb->expr()->eq('form_id', $qb->createNamedParameter($formId, IQueryBuilder::PARAM_INT)))
70+
->andWhere($qb->expr()->eq('question_id', $qb->createNamedParameter($questionId, IQueryBuilder::PARAM_INT)));
71+
72+
$uploadedFile = $this->findEntities($qb)[0] ?? null;
73+
if ($uploadedFile === null
74+
|| $uploadedFile->getUploadToken() === null
75+
|| !hash_equals($uploadedFile->getUploadToken(), $uploadToken)) {
76+
throw new DoesNotExistException(sprintf('Uploaded file with id "%d" not found', $uploadedFileId));
77+
}
78+
79+
return $uploadedFile;
80+
}
81+
5882
/**
5983
* @param \DateTimeImmutable $dateTime
6084
* @return UploadedFile[]
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Forms\Migration;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\DB\Types;
15+
use OCP\Migration\IOutput;
16+
use OCP\Migration\SimpleMigrationStep;
17+
use Override;
18+
19+
class Version050300Date20260713180000 extends SimpleMigrationStep {
20+
21+
/**
22+
* @param IOutput $output
23+
* @param Closure(): ISchemaWrapper $schemaClosure
24+
* @param array $options
25+
* @return null|ISchemaWrapper
26+
*/
27+
#[Override]
28+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
29+
/** @var ISchemaWrapper $schema */
30+
$schema = $schemaClosure();
31+
32+
if (!$schema->hasTable('forms_v2_uploaded_files')) {
33+
return null;
34+
}
35+
36+
$table = $schema->getTable('forms_v2_uploaded_files');
37+
$changed = false;
38+
39+
if (!$table->hasColumn('question_id')) {
40+
$table->addColumn('question_id', Types::INTEGER, [
41+
'notnull' => false,
42+
]);
43+
$changed = true;
44+
}
45+
46+
if (!$table->hasColumn('upload_token')) {
47+
$table->addColumn('upload_token', Types::STRING, [
48+
'notnull' => false,
49+
'length' => 64,
50+
]);
51+
$changed = true;
52+
}
53+
54+
return $changed ? $schema : null;
55+
}
56+
}

lib/ResponseDefinitions.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,8 @@
151151
*
152152
* @psalm-type FormsUploadedFile = array{
153153
* uploadedFileId: int,
154-
* fileName: string
154+
* fileName: string,
155+
* uploadToken: string,
155156
* }
156157
*/
157158
class ResponseDefinitions {

lib/Service/SubmissionService.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -470,9 +470,10 @@ private function escapeCSV(string $value): string {
470470
* @param array $questions Array of the questions of the form
471471
* @param array $answers Array of the submitted answers
472472
* @param string $formOwnerId Owner of the form
473+
* @param int $formId Id of the form being submitted
473474
* @throw \InvalidArgumentException if validation failed
474475
*/
475-
public function validateSubmission(array $questions, array $answers, string $formOwnerId): void {
476+
public function validateSubmission(array $questions, array $answers, string $formOwnerId, int $formId): void {
476477
// Check by questions
477478
foreach ($questions as $question) {
478479
$questionId = $question['id'];
@@ -485,7 +486,7 @@ public function validateSubmission(array $questions, array $answers, string $for
485486
if (is_array($value)) {
486487
// file type
487488
if (isset($value['uploadedFileId'])) {
488-
return !empty($value['uploadedFileId']);
489+
return !empty($value['uploadedFileId']) && !empty($value['uploadToken']);
489490
}
490491

491492
// Grid questions
@@ -613,8 +614,14 @@ public function validateSubmission(array $questions, array $answers, string $for
613614
}
614615

615616
foreach ($answers[$questionId] as $answer) {
616-
$uploadedFile = $this->uploadedFileMapper->findByUploadedFileId($answer['uploadedFileId']);
617-
if (!$uploadedFile) {
617+
try {
618+
$uploadedFile = $this->uploadedFileMapper->getForSubmission(
619+
(int)$answer['uploadedFileId'],
620+
$formId,
621+
$questionId,
622+
$answer['uploadToken'] ?? '',
623+
);
624+
} catch (DoesNotExistException) {
618625
throw new \InvalidArgumentException(sprintf('File "%s" for question "%s" not exists anymore. Please delete and re-upload the file.', $answer['fileName'] ?? $answer['uploadedFileId'], $question['text']));
619626
}
620627

openapi.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,8 @@
703703
"type": "object",
704704
"required": [
705705
"uploadedFileId",
706-
"fileName"
706+
"fileName",
707+
"uploadToken"
707708
],
708709
"properties": {
709710
"uploadedFileId": {
@@ -712,6 +713,9 @@
712713
},
713714
"fileName": {
714715
"type": "string"
716+
},
717+
"uploadToken": {
718+
"type": "string"
715719
}
716720
}
717721
}

tests/Integration/Api/ApiV3Test.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -780,12 +780,13 @@ public function testDeleteFormWithSubmissions() {
780780

781781
$uploadedFileData = $this->OcsResponse2Data($uploadedFileResponse);
782782
$uploadedFileId = $uploadedFileData[0]['uploadedFileId'];
783+
$uploadToken = $uploadedFileData[0]['uploadToken'];
783784

784785
// Create a submission with the file
785786
$resp = $this->http->request('POST', "api/v3/forms/$formId/submissions", [
786787
'json' => [
787788
'answers' => [
788-
$questionId => [['uploadedFileId' => $uploadedFileId]]
789+
$questionId => [['uploadedFileId' => $uploadedFileId, 'uploadToken' => $uploadToken]]
789790
]
790791
]
791792
]);
@@ -1403,13 +1404,14 @@ public function testDeleteSubmissionWithFiles() {
14031404

14041405
$uploadedFileData = $this->OcsResponse2Data($uploadedFileResponse);
14051406
$uploadedFileId = $uploadedFileData[0]['uploadedFileId'];
1407+
$uploadToken = $uploadedFileData[0]['uploadToken'];
14061408

14071409
// Create a new submission with the file
14081410
$resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/submissions", [
14091411
'json' => [
14101412
'answers' => [
14111413
$this->testForms[0]['questions'][0]['id'] => ['Test Answer'],
1412-
$this->testForms[0]['questions'][2]['id'] => [['uploadedFileId' => $uploadedFileId]]
1414+
$this->testForms[0]['questions'][2]['id'] => [['uploadedFileId' => $uploadedFileId, 'uploadToken' => $uploadToken]]
14131415
]
14141416
]
14151417
]);
@@ -1518,6 +1520,7 @@ public function testNewSubmission() {
15181520

15191521
$data = $this->OcsResponse2Data($uploadedFileResponse);
15201522
$uploadedFileId = $data[0]['uploadedFileId'];
1523+
$uploadToken = $data[0]['uploadToken'];
15211524

15221525
$resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/submissions", [
15231526
'json' => [
@@ -1526,7 +1529,7 @@ public function testNewSubmission() {
15261529
$this->testForms[0]['questions'][1]['id'] => [
15271530
$this->testForms[0]['questions'][1]['options'][0]['id']
15281531
],
1529-
$this->testForms[0]['questions'][2]['id'] => [['uploadedFileId' => $uploadedFileId]]
1532+
$this->testForms[0]['questions'][2]['id'] => [['uploadedFileId' => $uploadedFileId, 'uploadToken' => $uploadToken]]
15301533
]
15311534
]
15321535
]);

0 commit comments

Comments
 (0)