diff --git a/appinfo/info.xml b/appinfo/info.xml
index 1bd047f25b..5f6d421a4a 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -13,7 +13,7 @@
- **🙋 Get involved!** We have lots of stuff planned like more question types, collaboration on forms, [and much more](https://github.com/nextcloud/forms/milestones)!
]]>
- 4.2.3
+ 4.2.4
agpl
Affan Hussain
@@ -54,6 +54,10 @@
+
+ OCA\Forms\BackgroundJob\CleanupUploadedFilesJob
+
+
OCA\Forms\Settings\Settings
OCA\Forms\Settings\SettingsSection
diff --git a/appinfo/routes.php b/appinfo/routes.php
index bfb2e00947..0c1fea1a9e 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -331,6 +331,14 @@
'apiVersion' => 'v2(\.[1-4])?'
]
],
+ [
+ 'name' => 'api#uploadFiles',
+ 'url' => '/api/{apiVersion}/uploadFiles/{formId}/{questionId}',
+ 'verb' => 'POST',
+ 'requirements' => [
+ 'apiVersion' => 'v2.5'
+ ]
+ ],
[
'name' => 'api#insertSubmission',
'url' => '/api/{apiVersion}/submission/insert',
diff --git a/composer.json b/composer.json
index 1c0bf18cce..bc3b261634 100644
--- a/composer.json
+++ b/composer.json
@@ -30,7 +30,8 @@
"phpunit/phpunit": "^9"
},
"require": {
- "phpoffice/phpspreadsheet": "^2.0"
+ "phpoffice/phpspreadsheet": "^2.0",
+ "symfony/polyfill-uuid": "^1.29"
},
"extra": {
"bamarni-bin": {
diff --git a/composer.lock b/composer.lock
index 81c84c0687..7f5eee6ddb 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "9f8bb75cc8745c84b1408667f1ed4c46",
+ "content-hash": "2439cfdde1500d089bad47f8cd74d27c",
"packages": [
{
"name": "maennchen/zipstream-php",
@@ -962,6 +962,85 @@
],
"time": "2024-01-29T20:11:03+00:00"
},
+ {
+ "name": "symfony/polyfill-uuid",
+ "version": "v1.29.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-uuid.git",
+ "reference": "3abdd21b0ceaa3000ee950097bc3cf9efc137853"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/3abdd21b0ceaa3000ee950097bc3cf9efc137853",
+ "reference": "3abdd21b0ceaa3000ee950097bc3cf9efc137853",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "provide": {
+ "ext-uuid": "*"
+ },
+ "suggest": {
+ "ext-uuid": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Uuid\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Grégoire Pineau",
+ "email": "lyrixx@lyrixx.info"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for uuid functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "uuid"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-uuid/tree/v1.29.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-01-29T20:11:03+00:00"
+ },
{
"name": "voku/anti-xss",
"version": "4.1.42",
diff --git a/docs/API.md b/docs/API.md
index 86f72b4306..7fa5cacf80 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -28,6 +28,8 @@ This file contains the API-Documentation. For more information on the returned D
- Completely new way of handling access & shares.
### Other API changes
+- In API version 2.5 the following endpoints were introduced:
+ - `POST /api/2.5/uploadFiles/{formId}/{questionId}` to upload files to answer before form submitting
- In API version 2.4 the following endpoints were introduced:
- `POST /api/2.4/form/link/{fileFormat}` to link form to a file
- `POST /api/2.4/form/unlink` to unlink form from a file
@@ -629,6 +631,21 @@ Delete all Submissions to a form
"data": 3
```
+### Upload a file
+Upload a files to answer before form submitting
+- Endpoint: `/api/2.5/uploadFiles/{formId}/{questionId}`
+- Method: `POST`
+- Parameters:
+ | Parameter | Type | Description |
+ |-----------|---------|-------------|
+ | _formId_ | Integer | ID of the form to upload the file to |
+ | _questionId_ | Integer | ID of the question to upload the file to |
+ | _files_ | Array of files | Files to upload |
+- Response: **Status-Code OK**, as well as the id of the uploaded file and it's name.
+```
+"data": {"uploadedFileId": "uuid4", "fileName": "string"}
+```
+
### Insert a Submission
Store Submission to Database
- Endpoint: `/api/v2.4/submission/insert`
@@ -644,10 +661,15 @@ Store Submission to Database
- QuestionID as key
- 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).
```
{
"1":[27,32], // dropdown or multiple
"2":["ShortTextAnswer"], // All Text-Based Question-Types
+ "3":[ // File-Upload
+ {"uploadedFileId": "uuid4"},
+ {"uploadedFileId": "uuid4"}
+ ],
}
```
- Response: **Status-Code OK**.
diff --git a/docs/DataStructure.md b/docs/DataStructure.md
index 4c6f7345de..a5865bf42f 100644
--- a/docs/DataStructure.md
+++ b/docs/DataStructure.md
@@ -194,11 +194,15 @@ Currently supported Question-Types are:
## Extra Settings
Optional extra settings for some [Question Types](#question-types)
-| Extra Setting | Question Type | Type | Values | Description |
-|--------------------|---------------|---------|--------|-------------|
-| `allowOtherAnswer` | `multiple, multiple_unique` | Boolean | `true/false` | Allows the user to specify a custom answer |
-| `shuffleOptions` | `dropdown, multiple, multiple_unique` | Boolean | `true/false` | The list of options should be shuffled |
-| `optionsLimitMax` | `multiple` | Integer | - | Maximum number of options that can be selected |
-| `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 |
+| Extra Setting | Question Type | Type | Values | Description |
+|-------------------------|---------------------------------------|------------------|---------------------------------------------|-----------------------------------------------------------------------------|
+| `allowOtherAnswer` | `multiple, multiple_unique` | Boolean | `true/false` | Allows the user to specify a custom answer |
+| `shuffleOptions` | `dropdown, multiple, multiple_unique` | Boolean | `true/false` | The list of options should be shuffled |
+| `optionsLimitMax` | `multiple` | Integer | - | Maximum number of options that can be selected |
+| `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 |
+| `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 |
+| `maxFileSize` | `file` | Integer | - | Maximum file size in bytes, 0 means no limit |
diff --git a/lib/BackgroundJob/CleanupUploadedFilesJob.php b/lib/BackgroundJob/CleanupUploadedFilesJob.php
new file mode 100644
index 0000000000..dd24d03d24
--- /dev/null
+++ b/lib/BackgroundJob/CleanupUploadedFilesJob.php
@@ -0,0 +1,92 @@
+setInterval(60 * 60 * 24);
+
+ }
+
+ /**
+ * @param array $argument
+ */
+ public function run($argument): void {
+ $dateTime = new \DateTimeImmutable(self::FILE_LIFETIME);
+
+ $this->logger->info('Deleting files that were uploaded before {before} and still not submitted.', [
+ 'before' => $dateTime->format(\DateTimeImmutable::ATOM),
+ ]);
+
+ $uploadedFiles = $this->uploadedFileMapper->findUploadedEarlierThan($dateTime);
+
+ $deleted = 0;
+ $usersToCleanup = [];
+ foreach ($uploadedFiles as $uploadedFile) {
+ $this->logger->info('Deleting uploaded file "{originalFileName}" for form {formId}.', [
+ 'originalFileName' => $uploadedFile->getOriginalFileName(),
+ 'formId' => $uploadedFile->getFormId(),
+ ]);
+
+ $form = $this->formMapper->findById($uploadedFile->getFormId());
+ $usersToCleanup[$form->getOwnerId()] = true;
+ $userFolder = $this->storage->getUserFolder($form->getOwnerId());
+
+ $nodes = $userFolder->getById($uploadedFile->getFileId());
+
+ if (!empty($nodes)) {
+ $node = $nodes[0];
+ $node->delete();
+ } else {
+ $this->logger->warning('Could not find uploaded file "{fileId}" for deletion.', [
+ 'fileId' => $uploadedFile->getFileId(),
+ ]);
+ }
+
+ $this->uploadedFileMapper->delete($uploadedFile);
+
+ $deleted++;
+ }
+
+ $this->logger->info('Deleted {deleted} uploaded files.', ['deleted' => $deleted]);
+
+ // now delete empty folders in user folders
+ $deleted = 0;
+ foreach (array_keys($usersToCleanup) as $userId) {
+ $this->logger->info('Cleaning up empty folders for user {userId}.', ['userId' => $userId]);
+ $userFolder = $this->storage->getUserFolder($userId);
+
+ $unsubmittedFilesFolder = $userFolder->get(Constants::UNSUBMITTED_FILES_FOLDER);
+ if (!$unsubmittedFilesFolder instanceof Folder) {
+ continue;
+ }
+
+ foreach ($unsubmittedFilesFolder->getDirectoryListing() as $node) {
+ if ($node->getName() < $dateTime->getTimestamp()) {
+ $node->delete();
+ $deleted++;
+ }
+ }
+ }
+
+ $this->logger->info('Deleted {deleted} folders.', ['deleted' => $deleted]);
+ }
+}
diff --git a/lib/Constants.php b/lib/Constants.php
index e40e1971c0..7ed8fff135 100644
--- a/lib/Constants.php
+++ b/lib/Constants.php
@@ -91,6 +91,7 @@ class Constants {
public const ANSWER_TYPE_DATE = 'date';
public const ANSWER_TYPE_DATETIME = 'datetime';
public const ANSWER_TYPE_TIME = 'time';
+ public const ANSWER_TYPE_FILE = 'file';
// All AnswerTypes
public const ANSWER_TYPES = [
@@ -101,7 +102,8 @@ class Constants {
self::ANSWER_TYPE_LONG,
self::ANSWER_TYPE_DATE,
self::ANSWER_TYPE_DATETIME,
- self::ANSWER_TYPE_TIME
+ self::ANSWER_TYPE_TIME,
+ self::ANSWER_TYPE_FILE,
];
// AnswerTypes, that need/have predefined Options
@@ -155,6 +157,21 @@ class Constants {
'validationRegex' => ['string'],
];
+ public const EXTRA_SETTINGS_FILE = [
+ 'allowedFileTypes',
+ 'allowedFileExtensions',
+ 'maxAllowedFilesCount',
+ 'maxFileSize',
+ ];
+
+ // should be in sync with FileTypes.js
+ public const EXTRA_SETTINGS_ALLOWED_FILE_TYPES = [
+ 'image',
+ 'x-office/document',
+ 'x-office/presentation',
+ 'x-office/spreadsheet',
+ ];
+
/**
* !! Keep in sync with src/mixins/ShareTypes.js !!
*/
@@ -204,4 +221,8 @@ class Constants {
];
public const DEFAULT_FILE_FORMAT = 'csv';
+
+ public const UNSUBMITTED_FILES_FOLDER = 'forms/unsubmitted';
+
+ public const FILES_FOLDER = 'forms';
}
diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php
index ad0a9bcc8d..3fe483a9e8 100644
--- a/lib/Controller/ApiController.php
+++ b/lib/Controller/ApiController.php
@@ -40,6 +40,8 @@
use OCA\Forms\Db\ShareMapper;
use OCA\Forms\Db\Submission;
use OCA\Forms\Db\SubmissionMapper;
+use OCA\Forms\Db\UploadedFile;
+use OCA\Forms\Db\UploadedFileMapper;
use OCA\Forms\Service\ConfigService;
use OCA\Forms\Service\FormsService;
use OCA\Forms\Service\SubmissionService;
@@ -48,10 +50,13 @@
use OCP\AppFramework\Db\IMapperException;
use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\Http\Response;
use OCP\AppFramework\OCS\OCSBadRequestException;
use OCP\AppFramework\OCS\OCSForbiddenException;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCSController;
+use OCP\Files\IMimeTypeDetector;
+use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IL10N;
use OCP\IRequest;
@@ -81,6 +86,9 @@ public function __construct(
private IL10N $l10n,
private LoggerInterface $logger,
private IUserManager $userManager,
+ private IRootFolder $storage,
+ private UploadedFileMapper $uploadedFileMapper,
+ private IMimeTypeDetector $mimeTypeDetector,
) {
parent::__construct($appName, $request);
$this->currentUser = $userSession->getUser();
@@ -918,14 +926,19 @@ public function getSubmissions(string $hash): DataResponse {
/**
* Insert answers for a question
*
+ * @param Form $form
* @param int $submissionId
* @param array $question
- * @param array $answerArray [arrayOfString]
+ * @param string[]|array $answerArray
*/
- private function storeAnswersForQuestion($submissionId, array $question, array $answerArray) {
+ private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray) {
foreach ($answerArray as $answer) {
- $answerText = '';
+ $answerEntity = new Answer();
+ $answerEntity->setSubmissionId($submissionId);
+ $answerEntity->setQuestionId($question['id']);
+ $answerText = '';
+ $uploadedFile = null;
// Are we using answer ids as values
if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) {
// Search corresponding option, skip processing if not found
@@ -935,6 +948,23 @@ private function storeAnswersForQuestion($submissionId, array $question, array $
} elseif (!empty($question['extraSettings']['allowOtherAnswer']) && strpos($answer, Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX) === 0) {
$answerText = str_replace(Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX, "", $answer);
}
+ } elseif ($question['type'] === Constants::ANSWER_TYPE_FILE) {
+ $uploadedFile = $this->uploadedFileMapper->findByUploadedFileId($answer['uploadedFileId']);
+ $answerEntity->setFileId($uploadedFile->getFileId());
+
+ $userFolder = $this->storage->getUserFolder($form->getOwnerId());
+ $path = $this->formsService->getUploadedFilePath($form, $submissionId, $question['id'], $question['name'], $question['text']);
+
+ if ($userFolder->nodeExists($path)) {
+ $folder = $userFolder->get($path);
+ } else {
+ $folder = $userFolder->newFolder($path);
+ }
+
+ $file = $userFolder->getById($uploadedFile->getFileId())[0];
+ $file->move($folder->getPath() . '/' . $file->getName());
+
+ $answerText = $file->getName();
} else {
$answerText = $answer; // Not a multiple-question, answerText is given answer
}
@@ -943,12 +973,135 @@ private function storeAnswersForQuestion($submissionId, array $question, array $
continue;
}
- $answerEntity = new Answer();
- $answerEntity->setSubmissionId($submissionId);
- $answerEntity->setQuestionId($question['id']);
$answerEntity->setText($answerText);
$this->answerMapper->insert($answerEntity);
+ if ($uploadedFile) {
+ $this->uploadedFileMapper->delete($uploadedFile);
+ }
+ }
+ }
+
+
+ /**
+ * @CORS
+ * @NoAdminRequired
+ * @PublicPage
+ *
+ * Uploads a temporary files to the server during form filling
+ *
+ * @return Response
+ */
+ public function uploadFiles(int $formId, int $questionId, string $shareHash = ''): Response {
+ $this->logger->debug('Uploading files for formId: {formId}, questionId: {questionId}',
+ ['formId' => $formId, 'questionId' => $questionId]);
+
+ $uploadedFiles = [];
+ foreach ($this->request->getUploadedFile('files') as $key => $files) {
+ foreach ($files as $i => $value) {
+ $uploadedFiles[$i][$key] = $value;
+ }
+ }
+
+ if (!count($uploadedFiles)) {
+ throw new OCSBadRequestException($this->l10n->t('No file provided'));
+ }
+
+ $form = $this->loadFormForSubmission($formId, $shareHash);
+
+ if (!$this->formsService->canSubmit($form)) {
+ throw new OCSForbiddenException('Already submitted');
+ }
+
+ try {
+ $question = $this->questionMapper->findById($questionId);
+ } catch (IMapperException $e) {
+ $this->logger->debug('Could not find question with id {questionId}', ['questionId' => $questionId]);
+ throw new OCSBadRequestException(previous: $e instanceof \Exception ? $e : null);
}
+
+ $path = $this->formsService->getTemporaryUploadedFilePath($form, $question);
+
+ $response = [];
+ foreach ($uploadedFiles as $uploadedFile) {
+ $error = $uploadedFile['error'] ?? 0;
+ if ($error !== UPLOAD_ERR_OK) {
+ $this->logger->error('Failed to get the uploaded file. PHP file upload error code: ' . $error,
+ ['file_name' => $uploadedFile['name']]);
+
+ throw new OCSBadRequestException($this->l10n->t('Failed to upload the file "%s".', [$uploadedFile['name']]));
+ }
+
+ if (!is_uploaded_file($uploadedFile['tmp_name'])) {
+ throw new OCSBadRequestException($this->l10n->t('Invalid file provided'));
+ }
+
+ $userFolder = $this->storage->getUserFolder($form->getOwnerId());
+ $userFolder->getStorage()->verifyPath($path, $uploadedFile['name']);
+
+ $extraSettings = $question->getExtraSettings();
+ if ($extraSettings['maxFileSize'] > 0 && $uploadedFile['size'] > $extraSettings['maxFileSize']) {
+ throw new OCSBadRequestException($this->l10n->t('File size exceeds the maximum allowed size of %s bytes.',
+ [$extraSettings['maxFileSize']]
+ ));
+ }
+
+ if (!empty($extraSettings['allowedFileTypes']) || !empty($extraSettings['allowedFileExtensions'])) {
+ $mimeType = $this->mimeTypeDetector->detectContent($uploadedFile['tmp_name']);
+ $secureMimeType = $this->mimeTypeDetector->getSecureMimeType($mimeType);
+
+ $valid = false;
+ foreach ($extraSettings['allowedFileTypes'] ?? [] as $allowedFileType) {
+ if (str_starts_with($secureMimeType, $allowedFileType)) {
+ $valid = true;
+ break;
+ }
+ }
+
+ if (!$valid && !empty($extraSettings['allowedFileExtensions'])) {
+ $mimeTypesPerExtension = method_exists($this->mimeTypeDetector, 'getAllMappings')
+ ? $this->mimeTypeDetector->getAllMappings() : [];
+ foreach ($extraSettings['allowedFileExtensions'] as $allowedFileExtension) {
+ if (isset($mimeTypesPerExtension[$allowedFileExtension])
+ && in_array($mimeType, $mimeTypesPerExtension[$allowedFileExtension])) {
+ $valid = true;
+ break;
+ }
+ }
+ }
+
+ if (!$valid) {
+ throw new OCSBadRequestException($this->l10n->t('File type is not allowed. Allowed file types: %s',
+ implode(', ', array_merge($extraSettings['allowedFileTypes'] ?? [], $extraSettings['allowedFileExtensions'] ?? []))
+ ));
+ }
+ }
+
+ if ($userFolder->nodeExists($path)) {
+ $folder = $userFolder->get($path);
+ } else {
+ $folder = $userFolder->newFolder($path);
+ }
+ /** @var \OCP\Files\Folder $folder */
+
+ $fileName = $folder->getNonExistingName($uploadedFile['name']);
+ $file = $folder->newFile($fileName, file_get_contents($uploadedFile['tmp_name']));
+
+ $uploadedFileId = uuid_create(\UUID_TYPE_RANDOM);
+ $uploadedFileEntity = new UploadedFile();
+ $uploadedFileEntity->setId($uploadedFileId);
+ $uploadedFileEntity->setFormId($formId);
+ $uploadedFileEntity->setOriginalFileName($fileName);
+ $uploadedFileEntity->setFileId($file->getId());
+ $uploadedFileEntity->setCreated(time());
+ $this->uploadedFileMapper->insert($uploadedFileEntity);
+
+ $response[] = [
+ 'uploadedFileId' => $uploadedFileId,
+ 'fileName' => $fileName,
+ ];
+ }
+
+ return new DataResponse($response);
}
/**
@@ -973,47 +1126,15 @@ public function insertSubmission(int $formId, array $answers, string $shareHash
'shareHash' => $shareHash,
]);
- try {
- $form = $this->formMapper->findById($formId);
- $questions = $this->formsService->getQuestions($formId);
- } catch (IMapperException $e) {
- $this->logger->debug('Could not find form');
- throw new OCSBadRequestException();
- }
-
- // Does the user have access to the form (Either by logged in user, or by providing public share-hash.)
- try {
- $isPublicShare = false;
-
- // If hash given, find the corresponding share & check if hash corresponds to given formId.
- if ($shareHash !== '') {
- // public by legacy Link
- if (isset($form->getAccess()['legacyLink']) && $shareHash === $form->getHash()) {
- $isPublicShare = true;
- }
-
- // Public link share
- $share = $this->shareMapper->findPublicShareByHash($shareHash);
- if ($share->getFormId() === $formId) {
- $isPublicShare = true;
- }
- }
- } catch (DoesNotExistException $e) {
- // $isPublicShare already false.
- } finally {
- // Now forbid, if no public share and no direct share.
- if (!$isPublicShare && !$this->formsService->hasUserAccess($form)) {
- throw new OCSForbiddenException('Not allowed to access this form');
- }
- }
-
- // Not allowed if form has expired.
- if ($this->formsService->hasFormExpired($form)) {
- throw new OCSForbiddenException('This form is no longer taking answers');
- }
+ $form = $this->loadFormForSubmission($formId, $shareHash);
+ $questions = $this->formsService->getQuestions($formId);
// Is the submission valid
- if (!$this->submissionService->validateSubmission($questions, $answers)) {
+ $isSubmissionValid = $this->submissionService->validateSubmission($questions, $answers, $form->getOwnerId());
+ if (is_string($isSubmissionValid)) {
+ throw new OCSBadRequestException($isSubmissionValid);
+ }
+ if ($isSubmissionValid === false) {
throw new OCSBadRequestException('At least one submitted answer is not valid');
}
@@ -1054,7 +1175,7 @@ public function insertSubmission(int $formId, array $answers, string $shareHash
continue;
}
- $this->storeAnswersForQuestion($submission->getId(), $questions[$questionIndex], $answerArray);
+ $this->storeAnswersForQuestion($form, $submission->getId(), $questions[$questionIndex], $answerArray);
}
$this->formsService->setLastUpdatedTimestamp($formId);
@@ -1301,6 +1422,48 @@ public function linkFile(string $hash, string $path, string $fileFormat): DataRe
]);
}
+ private function loadFormForSubmission(int $formId, string $shareHash): Form {
+ try {
+ $form = $this->formMapper->findById($formId);
+ } catch (IMapperException $e) {
+ $this->logger->debug('Could not find form');
+ throw new OCSBadRequestException(previous: $e instanceof \Exception ? $e : null);
+ }
+
+ // Does the user have access to the form (Either by logged-in user, or by providing public share-hash.)
+ try {
+ $isPublicShare = false;
+
+ // If hash given, find the corresponding share & check if hash corresponds to given formId.
+ if ($shareHash !== '') {
+ // public by legacy Link
+ if (isset($form->getAccess()['legacyLink']) && $shareHash === $form->getHash()) {
+ $isPublicShare = true;
+ }
+
+ // Public link share
+ $share = $this->shareMapper->findPublicShareByHash($shareHash);
+ if ($share->getFormId() === $formId) {
+ $isPublicShare = true;
+ }
+ }
+ } catch (DoesNotExistException $e) {
+ // $isPublicShare already false.
+ } finally {
+ // Now forbid, if no public share and no direct share.
+ if (!$isPublicShare && !$this->formsService->hasUserAccess($form)) {
+ throw new OCSForbiddenException('Not allowed to access this form');
+ }
+ }
+
+ // Not allowed if form has expired.
+ if ($this->formsService->hasFormExpired($form)) {
+ throw new OCSForbiddenException('This form is no longer taking answers');
+ }
+
+ return $form;
+ }
+
/**
* Helper that retrieves a form if the current user is allowed to edit it
* This throws an exception in case either the form is not found or permissions are missing.
diff --git a/lib/Db/Answer.php b/lib/Db/Answer.php
index 0e5c2e911e..2b6097cd47 100644
--- a/lib/Db/Answer.php
+++ b/lib/Db/Answer.php
@@ -35,12 +35,15 @@
* @method void setSubmissionId(integer $value)
* @method integer getQuestionId()
* @method void setQuestionId(integer $value)
+ * @method integer|null getFileId()
+ * @method void setFileId(?integer $value)
* @method string getText()
* @method void setText(string $value)
*/
class Answer extends Entity {
protected $submissionId;
protected $questionId;
+ protected $fileId;
protected $text;
/**
@@ -49,12 +52,14 @@ class Answer extends Entity {
public function __construct() {
$this->addType('submissionId', 'integer');
$this->addType('questionId', 'integer');
+ $this->addType('fileId', 'integer');
}
public function read(): array {
return [
'id' => $this->getId(),
'submissionId' => $this->getSubmissionId(),
+ 'fileId' => $this->getFileId(),
'questionId' => $this->getQuestionId(),
'text' => (string)$this->getText(),
];
diff --git a/lib/Db/Question.php b/lib/Db/Question.php
index e4f97e1777..02fa517b5c 100644
--- a/lib/Db/Question.php
+++ b/lib/Db/Question.php
@@ -48,8 +48,6 @@
* @method void setDescription(string $value)
* @method string getName()
* @method void setName(string $value)
- * @method object getExtraSettings()
- * @method void setExtraSettings(object $value)
*/
class Question extends Entity {
protected $formId;
@@ -82,7 +80,7 @@ public function setExtraSettings(array $extraSettings) {
unset($extraSettings[$key]);
}
}
-
+
$this->setExtraSettingsJson(json_encode($extraSettings, JSON_FORCE_OBJECT));
}
diff --git a/lib/Db/UploadedFile.php b/lib/Db/UploadedFile.php
new file mode 100644
index 0000000000..d2b00f5f39
--- /dev/null
+++ b/lib/Db/UploadedFile.php
@@ -0,0 +1,69 @@
+
+ *
+ * @author Kostiantyn Miakshyn
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+namespace OCA\Forms\Db;
+
+use OCP\AppFramework\Db\Entity;
+
+/**
+ * @method string getId()
+ * @method void setId(string $value)
+ * @method integer getFormId()
+ * @method void setFormId(integer $value)
+ * @method string getOriginalFileName()
+ * @method void setOriginalFileName(string $value)
+ * @method integer getFileId()
+ * @method void setFileId(integer $value)
+ * @method integer getCreated()
+ * @method void setCreated(integer $value)
+ */
+class UploadedFile extends Entity {
+ protected $formId;
+ protected $originalFileName;
+ protected $fileId;
+ protected $created;
+
+ /**
+ * Answer constructor.
+ */
+ public function __construct() {
+ $this->addType('id', 'string');
+ $this->addType('formId', 'integer');
+ $this->addType('originalFileName', 'string');
+ $this->addType('fileId', 'integer');
+ $this->addType('created', 'integer');
+ }
+
+ public function read(): array {
+ return [
+ 'id' => $this->getId(),
+ 'formId' => $this->getFormId(),
+ 'originalFileName' => $this->getOriginalFileName(),
+ 'fileId' => $this->getFileId(),
+ 'created' => $this->getCreated(),
+ ];
+ }
+}
diff --git a/lib/Db/UploadedFileMapper.php b/lib/Db/UploadedFileMapper.php
new file mode 100644
index 0000000000..d781e2b632
--- /dev/null
+++ b/lib/Db/UploadedFileMapper.php
@@ -0,0 +1,74 @@
+
+ *
+ * @author Kostiantyn Miakshyn
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+namespace OCA\Forms\Db;
+
+use OCP\AppFramework\Db\QBMapper;
+use OCP\IDBConnection;
+
+/**
+ * @extends QBMapper
+ */
+class UploadedFileMapper extends QBMapper {
+
+ /**
+ * AnswerMapper constructor.
+ * @param IDBConnection $db
+ */
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'forms_v2_uploaded_files', UploadedFile::class);
+ }
+
+ /**
+ * @param string $uploadedFileId
+ * @throws \OCP\AppFramework\Db\DoesNotExistException if not found
+ * @return UploadedFile
+ */
+ public function findByUploadedFileId(string $uploadedFileId): UploadedFile {
+ $qb = $this->db->getQueryBuilder();
+
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where(
+ $qb->expr()->eq('id', $qb->createNamedParameter($uploadedFileId))
+ );
+
+ return $this->findEntities($qb)[0];
+ }
+
+ /**
+ * @param \DateTimeImmutable $dateTime
+ * @return UploadedFile[]
+ */
+ public function findUploadedEarlierThan(\DateTimeImmutable $dateTime): array {
+ $qb = $this->db->getQueryBuilder();
+
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where(
+ $qb->expr()->lt('created', $qb->createNamedParameter($dateTime->getTimestamp()))
+ );
+
+ return $this->findEntities($qb);
+ }
+}
diff --git a/lib/Migration/Version040010Date20240401123456.php b/lib/Migration/Version040010Date20240401123456.php
new file mode 100644
index 0000000000..de1d331160
--- /dev/null
+++ b/lib/Migration/Version040010Date20240401123456.php
@@ -0,0 +1,80 @@
+
+ *
+ * @author Kostiantyn Miakshyn
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+namespace OCA\Forms\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+class Version040010Date20240401123456 extends SimpleMigrationStep {
+ /**
+ * @param IOutput $output
+ * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
+ * @param array $options
+ * @return null|ISchemaWrapper
+ */
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
+ /** @var ISchemaWrapper $schema */
+ $schema = $schemaClosure();
+
+ $answersTable = $schema->getTable('forms_v2_answers');
+ $answersTable->addColumn('file_id', Types::BIGINT, [
+ 'notnull' => false,
+ 'default' => null,
+ 'length' => 11,
+ 'unsigned' => true,
+ ]);
+
+ $table = $schema->createTable('forms_v2_uploaded_files');
+ $table->addColumn('id', Types::STRING, [
+ 'length' => 36,
+ 'notnull' => true,
+ ]);
+ $table->addColumn('form_id', Types::INTEGER, [
+ 'notnull' => true,
+ ]);
+ $table->addColumn('original_file_name', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 256,
+ ]);
+ $table->addColumn('file_id', Types::BIGINT, [
+ 'notnull' => false,
+ 'default' => null,
+ 'length' => 11,
+ 'unsigned' => true,
+ ]);
+ $table->addColumn('created', Types::INTEGER, [
+ 'notnull' => false,
+ 'comment' => 'unix-timestamp',
+ ]);
+ $table->setPrimaryKey(['id'], 'id');
+
+ return $schema;
+ }
+}
diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php
index 4f65be536c..3bfd464848 100644
--- a/lib/Service/FormsService.php
+++ b/lib/Service/FormsService.php
@@ -29,6 +29,7 @@
use OCA\Forms\Db\Form;
use OCA\Forms\Db\FormMapper;
use OCA\Forms\Db\OptionMapper;
+use OCA\Forms\Db\Question;
use OCA\Forms\Db\QuestionMapper;
use OCA\Forms\Db\Share;
use OCA\Forms\Db\ShareMapper;
@@ -596,6 +597,9 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType
case Constants::ANSWER_TYPE_SHORT:
$allowed = Constants::EXTRA_SETTINGS_SHORT;
break;
+ case Constants::ANSWER_TYPE_FILE:
+ $allowed = Constants::EXTRA_SETTINGS_FILE;
+ break;
default:
$allowed = [];
}
@@ -696,8 +700,29 @@ public function getFileName(Form $form, string $fileFormat): string {
// TRANSLATORS Appendix for CSV-Export: 'Form Title (responses).csv'
$fileName = $form->getTitle() . ' (' . $this->l10n->t('responses') . ').'.$fileFormat;
- // Sanitize file name, replace all invalid characters
- return str_replace(mb_str_split(\OCP\Constants::FILENAME_INVALID_CHARS), '-', $fileName);
+ return self::normalizeFileName($fileName);
+ }
+
+ public function getUploadedFilePath(Form $form, int $submissionId, int $questionId, string $questionName, string $questionText): string {
+
+ return implode('/', [
+ Constants::FILES_FOLDER,
+ self::normalizeFileName($form->getId().'. '.$form->getTitle()),
+ $submissionId,
+ self::normalizeFileName($questionId.'. '.($questionName ?: $questionText))
+ ]);
}
+ public function getTemporaryUploadedFilePath(Form $form, Question $question): string {
+ return implode('/', [
+ Constants::UNSUBMITTED_FILES_FOLDER,
+ microtime(true),
+ self::normalizeFileName($form->getId().'. '.$form->getTitle()),
+ self::normalizeFileName($question->getId().'. '.($question->getName() ?: $question->getText()))
+ ]);
+ }
+
+ private static function normalizeFileName(string $fileName): string {
+ return str_replace(mb_str_split(\OCP\Constants::FILENAME_INVALID_CHARS), '-', $fileName);
+ }
}
diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php
index d4c942b99d..83251b4f57 100644
--- a/lib/Service/SubmissionService.php
+++ b/lib/Service/SubmissionService.php
@@ -33,6 +33,7 @@
use OCA\Forms\Db\AnswerMapper;
use OCA\Forms\Db\Form;
use OCA\Forms\Db\FormMapper;
+use OCA\Forms\Db\Question;
use OCA\Forms\Db\QuestionMapper;
use OCA\Forms\Db\Submission;
use OCA\Forms\Db\SubmissionMapper;
@@ -45,6 +46,7 @@
use OCP\IL10N;
use OCP\ITempManager;
+use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
@@ -103,6 +105,7 @@ public function __construct(FormMapper $formMapper,
IMailer $mailer,
private ITempManager $tempManager,
private FormsService $formsService,
+ private IUrlGenerator $urlGenerator,
) {
$this->formMapper = $formMapper;
$this->questionMapper = $questionMapper;
@@ -266,8 +269,11 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
$header[] = $this->l10n->t('User ID');
$header[] = $this->l10n->t('User display name');
$header[] = $this->l10n->t('Timestamp');
+ /** @var array $questionPerQuestionId */
+ $questionPerQuestionId = [];
foreach ($questions as $question) {
$header[] = $question->getText();
+ $questionPerQuestionId[$question->getId()] = $question;
}
// Init dataset
@@ -293,18 +299,30 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
$row[] = date_format(date_timestamp_set(new DateTime(), $submission->getTimestamp())->setTimezone(new DateTimeZone($userTimezone)), 'c');
// Answers, make sure we keep the question order
- $answers = array_reduce($this->answerMapper->findBySubmission($submission->getId()), function (array $carry, Answer $answer) {
- $questionId = $answer->getQuestionId();
-
- // If key exists, insert separator
- if (array_key_exists($questionId, $carry)) {
- $carry[$questionId] .= '; ' . $answer->getText();
- } else {
- $carry[$questionId] = $answer->getText();
- }
+ $answers = array_reduce($this->answerMapper->findBySubmission($submission->getId()),
+ function (array $carry, Answer $answer) use ($questionPerQuestionId) {
+ $questionId = $answer->getQuestionId();
+
+ if (isset($questionPerQuestionId[$questionId]) &&
+ $questionPerQuestionId[$questionId]->getType() === Constants::ANSWER_TYPE_FILE) {
+ if (array_key_exists($questionId, $carry)) {
+ $carry[$questionId]['label'] .= "; \n" . $answer->getText();
+ } else {
+ $carry[$questionId] = [
+ 'label' => $answer->getText(),
+ 'url' => $this->urlGenerator->linkToRouteAbsolute('files.View.showFile', ['fileid' => $answer->getFileId()])
+ ];
+ }
+ } else {
+ if (array_key_exists($questionId, $carry)) {
+ $carry[$questionId] .= '; ' . $answer->getText();
+ } else {
+ $carry[$questionId] = $answer->getText();
+ }
+ }
- return $carry;
- }, []);
+ return $carry;
+ }, []);
foreach ($questions as $question) {
$row[] = $answers[$question->getId()] ?? null;
@@ -318,7 +336,7 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
/**
* @param array $header
- * @param array> $data
+ * @param array|non-empty-list> $data
*/
private function exportData(array $header, array $data, string $fileFormat, ?File $file = null): string {
if ($file && $file->getContent()) {
@@ -333,9 +351,23 @@ private function exportData(array $header, array $data, string $fileFormat, ?Fil
foreach ($header as $columnIndex => $value) {
$activeWorksheet->setCellValue([$columnIndex + 1, 1], $value);
}
- foreach ($data as $rowIndex => $row) {
- foreach ($row as $columnIndex => $value) {
- $activeWorksheet->setCellValue([$columnIndex + 1, $rowIndex + 2], $value);
+ foreach ($data as $rowIndex => $rowData) {
+ foreach ($rowData as $columnIndex => $value) {
+ $column = $columnIndex + 1;
+ $row = $rowIndex + 2;
+
+ if (is_array($value)) {
+ $activeWorksheet->getCell([$column, $row])
+ ->setValueExplicit($value['label'])
+ ->getHyperlink()
+ ->setUrl($value['url']);
+
+ $activeWorksheet->getStyle([$column, $row])
+ ->getAlignment()
+ ->setWrapText(true);
+ } else {
+ $activeWorksheet->setCellValue([$column, $row], $value);
+ }
}
}
@@ -353,9 +385,10 @@ private function exportData(array $header, array $data, string $fileFormat, ?Fil
* Validate all answers against the questions
* @param array $questions Array of the questions of the form
* @param array $answers Array of the submitted answers
- * @return boolean If the submission is valid
+ * @param string $formOwnerId Owner of the form
+ * @return boolean|string True for valid submission, false or error message for invalid
*/
- public function validateSubmission(array $questions, array $answers): bool {
+ public function validateSubmission(array $questions, array $answers, string $formOwnerId): bool|string {
// Check by questions
foreach ($questions as $question) {
$questionId = $question['id'];
@@ -364,13 +397,20 @@ public function validateSubmission(array $questions, array $answers): bool {
// Check if all required questions have an answer
if ($question['isRequired'] &&
(!$questionAnswered ||
- !array_filter($answers[$questionId], 'strlen') ||
+ !array_filter($answers[$questionId], function (string|array $value): bool {
+ // file type
+ if (is_array($value)) {
+ return !empty($value['uploadedFileId']);
+ }
+
+ return $value !== '';
+ }) ||
(!empty($question['extraSettings']['allowOtherAnswer']) && !array_filter($answers[$questionId], fn ($value) => $value !== Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX)))
) {
return false;
}
- // Perform further checks only for answered questions - otherwise early return
+ // Perform further checks only for answered questions
if (!$questionAnswered) {
continue;
}
@@ -385,8 +425,8 @@ public function validateSubmission(array $questions, array $answers): bool {
|| ($maxOptions > 0 && $answersCount > $maxOptions)) {
return false;
}
- } elseif ($answersCount > 1) {
- // Check if non multiple questions have not more than one answer
+ } elseif ($answersCount > 1 && $question['type'] !== Constants::ANSWER_TYPE_FILE) {
+ // Check if non-multiple questions have not more than one answer
return false;
}
@@ -413,6 +453,21 @@ public function validateSubmission(array $questions, array $answers): bool {
if ($question['type'] === Constants::ANSWER_TYPE_SHORT && !$this->validateShortQuestion($question, $answers[$questionId][0])) {
return false;
}
+
+ if ($question['type'] === Constants::ANSWER_TYPE_FILE) {
+ $maxAllowedFilesCount = $question['extraSettings']['maxAllowedFilesCount'] ?? 0;
+ if ($maxAllowedFilesCount > 0 && count($answers[$questionId]) > $maxAllowedFilesCount) {
+ return $this->l10n->t('Too many files uploaded. Maximum allowed: %s', [$maxAllowedFilesCount]);
+ }
+
+ foreach ($answers[$questionId] as $answer) {
+ $nodes = $this->storage->getUserFolder($formOwnerId)->getById($answer['uploadedFileId']);
+
+ if (empty($nodes)) {
+ return $this->l10n->t('File "%s" for question "%s" not exists anymore. Please delete and re-upload the file.', [$answer['fileName'], $question['text']]);
+ }
+ }
+ }
}
// Check for excess answers
diff --git a/src/components/Questions/QuestionFile.vue b/src/components/Questions/QuestionFile.vue
new file mode 100644
index 0000000000..47ffa05902
--- /dev/null
+++ b/src/components/Questions/QuestionFile.vue
@@ -0,0 +1,303 @@
+
+
+
+
+
+
+
+
+
+
+ {{ allowedFileTypesLabel }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('forms', 'Allow only specific file types') }}
+
+
+
+ {{ fileTypeLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+ {{ uploadedFile.fileName }}
+
+
+
+
+
+ {{ t('forms', 'Delete') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Results/Answer.vue b/src/components/Results/Answer.vue
index af9436265e..a3020a116f 100644
--- a/src/components/Results/Answer.vue
+++ b/src/components/Results/Answer.vue
@@ -27,18 +27,39 @@
- {{ answerText }}
+
+
+ {{ answer.text }}
+
+
+
+ {{ answerText }}
+