From d45d84cf617612cb05f6db4bb1246e21cccf8ee2 Mon Sep 17 00:00:00 2001 From: Konstantin Myakshin Date: Fri, 29 Mar 2024 01:37:21 +0200 Subject: [PATCH] Add support for file question Signed-off-by: Konstantin Myakshin --- appinfo/routes.php | 8 + composer.json | 3 +- composer.lock | 318 +++++++++++++++++- lib/Constants.php | 68 +++- lib/Controller/ApiController.php | 118 ++++++- lib/Db/Answer.php | 5 + lib/Db/UploadedFile.php | 69 ++++ lib/Db/UploadedFileMapper.php | 58 ++++ .../Version040010Date20240401123456.php | 80 +++++ lib/Service/FormsService.php | 19 +- lib/Service/SubmissionService.php | 5 +- src/components/Questions/QuestionFile.vue | 230 +++++++++++++ src/models/AnswerTypes.js | 18 +- 13 files changed, 986 insertions(+), 13 deletions(-) create mode 100644 lib/Db/UploadedFile.php create mode 100644 lib/Db/UploadedFileMapper.php create mode 100644 lib/Migration/Version040010Date20240401123456.php create mode 100644 src/components/Questions/QuestionFile.vue 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..3c871d4fab 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,8 @@ "phpunit/phpunit": "^9" }, "require": { - "phpoffice/phpspreadsheet": "^2.0" + "phpoffice/phpspreadsheet": "^2.0", + "symfony/mime": "^5.4" }, "extra": { "bamarni-bin": { diff --git a/composer.lock b/composer.lock index 81c84c0687..26616735a1 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": "75cfbea8f1fb3645bdb0a93a95d3c683", "packages": [ { "name": "maennchen/zipstream-php", @@ -570,6 +570,158 @@ }, "time": "2021-10-29T13:26:27+00:00" }, + { + "name": "symfony/deprecation-contracts", + "version": "v2.5.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "80d075412b557d41002320b96a096ca65aa2c98d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/80d075412b557d41002320b96a096ca65aa2c98d", + "reference": "80d075412b557d41002320b96a096ca65aa2c98d", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.3" + }, + "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": "2023-01-24T14:02:46+00:00" + }, + { + "name": "symfony/mime", + "version": "v5.4.38", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "82fa6be8a0295a3932df871e88fc8c8d77aa71d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/82fa6be8a0295a3932df871e88fc8c8d77aa71d4", + "reference": "82fa6be8a0295a3932df871e88fc8c8d77aa71d4", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php80": "^1.16" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/mailer": "<4.4", + "symfony/serializer": "<5.4.35|>=6,<6.3.12|>=6.4,<6.4.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^4.4|^5.0|^6.0", + "symfony/process": "^5.4|^6.4", + "symfony/property-access": "^4.4|^5.1|^6.0", + "symfony/property-info": "^4.4|^5.1|^6.0", + "symfony/serializer": "^5.4.35|~6.3.12|^6.4.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v5.4.38" + }, + "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-03-21T07:25:32+00:00" + }, { "name": "symfony/polyfill-iconv", "version": "v1.29.0", @@ -728,6 +880,90 @@ ], "time": "2024-01-29T20:11:03+00:00" }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.29.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "a287ed7475f85bf6f61890146edbc932c0fff919" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/a287ed7475f85bf6f61890146edbc932c0fff919", + "reference": "a287ed7475f85bf6f61890146edbc932c0fff919", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "symfony/polyfill-intl-normalizer": "^1.10", + "symfony/polyfill-php72": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/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": "symfony/polyfill-intl-normalizer", "version": "v1.29.0", @@ -962,6 +1198,86 @@ ], "time": "2024-01-29T20:11:03+00:00" }, + { + "name": "symfony/polyfill-php80", + "version": "v1.29.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", + "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/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/lib/Constants.php b/lib/Constants.php index 927743f82f..a54a6b917b 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -73,6 +73,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 = [ @@ -83,7 +84,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 @@ -135,6 +137,70 @@ class Constants { 'validationRegex' ]; + public const EXTRA_SETTINGS_FILE = [ + 'allowOnlySpecificFileTypes', + 'allowedFileTypes', + 'maxAllowedFilesCount', + 'maxFileSizeValue', + 'maxFileSizeUnit', + ]; + + // should be in sync with + public const EXTRA_SETTINGS_ALLOWED_FILE_TYPES = [ + 'document', + 'presentation', + 'spreadsheet', + 'pdf', + 'image', + 'video', + 'audio', + ]; + + public const EXTRA_SETTINGS_ALLOWED_FILE_TYPES_MIME = [ + 'document' => [ + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.oasis.opendocument.text', + 'application/pdf', + 'text/plain', + ], + 'presentation' => [ + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.presentation', + 'application/pdf', + ], + 'spreadsheet' => [ + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.oasis.opendocument.spreadsheet', + ], + 'pdf' => [ + 'application/pdf', + ], + 'image' => [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/bmp', + 'image/tiff', + 'image/webp', + //fixme: add Apple's HEIC/HEIF + ], + 'video' => [ + 'video/mp4', + 'video/webm', + 'video/ogg', + 'video/quicktime', + 'video/x-msvideo', + ], + 'audio' => [ + 'audio/mpeg', + 'audio/ogg', + 'audio/wav', + ], + ]; + /** * !! Keep in sync with src/mixins/ShareTypes.js !! */ diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 1a4e699f37..bc5ad0dbe9 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -40,18 +40,24 @@ 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; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\IMapperException; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\JSONResponse; +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\IRootFolder; use OCP\Files\NotFoundException; use OCP\IL10N; use OCP\IRequest; @@ -60,6 +66,7 @@ use OCP\IUserSession; use Psr\Log\LoggerInterface; +use Ramsey\Uuid\Uuid; class ApiController extends OCSController { /** @var IUser */ @@ -81,6 +88,8 @@ public function __construct( private IL10N $l10n, private LoggerInterface $logger, private IUserManager $userManager, + private IRootFolder $storage, + private UploadedFileMapper $uploadedFileMapper, ) { parent::__construct($appName, $request); $this->currentUser = $userSession->getUser(); @@ -1002,9 +1011,24 @@ public function getSubmissions(string $hash): DataResponse { * * @param int $submissionId * @param array $question - * @param array $answerArray [arrayOfString] + * @param string[]|array{uploadedFileId: string, uploadedFileName: string} $answerArray */ private function storeAnswersForQuestion($submissionId, array $question, array $answerArray) { + if ($question['type'] === Constants::ANSWER_TYPE_FILE) { + $answerEntity = new Answer(); + $answerEntity->setSubmissionId($submissionId); + $answerEntity->setQuestionId($question['id']); + $answerEntity->setText($answerArray['uploadedFileName']); + + $uploadedFile = $this->uploadedFileMapper->findByUploadedFileId($answerArray['uploadedFileId']); + $answerEntity->setFileId($uploadedFile->getFileId()); + + $this->answerMapper->insert($answerEntity); + $this->uploadedFileMapper->delete($uploadedFile); + + return; + } + foreach ($answerArray as $answer) { $answerText = ''; @@ -1033,6 +1057,95 @@ private function storeAnswersForQuestion($submissionId, array $question, array $ } } + + /** + * @CORS + * @PublicCORSFix + * @NoAdminRequired + * @PublicPage + * + * Uploads a temporary files to the server during form filling + * + * @return Response + */ + public function uploadFiles(int $formId, int $questionId): 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)) { + return new JSONResponse( + ['data' => ['message' => $this->l10n->t('No file provided')]], + Http::STATUS_BAD_REQUEST + ); + } + + $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']]); + + return new JSONResponse( + ['data' => ['message' => $this->l10n->t('Failed to upload the file "{file}".', + ['file' => $uploadedFile['name']])]], + Http::STATUS_BAD_REQUEST + ); + } + + $form = $this->formMapper->findById($formId); + $userFolder = $this->storage->getUserFolder($form->getOwnerId()); + $question = $this->questionMapper->findById($questionId); + $path = $this->formsService->getUploadedFilePath($form, $question); + + if (!is_uploaded_file($uploadedFile['tmp_name'])) { + return new JSONResponse( + ['data' => ['message' => $this->l10n->t('Invalid file provided')]], + Http::STATUS_BAD_REQUEST + ); + } + + $userFolder->getStorage()->verifyPath($path, $uploadedFile['name']); + // {user}/{formsfolder}/{formid} + + //fixme: add validation of supported mime types + //fixme: add validation of max file size + //fixme: add validation of max file count + + if ($userFolder->nodeExists($path)) { + $folder = $userFolder->get($path); + } else { + $folder = $userFolder->newFolder($path); + } + + $fileName = $folder->getNonExistingName($uploadedFile['name']); + $file = $folder->newFile($fileName, file_get_contents($uploadedFile['tmp_name'])); + + $uploadedFileId = Uuid::uuid4()->toString(); + $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); + } + /** * @CORS * @NoAdminRequired @@ -1063,7 +1176,7 @@ public function insertSubmission(int $formId, array $answers, string $shareHash throw new OCSBadRequestException(); } - // Does the user have access to the form (Either by logged in user, or by providing public share-hash.) + // Does the user have access to the form (Either by logged-in user, or by providing public share-hash.) try { $isPublicShare = false; @@ -1120,7 +1233,6 @@ public function insertSubmission(int $formId, array $answers, string $shareHash // Insert new submission $this->submissionMapper->insert($submission); - $submissionId = $submission->getId(); // Ensure the form is unique if needed. // If we can not submit anymore then the submission must be unique 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/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..3ac2d4ec04 --- /dev/null +++ b/lib/Db/UploadedFileMapper.php @@ -0,0 +1,58 @@ + + * + * @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]; + } +} diff --git a/lib/Migration/Version040010Date20240401123456.php b/lib/Migration/Version040010Date20240401123456.php new file mode 100644 index 0000000000..0465210255 --- /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']); + + return $schema; + } +} diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 49c01d8787..697999bad2 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; @@ -595,6 +596,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 = []; } @@ -680,8 +684,19 @@ 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, Question $question): string { + + return implode('/', [ + 'forms', + self::normalizeFileName($form->getId().'. '.$form->getTitle()), + self::normalizeFileName($question->getId().'. '.$question->getName()) + ]); + } + + 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 75689fe19e..ebdf2c850c 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -372,8 +372,9 @@ public function validateSubmission(array $questions, array $answers): bool { // Perform further checks only for answered questions if ($questionAnswered) { - // Check if non multiple questions have not more than one answer - if ($question['type'] !== Constants::ANSWER_TYPE_MULTIPLE && count($answers[$questionId]) > 1) { + // Check if non-multiple questions have not more than one answer + if (!in_array($question['type'], [Constants::ANSWER_TYPE_MULTIPLE, Constants::ANSWER_TYPE_FILE]) + && count($answers[$questionId]) > 1) { return false; } diff --git a/src/components/Questions/QuestionFile.vue b/src/components/Questions/QuestionFile.vue new file mode 100644 index 0000000000..27e6acf994 --- /dev/null +++ b/src/components/Questions/QuestionFile.vue @@ -0,0 +1,230 @@ + + + + + + + diff --git a/src/models/AnswerTypes.js b/src/models/AnswerTypes.js index 5268ef758e..e24044efd2 100644 --- a/src/models/AnswerTypes.js +++ b/src/models/AnswerTypes.js @@ -20,17 +20,19 @@ * */ -import QuestionMultiple from '../components/Questions/QuestionMultiple.vue' +import QuestionDate from '../components/Questions/QuestionDate.vue' import QuestionDropdown from '../components/Questions/QuestionDropdown.vue' -import QuestionShort from '../components/Questions/QuestionShort.vue' +import QuestionFile from '../components/Questions/QuestionFile.vue' import QuestionLong from '../components/Questions/QuestionLong.vue' -import QuestionDate from '../components/Questions/QuestionDate.vue' +import QuestionMultiple from '../components/Questions/QuestionMultiple.vue' +import QuestionShort from '../components/Questions/QuestionShort.vue' import IconCheckboxOutline from 'vue-material-design-icons/CheckboxOutline.vue' import IconRadioboxMarked from 'vue-material-design-icons/RadioboxMarked.vue' import IconArrowDownDropCircleOutline from 'vue-material-design-icons/ArrowDownDropCircleOutline.vue' import IconTextShort from 'vue-material-design-icons/TextShort.vue' import IconTextLong from 'vue-material-design-icons/TextLong.vue' +import IconFile from 'vue-material-design-icons/File.vue' import IconCalendar from 'vue-material-design-icons/Calendar.vue' import IconClockOutline from 'vue-material-design-icons/ClockOutline.vue' @@ -104,6 +106,16 @@ export default { warningInvalid: t('forms', 'This question needs a title and at least one answer!'), }, + file: { + component: QuestionFile, + icon: IconFile, + label: t('forms', 'File'), + predefined: false, + + titlePlaceholder: t('forms', 'File question title'), + warningInvalid: t('forms', 'This question needs a title!'), + }, + short: { component: QuestionShort, icon: IconTextShort,