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 @@ + + + + + + + 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 }}

+ +

+ {{ answerText }} +