diff --git a/appinfo/info.xml b/appinfo/info.xml
index c2f293b713..467c947c98 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -55,6 +55,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/docs/API.md b/docs/API.md
index 86f72b4306..fd095861e4 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
@@ -176,18 +178,20 @@ Returns the full-depth object of the requested form (without submissions).
"text": "Option 2"
}
],
+ "accept": [],
"extraSettings": {}
},
{
"id": 2,
"formId": 3,
"order": 2,
- "type": "short",
+ "type": "file",
"isRequired": true,
"text": "Question 2",
"name": "something_other",
"options": [],
"extraSettings": {}
+ "accept": ["image/*", ".pdf"],
}
],
"shares": [
@@ -629,6 +633,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": integer, "fileName": "string"}
+```
+
### Insert a Submission
Store Submission to Database
- Endpoint: `/api/v2.4/submission/insert`
@@ -644,10 +663,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": integer},
+ {"uploadedFileId": integer}
+ ],
}
```
- 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..7bcb29712c
--- /dev/null
+++ b/lib/BackgroundJob/CleanupUploadedFilesJob.php
@@ -0,0 +1,112 @@
+
+ *
+ * @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\BackgroundJob;
+
+use OCA\Forms\Constants;
+use OCA\Forms\Db\FormMapper;
+use OCA\Forms\Db\UploadedFileMapper;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+use OCP\Files\Folder;
+use OCP\Files\IRootFolder;
+use Psr\Log\LoggerInterface;
+
+class CleanupUploadedFilesJob extends TimedJob {
+ private const FILE_LIFETIME = '-1 hour';
+
+ public function __construct(
+ private IRootFolder $storage,
+ private FormMapper $formMapper,
+ private UploadedFileMapper $uploadedFileMapper,
+ private LoggerInterface $logger,
+ ITimeFactory $time) {
+ parent::__construct($time);
+
+ $this->setInterval(60 * 60);
+ }
+
+ /**
+ * @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..4d90fe1d6a 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' => ['array'],
+ 'allowedFileExtensions' => ['array'],
+ 'maxAllowedFilesCount' => ['integer'],
+ 'maxFileSize' => ['integer'],
+ ];
+
+ // 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 = self::FILES_FOLDER . '/unsubmitted';
+
+ public const FILES_FOLDER = 'forms';
}
diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php
index ad0a9bcc8d..b2efe1d8db 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();
@@ -438,6 +446,7 @@ public function newQuestion(int $formId, string $type, string $text = ''): DataR
$response = $question->read();
$response['options'] = [];
+ $response['accept'] = [];
$this->formsService->setLastUpdatedTimestamp($formId);
@@ -696,6 +705,7 @@ public function cloneQuestion(int $id): DataResponse {
$response = $newQuestion->read();
$response['options'] = [];
+ $response['accept'] = [];
foreach ($sourceOptions as $sourceOption) {
$optionData = $sourceOption->read();
@@ -918,14 +928,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 +950,25 @@ 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->getByUploadedFileId($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);
+ }
+ /** @var \OCP\Files\Folder $folder */
+
+ $file = $userFolder->getById($uploadedFile->getFileId())[0];
+ $name = $folder->getNonExistingName($file->getName());
+ $file->move($folder->getPath() . '/' . $name);
+
+ $answerText = $name;
} else {
$answerText = $answer; // Not a multiple-question, answerText is given answer
}
@@ -943,14 +977,132 @@ 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('No files 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(sprintf('Failed to upload the file "%s".', $uploadedFile['name']));
+ }
+
+ if (!is_uploaded_file($uploadedFile['tmp_name'])) {
+ throw new OCSBadRequestException('Invalid file provided');
+ }
+
+ $userFolder = $this->storage->getUserFolder($form->getOwnerId());
+ $userFolder->getStorage()->verifyPath($path, $uploadedFile['name']);
+
+ $extraSettings = $question->getExtraSettings();
+ if (($extraSettings['maxFileSize'] ?? 0) > 0 && $uploadedFile['size'] > $extraSettings['maxFileSize']) {
+ throw new OCSBadRequestException(sprintf('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']);
+ $aliases = $this->mimeTypeDetector->getAllAliases();
+
+ $valid = false;
+ foreach ($extraSettings['allowedFileTypes'] ?? [] as $allowedFileType) {
+ if (str_starts_with($aliases[$mimeType] ?? '', $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(sprintf('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']));
+
+ $uploadedFileEntity = new UploadedFile();
+ $uploadedFileEntity->setFormId($formId);
+ $uploadedFileEntity->setOriginalFileName($fileName);
+ $uploadedFileEntity->setFileId($file->getId());
+ $uploadedFileEntity->setCreated(time());
+ $this->uploadedFileMapper->insert($uploadedFileEntity);
+
+ $response[] = [
+ 'uploadedFileId' => $uploadedFileEntity->getId(),
+ 'fileName' => $fileName,
+ ];
+ }
+
+ return new DataResponse($response);
+ }
+
/**
* @CORS
* @NoAdminRequired
@@ -973,47 +1125,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 +1174,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 +1421,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/Controller/ShareApiController.php b/lib/Controller/ShareApiController.php
index 3abf38043a..07bc5bcdec 100644
--- a/lib/Controller/ShareApiController.php
+++ b/lib/Controller/ShareApiController.php
@@ -41,6 +41,7 @@
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCS\OCSForbiddenException;
use OCP\AppFramework\OCSController;
+use OCP\Files\IRootFolder;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IRequest;
@@ -48,6 +49,7 @@
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\Security\ISecureRandom;
+use OCP\Share\IManager;
use OCP\Share\IShare;
use Psr\Log\LoggerInterface;
@@ -67,6 +69,8 @@ public function __construct(
private IUserManager $userManager,
private ISecureRandom $secureRandom,
private CirclesService $circlesService,
+ private IRootFolder $storage,
+ private IManager $shareManager,
) {
parent::__construct($appName, $request);
$this->currentUser = $userSession->getUser();
@@ -189,7 +193,7 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar
// Create share-notifications (activity)
$this->formsService->notifyNewShares($form, $share);
-
+
$this->formsService->setLastUpdatedTimestamp($formId);
// Append displayName for Frontend
@@ -254,8 +258,8 @@ public function updateShare(int $id, array $keyValuePairs): DataResponse {
]);
try {
- $share = $this->shareMapper->findById($id);
- $form = $this->formMapper->findById($share->getFormId());
+ $formShare = $this->shareMapper->findById($id);
+ $form = $this->formMapper->findById($formShare->getFormId());
} catch (IMapperException $e) {
$this->logger->debug('Could not find share', ['exception' => $e]);
throw new OCSBadRequestException('Could not find share');
@@ -278,16 +282,46 @@ public function updateShare(int $id, array $keyValuePairs): DataResponse {
throw new OCSForbiddenException();
}
- if (!$this->validatePermissions($keyValuePairs['permissions'], $share->getShareType())) {
+ if (!$this->validatePermissions($keyValuePairs['permissions'], $formShare->getShareType())) {
throw new OCSBadRequestException('Invalid permission given');
}
- $share->setPermissions($keyValuePairs['permissions']);
- $share = $this->shareMapper->update($share);
+ $formShare->setPermissions($keyValuePairs['permissions']);
+ $formShare = $this->shareMapper->update($formShare);
+
+ if (in_array($formShare->getShareType(), [IShare::TYPE_USER, IShare::TYPE_GROUP, IShare::TYPE_USERGROUP, IShare::TYPE_CIRCLE], true)) {
+ $userFolder = $this->storage->getUserFolder($form->getOwnerId());
+ $uploadedFilesFolderPath = $this->formsService->getFormUploadedFilesFolderPath($form);
+ if ($userFolder->nodeExists($uploadedFilesFolderPath)) {
+ $folder = $userFolder->get($uploadedFilesFolderPath);
+ } else {
+ $folder = $userFolder->newFolder($uploadedFilesFolderPath);
+ }
+ /** @var \OCP\Files\Folder $folder */
+
+ if (in_array(Constants::PERMISSION_RESULTS, $keyValuePairs['permissions'], true)) {
+ $folderShare = $this->shareManager->newShare();
+ $folderShare->setShareType($formShare->getShareType());
+ $folderShare->setSharedWith($formShare->getShareWith());
+ $folderShare->setSharedBy($form->getOwnerId());
+ $folderShare->setPermissions(\OCP\Constants::PERMISSION_READ);
+ $folderShare->setNode($folder);
+ $folderShare->setShareOwner($form->getOwnerId());
+
+ $this->shareManager->createShare($folderShare);
+ } else {
+ $folderShares = $this->shareManager->getSharesBy($form->getOwnerId(), $formShare->getShareType(), $folder);
+ foreach ($folderShares as $folderShare) {
+ if ($folderShare->getSharedWith() === $formShare->getShareWith()) {
+ $this->shareManager->deleteShare($folderShare);
+ }
+ }
+ }
+ }
$this->formsService->setLastUpdatedTimestamp($form->getId());
- return new DataResponse($share->getId());
+ return new DataResponse($formShare->getId());
}
/**
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..8e037db1d0
--- /dev/null
+++ b/lib/Db/UploadedFile.php
@@ -0,0 +1,66 @@
+
+ *
+ * @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 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('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..ca3eeba12a
--- /dev/null
+++ b/lib/Db/UploadedFileMapper.php
@@ -0,0 +1,88 @@
+
+ *
+ * @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\DoesNotExistException;
+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
+ * @return UploadedFile|null
+ */
+ 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] ?? null;
+ }
+
+ /**
+ * @param string $uploadedFileId
+ * @throws DoesNotExistException if not found
+ * @return UploadedFile
+ */
+ public function getByUploadedFileId(string $uploadedFileId): UploadedFile {
+ $uploadedFile = $this->findByUploadedFileId($uploadedFileId);
+ if ($uploadedFile === null) {
+ throw new DoesNotExistException(sprintf('Uploaded file with id "%s" not found', $uploadedFileId));
+ }
+
+ return $uploadedFile;
+ }
+
+ /**
+ * @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/Version040300Date20240523123456.php b/lib/Migration/Version040300Date20240523123456.php
new file mode 100644
index 0000000000..0477db64a7
--- /dev/null
+++ b/lib/Migration/Version040300Date20240523123456.php
@@ -0,0 +1,84 @@
+
+ *
+ * @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 Version040300Date20240523123456 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');
+ if (!$answersTable->hasColumn('file_id')) {
+ $answersTable->addColumn('file_id', Types::BIGINT, [
+ 'notnull' => false,
+ 'default' => null,
+ 'length' => 11,
+ 'unsigned' => true,
+ ]);
+ }
+
+ if (!$schema->hasTable('forms_v2_uploaded_files')) {
+ $table = $schema->createTable('forms_v2_uploaded_files');
+ $table->addColumn('id', Types::INTEGER, [
+ 'autoincrement' => true,
+ '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..4679a50b73 100644
--- a/lib/Service/FormsService.php
+++ b/lib/Service/FormsService.php
@@ -29,12 +29,14 @@
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;
use OCA\Forms\Db\SubmissionMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\IMapperException;
+use OCP\Files\IMimeTypeDetector;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IGroup;
@@ -69,6 +71,7 @@ public function __construct(
private CirclesService $circlesService,
private IRootFolder $storage,
private IL10N $l10n,
+ private IMimeTypeDetector $mimeTypeDetector,
) {
$this->currentUser = $userSession->getUser();
}
@@ -116,6 +119,25 @@ public function getQuestions(int $formId): array {
foreach ($questionEntities as $questionEntity) {
$question = $questionEntity->read();
$question['options'] = $this->getOptions($question['id']);
+ $question['accept'] = [];
+ if ($question['type'] === Constants::ANSWER_TYPE_FILE) {
+ if ($question['extraSettings']['allowedFileTypes'] ?? null) {
+ $aliases = $this->mimeTypeDetector->getAllAliases();
+
+ foreach ($question['extraSettings']['allowedFileTypes'] as $type) {
+ $question['accept'] = array_keys(array_filter($aliases, function (string $alias) use ($type) {
+ return $alias === $type;
+ }));
+ }
+ }
+
+ if ($question['extraSettings']['allowedFileExtensions'] ?? null) {
+ foreach ($question['extraSettings']['allowedFileExtensions'] as $extension) {
+ $question['accept'][] = '.' . $extension;
+ }
+ }
+ }
+
$questionList[] = $question;
}
} catch (DoesNotExistException $e) {
@@ -596,6 +618,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 +721,35 @@ 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 getFormUploadedFilesFolderPath(Form $form): string {
+ return implode('/', [
+ Constants::FILES_FOLDER,
+ self::normalizeFileName($form->getId().' - '.$form->getTitle()),
+ ]);
+ }
+
+ public function getUploadedFilePath(Form $form, int $submissionId, int $questionId, ?string $questionName, string $questionText): string {
+
+ return implode('/', [
+ $this->getFormUploadedFilesFolderPath($form),
+ $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..308c854384 100644
--- a/lib/Service/SubmissionService.php
+++ b/lib/Service/SubmissionService.php
@@ -33,9 +33,11 @@
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;
+use OCA\Forms\Db\UploadedFileMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\OCS\OCSException;
use OCP\Files\File;
@@ -45,6 +47,7 @@
use OCP\IL10N;
use OCP\ITempManager;
+use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
@@ -103,6 +106,8 @@ public function __construct(FormMapper $formMapper,
IMailer $mailer,
private ITempManager $tempManager,
private FormsService $formsService,
+ private IUrlGenerator $urlGenerator,
+ private UploadedFileMapper $uploadedFileMapper,
) {
$this->formMapper = $formMapper;
$this->questionMapper = $questionMapper;
@@ -266,8 +271,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 +301,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 +338,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 +353,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 +387,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 +399,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 +427,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 +455,25 @@ 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 sprintf('Too many files uploaded for question "%s". Maximum allowed: %d', $question['text'], $maxAllowedFilesCount);
+ }
+
+ foreach ($answers[$questionId] as $answer) {
+ $uploadedFile = $this->uploadedFileMapper->findByUploadedFileId($answer['uploadedFileId']);
+ if (!$uploadedFile) {
+ return sprintf('File "%s" for question "%s" not exists anymore. Please delete and re-upload the file.', $answer['fileName'] ?? $answer['uploadedFileId'], $question['text']);
+ }
+
+ $nodes = $this->storage->getUserFolder($formOwnerId)->getById($uploadedFile->getFileId());
+ if (empty($nodes)) {
+ return sprintf('File "%s" for question "%s" not exists anymore. Please delete and re-upload the file.', $answer['fileName'] ?? $answer['uploadedFileId'], $question['text']);
+ }
+ }
+ }
}
// Check for excess answers
diff --git a/package-lock.json b/package-lock.json
index 65ba3e12df..a23b01be52 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"@nextcloud/axios": "^2.5.0",
"@nextcloud/dialogs": "^5.3.1",
"@nextcloud/event-bus": "^3.3.1",
+ "@nextcloud/files": "^3.2.1",
"@nextcloud/initial-state": "^2.2.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/logger": "^3.0.2",
@@ -1773,64 +1774,22 @@
}
},
"node_modules/@nextcloud/files": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.2.0.tgz",
- "integrity": "sha512-3EQBR758bzvqcNRzcp1etHGGkCZgK6wS9or8iQpzIOKf4B2tAe1O+hXA8GzPiQ5ZlGIPblOlMOEMlRg1KD49hg==",
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.4.0.tgz",
+ "integrity": "sha512-VfVI9bQVcORKpVA8WJDf93swo3StzuOdX7YDwmpkZqJcaAirsZr/B9tlMLYGAfgGPqOZfifyMGGWnKnx5HABug==",
"dependencies": {
- "@nextcloud/auth": "^2.2.1",
- "@nextcloud/l10n": "^2.2.0",
- "@nextcloud/logger": "^2.7.0",
+ "@nextcloud/auth": "^2.3.0",
+ "@nextcloud/l10n": "^3.1.0",
+ "@nextcloud/logger": "^3.0.2",
"@nextcloud/paths": "^2.1.0",
- "@nextcloud/router": "^3.0.0",
+ "@nextcloud/router": "^3.0.1",
"cancelable-promise": "^4.3.1",
- "is-svg": "^5.0.0",
- "webdav": "^5.5.0"
- },
- "engines": {
- "node": "^20.0.0",
- "npm": "^9.0.0"
- }
- },
- "node_modules/@nextcloud/files/node_modules/@nextcloud/l10n": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-2.2.0.tgz",
- "integrity": "sha512-UAM2NJcl/NR46MANSF7Gr7q8/Up672zRyGrxLpN3k4URNmWQM9upkbRME+1K3T29wPrUyOIbQu710ZjvZafqFA==",
- "dependencies": {
- "@nextcloud/router": "^2.1.2",
- "@nextcloud/typings": "^1.7.0",
- "dompurify": "^3.0.3",
- "escape-html": "^1.0.3",
- "node-gettext": "^3.0.0"
- },
- "engines": {
- "node": "^20.0.0",
- "npm": "^9.0.0"
- }
- },
- "node_modules/@nextcloud/files/node_modules/@nextcloud/l10n/node_modules/@nextcloud/router": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-2.2.0.tgz",
- "integrity": "sha512-M4AVGnB5tt3MYO5RpH/R2jq7z/nW05AmRhk4Lh68krVwRIYGo8pgNikKrPGogHd2Q3UgzF5Py1drHz3uuV99bQ==",
- "dependencies": {
- "@nextcloud/typings": "^1.7.0",
- "core-js": "^3.6.4"
+ "is-svg": "^5.0.1",
+ "webdav": "^5.6.0"
},
"engines": {
"node": "^20.0.0",
- "npm": "^9.0.0"
- }
- },
- "node_modules/@nextcloud/files/node_modules/@nextcloud/logger": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/@nextcloud/logger/-/logger-2.7.0.tgz",
- "integrity": "sha512-DSJg9H1jT2zfr7uoP4tL5hKncyY+LOuxJzLauj0M/f6gnpoXU5WG1Zw8EFPOrRWjkC0ZE+NCqrMnITgdRRpXJQ==",
- "dependencies": {
- "@nextcloud/auth": "^2.0.0",
- "core-js": "^3.6.4"
- },
- "engines": {
- "node": "^20.0.0",
- "npm": "^9.0.0"
+ "npm": "^10.0.0"
}
},
"node_modules/@nextcloud/initial-state": {
@@ -4862,7 +4821,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
- "optional": true,
"engines": {
"node": ">= 12"
}
@@ -6922,7 +6880,6 @@
"url": "https://paypal.me/jimmywarting"
}
],
- "optional": true,
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
@@ -7208,7 +7165,6 @@
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
- "optional": true,
"dependencies": {
"fetch-blob": "^3.1.2"
},
@@ -7776,6 +7732,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
"bin": {
"he": "bin/he"
}
@@ -8421,9 +8378,9 @@
}
},
"node_modules/is-svg": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-5.0.0.tgz",
- "integrity": "sha512-sRl7J0oX9yUNamSdc8cwgzh9KBLnQXNzGmW0RVHwg/jEYjGNYHC6UvnYD8+hAeut9WwxRvhG9biK7g/wDGxcMw==",
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-5.0.1.tgz",
+ "integrity": "sha512-mLYxDsfisQWdS4+gSblAwhATDoNMS/tx8G7BKA+aBIf7F0m1iUwMvuKAo6mW4WMleQAEE50I1Zqef9yMMfHk3w==",
"dependencies": {
"fast-xml-parser": "^4.1.3"
},
@@ -10153,7 +10110,6 @@
"url": "https://paypal.me/jimmywarting"
}
],
- "optional": true,
"engines": {
"node": ">=10.5.0"
}
@@ -10162,7 +10118,6 @@
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
- "optional": true,
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
@@ -14887,32 +14842,32 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
- "optional": true,
"engines": {
"node": ">= 8"
}
},
"node_modules/webdav": {
- "version": "5.5.0",
- "resolved": "https://registry.npmjs.org/webdav/-/webdav-5.5.0.tgz",
- "integrity": "sha512-SHSDe6n8lBuwwyX+uePB1N1Yn35ebd3locl/LbADMWpcEoowyFdIbnH3fv17T4Jf2tOa1Vwjr/Lld3t0dOio1w==",
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/webdav/-/webdav-5.6.0.tgz",
+ "integrity": "sha512-1zpC9T+nZAEz3hHrEdis3gybiwoR5LillHmFiylhYWAsGU0bGlWlRZtK5NJ3bTr2wCoKABrRGGqLk24+UxF4Gg==",
"dependencies": {
"@buttercup/fetch": "^0.2.1",
"base-64": "^1.0.0",
"byte-length": "^1.0.2",
+ "entities": "^4.5.0",
"fast-xml-parser": "^4.2.4",
- "he": "^1.2.0",
"hot-patcher": "^2.0.0",
"layerr": "^2.0.1",
"md5": "^2.3.0",
"minimatch": "^7.4.6",
"nested-property": "^4.0.0",
+ "node-fetch": "^3.3.2",
"path-posix": "^1.0.0",
"url-join": "^5.0.0",
"url-parse": "^1.5.10"
},
"engines": {
- "node": ">=14"
+ "node": ">=16"
}
},
"node_modules/webdav/node_modules/minimatch": {
diff --git a/package.json b/package.json
index 64b13be747..ed86e6a3c3 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
"@nextcloud/axios": "^2.5.0",
"@nextcloud/dialogs": "^5.3.1",
"@nextcloud/event-bus": "^3.3.1",
+ "@nextcloud/files": "^3.2.1",
"@nextcloud/initial-state": "^2.2.0",
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/logger": "^3.0.2",
diff --git a/src/components/Questions/QuestionFile.vue b/src/components/Questions/QuestionFile.vue
new file mode 100644
index 0000000000..7685744f4c
--- /dev/null
+++ b/src/components/Questions/QuestionFile.vue
@@ -0,0 +1,325 @@
+
+
+
+
+
+
+
+
+
+
+ {{ allowedFileTypesLabel }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('forms', 'Allow only specific file types') }}
+
+
+
+ {{ fileTypeLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 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 }}
+