From 2e010ce3a010b7ab6ddf346c3974f2e50a3ea043 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Mon, 14 Sep 2026 20:11:36 +0200 Subject: [PATCH 1/4] Feat(file): add support of the chunked file upload Signed-off-by: Kostiantyn Miakshyn --- docs/API_v3.md | 50 +++ lib/BackgroundJob/CleanupUploadedFilesJob.php | 21 + lib/Controller/ApiController.php | 358 +++++++++++++--- lib/Db/UploadedFileMapper.php | 16 + lib/ResponseDefinitions.php | 4 + openapi.json | 385 ++++++++++++++++++ package-lock.json | 2 +- package.json | 2 +- src/components/Questions/QuestionFile.vue | 268 ++++++++---- src/utils/FileUpload.ts | 196 +++++++++ .../CleanupUploadedFilesJobTest.php | 4 + tests/Unit/Controller/ApiControllerTest.php | 6 +- 12 files changed, 1167 insertions(+), 145 deletions(-) create mode 100644 src/utils/FileUpload.ts diff --git a/docs/API_v3.md b/docs/API_v3.md index 9ce0f136a..2d25d5030 100644 --- a/docs/API_v3.md +++ b/docs/API_v3.md @@ -886,6 +886,56 @@ Upload a file to a file question before form submission. Each uploaded file is s When submitting the form, each file answer must include both `uploadedFileId` and `uploadToken` from this response for the same `questionId`. Uploads from other forms, questions, or sessions are rejected. +### Create an upload share + +For large files it is recommended to upload via WebDAV instead of a multipart request, as this supports chunked uploads and is not limited by PHP upload limits. This endpoint creates a temporary folder inside the form owner's storage and shares it via a create-only (file drop) public link. Files can then be uploaded to `public.php/dav/files/{shareToken}` - including chunked uploads via `public.php/dav/uploads/{shareToken}` - and afterwards registered with the `register` endpoint. + +- Endpoint: `/api/v3/forms/{formId}/submissions/files/{questionId}/share` +- Method: `POST` +- Url-Parameters: + | Parameter | Type | Description | + |--------------|----------------|-------------| + | _formId_ | Integer | ID of the form to upload the file to | + | _questionId_ | Integer | ID of the file question to upload the file to | +- Parameters: + | Parameter | Type | Description | + |--------------|----------------|-------------| + | _shareHash_ | String | optional, only necessary for uploads on a public share link | +- Response: **Status-Code OK**, as well as the token of the created share. + +``` +"data": { + "shareToken": "dCP8yn3N86EK9sL" +} +``` + +### Register an uploaded file + +Registers a file that was uploaded to an upload share (see above) and binds it to the form and question, just like a direct file upload. The response includes an `uploadToken` that must be sent back when submitting the form, together with `uploadedFileId`. + +- Endpoint: `/api/v3/forms/{formId}/submissions/files/{questionId}/register` +- Method: `POST` +- Url-Parameters: + | Parameter | Type | Description | + |--------------|----------------|-------------| + | _formId_ | Integer | ID of the form to upload the file to | + | _questionId_ | Integer | ID of the file question to upload the file to | +- Parameters: + | Parameter | Type | Description | + |--------------|----------------|-------------| + | _shareToken_ | String | Token of the upload share the file was uploaded to | + | _fileName_ | String | Name of the uploaded file inside the share | + | _shareHash_ | String | optional, only necessary for uploads on a public share link | +- Response: **Status-Code OK**, as well as the file id and name of the registered file. + +``` +"data": { + "uploadedFileId": 42, + "fileName": "document.pdf", + "uploadToken": "a1b2c3d4e5f6..." +} +``` + ### Get a specific submission Get a specific submission of a form. Viewing another user's submission requires the `results` permission; otherwise only the submission owner may view it. diff --git a/lib/BackgroundJob/CleanupUploadedFilesJob.php b/lib/BackgroundJob/CleanupUploadedFilesJob.php index d4c18342e..78d9fac3a 100644 --- a/lib/BackgroundJob/CleanupUploadedFilesJob.php +++ b/lib/BackgroundJob/CleanupUploadedFilesJob.php @@ -15,6 +15,8 @@ use OCP\BackgroundJob\TimedJob; use OCP\Files\Folder; use OCP\Files\IRootFolder; +use OCP\Share\IManager; +use OCP\Share\IShare; use Psr\Log\LoggerInterface; class CleanupUploadedFilesJob extends TimedJob { @@ -24,6 +26,7 @@ public function __construct( private readonly IRootFolder $rootFolder, private readonly FormMapper $formMapper, private readonly UploadedFileMapper $uploadedFileMapper, + private readonly IManager $shareManager, private readonly LoggerInterface $logger, ITimeFactory $time, ) { @@ -96,6 +99,9 @@ public function run($argument): void { foreach ($unsubmittedFilesFolder->getDirectoryListing() as $node) { if ($node->getName() < $dateTime->getTimestamp()) { + if ($node instanceof Folder) { + $this->deleteUploadShares($node, $userId); + } $node->delete(); $deleted++; } @@ -104,4 +110,19 @@ public function run($argument): void { $this->logger->info('Deleted {deleted} folders.', ['deleted' => $deleted]); } + + /** + * Delete link shares created for file question uploads within a folder + */ + private function deleteUploadShares(Folder $folder, string $userId): void { + foreach ($this->shareManager->getSharesBy($userId, IShare::TYPE_LINK, $folder, false, -1) as $share) { + $this->shareManager->deleteShare($share); + } + + foreach ($folder->getDirectoryListing() as $node) { + if ($node instanceof Folder) { + $this->deleteUploadShares($node, $userId); + } + } + } } diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 6b54111fa..b8814cd35 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -45,15 +45,20 @@ use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCSController; use OCP\BackgroundJob\IJobList; +use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\IMimeTypeDetector; use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; use OCP\IL10N; use OCP\IRequest; use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; use OCP\Security\ISecureRandom; +use OCP\Share\Exceptions\ShareNotFound; +use OCP\Share\IManager; +use OCP\Share\IShare; use Psr\Log\LoggerInterface; /** @@ -67,6 +72,7 @@ * @psalm-import-type FormsSubmission from ResponseDefinitions * @psalm-import-type FormsSubmissions from ResponseDefinitions * @psalm-import-type FormsUploadedFile from ResponseDefinitions + * @psalm-import-type FormsUploadShare from ResponseDefinitions */ class ApiController extends OCSController { private readonly ?IUser $currentUser; @@ -92,6 +98,7 @@ public function __construct( private readonly IMimeTypeDetector $mimeTypeDetector, private readonly IJobList $jobList, private readonly ISecureRandom $secureRandom, + private readonly IManager $shareManager, ) { parent::__construct($appName, $request); $this->currentUser = $userSession->getUser(); @@ -1690,25 +1697,7 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = '' throw new OCSBadRequestException('No files provided'); } - $form = $this->formsService->loadFormForSubmission($formId, $shareHash); - - if (!$this->formsService->canSubmit($form)) { - throw new OCSForbiddenException('Already submitted'); - } - - try { - $question = $this->questionMapper->findById($questionId); - } catch (IMapperException) { - $this->logger->debug('Could not find question with id {questionId}', [ - 'questionId' => $questionId - ]); - throw new OCSNotFoundException('Could not find question'); - } - - if ($formId !== $question->getFormId()) { - $this->logger->debug('Question doesn\'t belong to the given form'); - throw new OCSBadRequestException('Question doesn\'t belong to the given form'); - } + [$form, $question] = $this->loadFileQuestionForUpload($formId, $questionId, $shareHash); $path = $this->formsService->getTemporaryUploadedFilePath($form, $question); @@ -1738,36 +1727,7 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = '' 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($mimeType, $allowedFileType) || 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'] ?? [])) - )); - } + $this->assertFileTypeAllowed($question, $mimeType); } if ($userFolder->nodeExists($path)) { @@ -1779,28 +1739,298 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = '' $fileName = $folder->getNonExistingName($uploadedFile['name']); $file = $folder->newFile($fileName, file_get_contents($uploadedFile['tmp_name'])); - $uploadToken = $this->secureRandom->generate(32, ISecureRandom::CHAR_ALPHANUMERIC); - $uploadedFileEntity = new UploadedFile(); - $uploadedFileEntity->setFormId($formId); - $uploadedFileEntity->setQuestionId($questionId); - $uploadedFileEntity->setUploadToken($uploadToken); - $uploadedFileEntity->setOriginalFileName($fileName); - $uploadedFileEntity->setFileId($file->getId()); - $uploadedFileEntity->setCreated(time()); - $this->uploadedFileMapper->insert($uploadedFileEntity); - - $response[] = [ - 'uploadedFileId' => $uploadedFileEntity->getId(), - 'fileName' => $fileName, - 'uploadToken' => $uploadToken, - ]; + $response[] = $this->createUploadedFileEntry($formId, $questionId, $fileName, $file->getId()); } return new DataResponse($response); } + /** + * Create a temporary upload share for a file question + * + * Creates a temporary folder inside the form owner's storage and shares it + * via a create-only (file drop) public link. This allows uploading files + * over WebDAV - including chunked uploads - before the form is submitted. + * Uploaded files have to be registered using the `register` endpoint. + * + * @param int $formId id of the form + * @param int $questionId id of the question + * @param string $shareHash hash of the form share + * @return DataResponse + * @throws OCSBadRequestException Question doesn't belong to the given form + * @throws OCSBadRequestException Question is not a file question + * @throws OCSBadRequestException Could not create the upload share + * @throws OCSForbiddenException Already submitted + * @throws OCSNotFoundException Could not find question + * + * 200: the token of the created upload share + */ + #[CORS()] + #[PublicPage()] + #[NoAdminRequired()] + #[BruteForceProtection(action: 'form')] + #[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/submissions/files/{questionId}/share')] + public function createUploadShare(int $formId, int $questionId, string $shareHash = ''): DataResponse { + $this->logger->debug('Creating upload share for formId: {formId}, questionId: {questionId}', [ + 'formId' => $formId, + 'questionId' => $questionId + ]); + + [$form, $question] = $this->loadFileQuestionForUpload($formId, $questionId, $shareHash); + + $userFolder = $this->rootFolder->getUserFolder($form->getOwnerId()); + $path = $this->formsService->getTemporaryUploadedFilePath($form, $question); + if ($userFolder->nodeExists($path)) { + $folder = $userFolder->get($path); + } else { + $folder = $userFolder->newFolder($path); + } + if (!$folder instanceof Folder) { + throw new OCSBadRequestException('Could not create upload folder'); + } + + $share = $this->shareManager->newShare(); + $share->setNode($folder); + $share->setShareType(IShare::TYPE_LINK); + // create-only (file drop): uploads land in the folder but can neither + // be listed, read back nor deleted by the submitter + $share->setPermissions(\OCP\Constants::PERMISSION_CREATE); + $share->setSharedBy($form->getOwnerId()); + $share->setShareOwner($form->getOwnerId()); + $share->setLabel($this->l10n->t('Forms file upload')); + + try { + $share = $this->shareManager->createShare($share); + } catch (\Exception $e) { + $this->logger->warning('Could not create upload share for form {formId}', [ + 'formId' => $formId, + 'exception' => $e, + ]); + throw new OCSBadRequestException('Could not create upload share'); + } + + return new DataResponse(['shareToken' => $share->getToken()]); + } + + /** + * Register a file that was uploaded to an upload share + * + * Looks up the file inside the folder of the given upload share, validates + * it against the question settings and binds it to the form and question. + * The returned `uploadedFileId` and `uploadToken` can then be used as an + * answer when submitting the form. + * + * @param int $formId id of the form + * @param int $questionId id of the question + * @param string $shareToken token of the upload share (see `share` endpoint) + * @param string $fileName name of the uploaded file inside the share + * @param string $shareHash hash of the form share + * @return DataResponse + * @throws OCSBadRequestException Question doesn't belong to the given form + * @throws OCSBadRequestException Question is not a file question + * @throws OCSBadRequestException Invalid upload share or file name + * @throws OCSBadRequestException File size exceeds the maximum allowed size + * @throws OCSBadRequestException File type is not allowed + * @throws OCSForbiddenException Already submitted + * @throws OCSNotFoundException Could not find question or uploaded file + * + * 200: the file id and name of the registered file + */ + #[CORS()] + #[PublicPage()] + #[NoAdminRequired()] + #[BruteForceProtection(action: 'form')] + #[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/submissions/files/{questionId}/register')] + public function registerUploadedFile(int $formId, int $questionId, string $shareToken, string $fileName, string $shareHash = ''): DataResponse { + $this->logger->debug('Registering uploaded file for formId: {formId}, questionId: {questionId}', [ + 'formId' => $formId, + 'questionId' => $questionId + ]); + + [$form, $question] = $this->loadFileQuestionForUpload($formId, $questionId, $shareHash); + + try { + $share = $this->shareManager->getShareByToken($shareToken); + } catch (ShareNotFound) { + throw new OCSBadRequestException('Invalid upload share'); + } + + if ($share->getShareType() !== IShare::TYPE_LINK || $share->getShareOwner() !== $form->getOwnerId()) { + throw new OCSBadRequestException('Invalid upload share'); + } + + try { + $node = $share->getNode(); + } catch (NotFoundException) { + throw new OCSBadRequestException('Invalid upload share'); + } + + if (!$node instanceof Folder) { + throw new OCSBadRequestException('Invalid upload share'); + } + + // The share must point to the temporary upload folder of this form and question + $userFolder = $this->rootFolder->getUserFolder($form->getOwnerId()); + $relativePath = $userFolder->getRelativePath($node->getPath()); + $expectedPath = '#^/' . preg_quote(Constants::UNSUBMITTED_FILES_FOLDER . '/', '#') + . '[^/]+/' . $formId . ' - [^/]+/' . $questionId . ' - [^/]+$#'; + if ($relativePath === null || !preg_match($expectedPath, $relativePath)) { + throw new OCSBadRequestException('Invalid upload share'); + } + + if ($fileName === '' || str_contains($fileName, '/') || str_contains($fileName, '\\')) { + throw new OCSBadRequestException('Invalid file name'); + } + + $file = $this->resolveSharedFile($node, $fileName); + if ($file === null) { + throw new OCSNotFoundException('Could not find uploaded file'); + } + + $extraSettings = $question->getExtraSettings(); + if (($extraSettings['maxFileSize'] ?? 0) > 0 && $file->getSize() > $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'])) { + $this->assertFileTypeAllowed($question, $file->getMimeType()); + } + + return new DataResponse($this->createUploadedFileEntry($formId, $questionId, $file->getName(), $file->getId())); + } + // private functions + /** + * Load a file question and check that the current request may submit to its form + * + * @return array{0: Form, 1: Question} + * @throws OCSBadRequestException Question doesn't belong to the given form or is not a file question + * @throws OCSForbiddenException Already submitted + * @throws OCSNotFoundException Could not find question + */ + private function loadFileQuestionForUpload(int $formId, int $questionId, string $shareHash): array { + $form = $this->formsService->loadFormForSubmission($formId, $shareHash); + + if (!$this->formsService->canSubmit($form)) { + throw new OCSForbiddenException('Already submitted'); + } + + try { + $question = $this->questionMapper->findById($questionId); + } catch (IMapperException) { + $this->logger->debug('Could not find question with id {questionId}', [ + 'questionId' => $questionId + ]); + throw new OCSNotFoundException('Could not find question'); + } + + if ($formId !== $question->getFormId()) { + $this->logger->debug('Question doesn\'t belong to the given form'); + throw new OCSBadRequestException('Question doesn\'t belong to the given form'); + } + + if ($question->getType() !== Constants::ANSWER_TYPE_FILE) { + throw new OCSBadRequestException('Question is not a file question'); + } + + return [$form, $question]; + } + + /** + * Check that a mime type matches the allowed file types/extensions of a question + * + * @throws OCSBadRequestException File type is not allowed + */ + private function assertFileTypeAllowed(Question $question, string $mimeType): void { + $extraSettings = $question->getExtraSettings(); + $aliases = $this->mimeTypeDetector->getAllAliases(); + + $valid = false; + foreach ($extraSettings['allowedFileTypes'] ?? [] as $allowedFileType) { + if (str_starts_with($mimeType, $allowedFileType) || 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'] ?? [])) + )); + } + } + + /** + * Resolve an uploaded file inside an upload share folder + * + * File drop shares rename conflicting uploads to "name (2).ext" etc., so + * suffixed variants are taken into account as well. Files that are already + * registered are skipped. + */ + private function resolveSharedFile(Folder $folder, string $fileName): ?File { + $extensionPos = strrpos($fileName, '.'); + for ($i = 0; $i <= 100; $i++) { + if ($i === 0) { + $candidate = $fileName; + } elseif ($extensionPos === false) { + $candidate = $fileName . ' (' . ($i + 1) . ')'; + } else { + $candidate = substr($fileName, 0, $extensionPos) . ' (' . ($i + 1) . ')' . substr($fileName, $extensionPos); + } + + try { + $node = $folder->get($candidate); + } catch (NotFoundException) { + break; + } + + if ($node instanceof File && $this->uploadedFileMapper->findByFileId($node->getId()) === null) { + return $node; + } + } + + return null; + } + + /** + * Create an UploadedFile entity and return its API representation + * + * @return FormsUploadedFile + */ + private function createUploadedFileEntry(int $formId, int $questionId, string $fileName, int $fileId): array { + $uploadToken = $this->secureRandom->generate(32, ISecureRandom::CHAR_ALPHANUMERIC); + $uploadedFileEntity = new UploadedFile(); + $uploadedFileEntity->setFormId($formId); + $uploadedFileEntity->setQuestionId($questionId); + $uploadedFileEntity->setUploadToken($uploadToken); + $uploadedFileEntity->setOriginalFileName($fileName); + $uploadedFileEntity->setFileId($fileId); + $uploadedFileEntity->setCreated(time()); + $this->uploadedFileMapper->insert($uploadedFileEntity); + + return [ + 'uploadedFileId' => $uploadedFileEntity->getId(), + 'fileName' => $fileName, + 'uploadToken' => $uploadToken, + ]; + } + /** * Insert answers for a question * diff --git a/lib/Db/UploadedFileMapper.php b/lib/Db/UploadedFileMapper.php index 00629e7f7..4bc71bc45 100644 --- a/lib/Db/UploadedFileMapper.php +++ b/lib/Db/UploadedFileMapper.php @@ -55,6 +55,22 @@ public function getByUploadedFileId(string $uploadedFileId): UploadedFile { return $uploadedFile; } + /** + * @param int $fileId + * @return UploadedFile|null + */ + public function findByFileId(int $fileId): ?UploadedFile { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)) + ); + + return $this->findEntities($qb)[0] ?? null; + } + /** * find all uploaded files for a given form and question and compare with the given upload token. * diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 4d658b910..46961dd5f 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -154,6 +154,10 @@ * fileName: string, * uploadToken: string, * } + * + * @psalm-type FormsUploadShare = array{ + * shareToken: string, + * } */ class ResponseDefinitions { } diff --git a/openapi.json b/openapi.json index 694617425..c65f5a5fe 100644 --- a/openapi.json +++ b/openapi.json @@ -711,6 +711,17 @@ } } }, + "UploadShare": { + "type": "object", + "required": [ + "shareToken" + ], + "properties": { + "shareToken": { + "type": "string" + } + } + }, "UploadedFile": { "type": "object", "required": [ @@ -4995,6 +5006,380 @@ } } }, + "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/submissions/files/{questionId}/register": { + "post": { + "operationId": "api-register-uploaded-file", + "summary": "Register a file that was uploaded to an upload share", + "description": "This endpoint allows CORS requests", + "tags": [ + "api" + ], + "security": [ + {}, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "shareToken": { + "type": "string", + "description": "token of the upload share (see `share` endpoint)" + }, + "fileName": { + "type": "string", + "description": "name of the uploaded file inside the share" + }, + "shareHash": { + "type": "string", + "default": "", + "description": "hash of the form share" + } + }, + "required": [ + "shareToken", + "fileName" + ] + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "id of the form", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "questionId", + "in": "path", + "description": "id of the question", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the file id and name of the registered file", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/UploadedFile" + } + } + } + } + } + } + } + }, + "400": { + "description": "File type is not allowed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Already submitted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find question or uploaded file", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/submissions/files/{questionId}/share": { + "post": { + "operationId": "api-create-upload-share", + "summary": "Create a temporary upload share for a file question", + "description": "This endpoint allows CORS requests", + "tags": [ + "api" + ], + "security": [ + {}, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "shareHash": { + "type": "string", + "default": "", + "description": "hash of the form share" + } + } + } + } + } + }, + "parameters": [ + { + "name": "formId", + "in": "path", + "description": "id of the form", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "questionId", + "in": "path", + "description": "id of the question", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "the token of the created upload share", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/UploadShare" + } + } + } + } + } + } + } + }, + "400": { + "description": "Could not create the upload share", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Already submitted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "Could not find question", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/forms/api/v3/forms/{formId}/shares": { "post": { "operationId": "share_api-new-share", diff --git a/package-lock.json b/package-lock.json index ed10a6a95..a8893eb70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@nextcloud/axios": "^2.6.0", "@nextcloud/dialogs": "^7.5.0", "@nextcloud/event-bus": "^3.3.3", - "@nextcloud/files": "^4.0.0", + "@nextcloud/files": "^4.1.0", "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/logger": "^3.0.3", diff --git a/package.json b/package.json index d3aca5e1c..c6319a903 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@nextcloud/axios": "^2.6.0", "@nextcloud/dialogs": "^7.5.0", "@nextcloud/event-bus": "^3.3.3", - "@nextcloud/files": "^4.0.0", + "@nextcloud/files": "^4.1.0", "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/logger": "^3.0.3", diff --git a/src/components/Questions/QuestionFile.vue b/src/components/Questions/QuestionFile.vue index 5d7f9a006..9e6ce7b9a 100644 --- a/src/components/Questions/QuestionFile.vue +++ b/src/components/Questions/QuestionFile.vue @@ -102,11 +102,29 @@ -
  • - - {{ t('forms', 'Uploading …') }} -
  • -
  • + + + + + + + +