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