From bd50b999c50791f2aeaf6dd7f3a3e7e7f17e610f Mon Sep 17 00:00:00 2001 From: Klaas Schuijtemaker Date: Sun, 30 Aug 2026 14:38:03 +0200 Subject: [PATCH] Allow documents to be uploaded from a public task A public form could not accept files. Three things were in the way, of which only two were missing. Valtimo's upload components (valtimo-file, documenten-api-file) are Angular components of the Valtimo front end, so the plain Form.io renderer the public page uses drew nothing at all for them. They are now rewritten to Form.io's own file component before the definition leaves the server, which keeps the mapping in one testable place instead of in the page. There was no upload target an applicant could reach: Valtimo's /api/v1/resource/temp is authenticated, and the public page has no token. POST /api/v1/public-task/{publicTaskId}/attachment now parks the file in temporary resource storage and answers with its resource id. The third thing turned out to need nothing: the id travels back in the submission, where Valtimo's UploadField raises a TemporaryResourceSubmittedEvent for it, exactly as for an upload from a task inside Valtimo. Whatever already handles those - a Documenten API "store uploaded document" task, the S3 listener - keeps working without knowing that a public task was involved. The endpoint is unauthenticated by design, so what it accepts is bounded rather than trusted. The bounds are configured per process link rather than in application properties, so that an administrator sets them where the rest of the task is configured: Maximum number of attachments, Maximum file size and Accepted file types, each falling back to a bounded default rather than to "no limit". They are read when the public task is created and kept with it, so editing a process link does not change the terms of a link that has already been sent out. An upload field can narrow them further with Form.io's own Maximum File Size and File Pattern, never widen them. The attachment count is a conditional update on a counter rather than a read followed by a write, so simultaneous uploads cannot both take the last slot; a refused file gives its slot back. The type a file is held to is the type its content is detected as, not the one the request claims, so a renamed executable is still an executable. Filenames are stripped of paths and of invisible characters - U+202E alone makes "factuurfdp.exe" read as "factuurexe.pdf" - before they reach metadata that later becomes, for instance, a bestandsnaam in the Documenten API. A submission may only point at files that this task uploaded for this case. Metadata on the process link is written on every file the task uploads, for whatever picks the file up: the Documenten API builds the whole document out of it and cannot default informatieobjecttype or titel, which inside Valtimo are collected by a component the public page cannot render. Its values are resolved against the case, so they can follow it rather than being fixed per process link. Valtimo resolves placeholders only in action properties that are strings, and this one is a map, so the plugin resolves the values itself; the screen offers a value selector for them. Worth knowing when deploying this: temporary resources are purged after valtimo.temporaryResourceStorage.retentionInMinutes, which defaults to 60. A public form is often left open longer than that, and an applicant would lose their attachments before pressing submit. spring.servlet.multipart.max-file-size caps every upload before the plugin sees it, and valtimo.upload.accepted-mime-types is the application-wide floor on file types. documentation/developer.md covers these, along with what a replacement HTML template has to carry over. --- README.md | 3 +- backend/app/build.gradle.kts | 1 + backend/app/docker-compose.yml | 9 + .../sandbox/LocalStackS3Configuration.kt | 71 ++ .../src/main/resources/config/application.yml | 17 + .../1-0-0/bpmn/create-public-task-url.bpmn | 66 +- .../public-task/1-0-0/bpmn/public-task.bpmn | 96 +-- .../public-task.case-definition.json | 22 +- .../1-0-0/case/tab/public-task.case-tab.json | 37 +- ...ublic-task.schema.document-definition.json | 20 +- .../communicate-public-task-url.form.json | 60 +- .../1-0-0/form/publick-task.form.json | 47 +- .../form/start-form-public-task.form.json | 38 +- .../public-task.process-document-link.json | 22 +- .../create-public-task-url.process-link.json | 48 +- .../public-task.process-link.json | 42 +- .../permission/document.permission.json | 4 +- .../plugin/public-task.pluginconfig.json | 2 +- backend/plugin/build.gradle.kts | 4 + backend/plugin/plugin.properties | 2 +- .../PublicTaskAutoConfiguration.kt | 43 +- .../publictask/domain/PublicTaskAttachment.kt | 36 + .../domain/PublicTaskAttachmentLimits.kt | 54 ++ .../publictask/domain/PublicTaskData.kt | 6 + .../domain/PublicTaskDocumentMetadata.kt | 75 ++ .../publictask/domain/PublicTaskEntity.kt | 22 +- .../publictask/plugin/PublicTaskPlugin.kt | 60 ++ .../plugin/PublicTaskPluginFactory.kt | 4 +- .../repository/PublicTaskRepository.kt | 37 +- .../publictask/service/FormIoFilePattern.kt | 70 ++ .../service/PublicTaskFormRewriter.kt | 48 ++ .../publictask/service/PublicTaskService.kt | 392 +++++++++- .../service/PublicTaskUploadField.kt | 114 +++ .../publictask/web/rest/PublicTaskResource.kt | 16 +- ...20240229-add-public-task-plugin-entity.xml | 33 + .../config/template/public_task_html.ftl | 250 +++++- .../valtimoplugins/publictask/BaseTest.kt | 11 +- .../domain/PublicTaskAttachmentLimitsTest.kt | 85 +++ .../domain/PublicTaskDocumentMetadataTest.kt | 103 +++ .../service/PublicTaskHtmlTemplateTest.kt | 98 ++- .../publictask/plugin/PublicTaskPluginTest.kt | 151 ++++ .../repository/PublicTaskRepositoryIT.kt | 135 ++++ .../service/FormIoFilePatternTest.kt | 98 +++ .../service/PublicTaskFormRewriterTest.kt | 165 ++++ .../service/PublicTaskServiceTest.kt | 713 +++++++++++++++++- .../service/PublicTaskUploadFieldTest.kt | 125 +++ .../web/rest/PublicTaskResourceIT.kt | 18 +- documentation/developer.md | 198 +++++ documentation/plugin.md | 203 +++-- documentation/release-notes.md | 3 + frontend/projects/plugin/package.json | 2 +- ...e-public-task-configuration.component.html | 59 +- ...ate-public-task-configuration.component.ts | 61 +- .../projects/plugin/src/lib/models/config.ts | 13 +- .../src/lib/publictask.plugin.module.ts | 4 +- .../lib/publictask.plugin.specification.ts | 42 +- 56 files changed, 3810 insertions(+), 348 deletions(-) create mode 100644 backend/app/src/main/kotlin/com/ritense/plugin/sandbox/LocalStackS3Configuration.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskAttachment.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskAttachmentLimits.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskDocumentMetadata.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/FormIoFilePattern.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskFormRewriter.kt create mode 100644 backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskUploadField.kt create mode 100644 backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskAttachmentLimitsTest.kt create mode 100644 backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskDocumentMetadataTest.kt create mode 100644 backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/plugin/PublicTaskPluginTest.kt create mode 100644 backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/repository/PublicTaskRepositoryIT.kt create mode 100644 backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/FormIoFilePatternTest.kt create mode 100644 backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskFormRewriterTest.kt create mode 100644 backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskUploadFieldTest.kt create mode 100644 documentation/developer.md diff --git a/README.md b/README.md index 2955452..eadf934 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ Taken als publiek formulier beschikbaar stellen. ## Documentation - [Getting Started](documentation/getting-started.md) — setup, running, and development instructions -- [Plugin Documentation](documentation/plugin.md) — plugin details and configuration +- [Plugin Documentation](documentation/plugin.md) — configuring the plugin in Valtimo +- [Implementation notes](documentation/developer.md) — process wiring, templates, application settings - [Release notes](documentation/release-notes.md) — versiegeschiedenis en wijzigingen ## Contact diff --git a/backend/app/build.gradle.kts b/backend/app/build.gradle.kts index c909650..15d20df 100644 --- a/backend/app/build.gradle.kts +++ b/backend/app/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { implementation("com.ritense.valtimo:valtimo-dependencies:$valtimoVersion") implementation("com.ritense.valtimo:local-mail:$valtimoVersion") + implementation("com.ritense.valtimo:s3-resource:$valtimoVersion") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.postgresql:postgresql") diff --git a/backend/app/docker-compose.yml b/backend/app/docker-compose.yml index ee595ac..1338f45 100644 --- a/backend/app/docker-compose.yml +++ b/backend/app/docker-compose.yml @@ -54,6 +54,15 @@ services: volumes: - plugin-database-data:/var/lib/postgres # persist data even if container shuts down + plugin-localstack: + image: localstack/localstack:3.7 + container_name: plugin-docker-compose-plugin-localstack + ports: + - "4566:4566" + environment: + SERVICES: s3 + AWS_DEFAULT_REGION: eu-west-1 + plugin-rabbitmq: image: rabbitmq:4.1.0-management container_name: plugin-docker-compose-plugin-rabbitmq diff --git a/backend/app/src/main/kotlin/com/ritense/plugin/sandbox/LocalStackS3Configuration.kt b/backend/app/src/main/kotlin/com/ritense/plugin/sandbox/LocalStackS3Configuration.kt new file mode 100644 index 0000000..ec0ed4d --- /dev/null +++ b/backend/app/src/main/kotlin/com/ritense/plugin/sandbox/LocalStackS3Configuration.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.plugin.sandbox + +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.CommandLineRunner +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.AwsCredentialsProviderChain +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.CORSConfiguration +import software.amazon.awssdk.services.s3.model.CORSRule + +// Credentials and a bucket for the LocalStack container. A real deployment has both already: dev only. +@Configuration +@Profile("dev") +class LocalStackS3Configuration { + /** LocalStack accepts any credentials; this keeps the SDK from looking for real ones. */ + @Bean + fun valtimoAwsCredentialsProviderChain(): AwsCredentialsProviderChain = + AwsCredentialsProviderChain + .builder() + .addCredentialsProvider( + StaticCredentialsProvider.create(AwsBasicCredentials.create("test", "test")), + ).build() + + /** LocalStack starts empty. The CORS rule is what lets the browser upload with a pre-signed URL. */ + @Bean + fun localStackS3BucketInitializer( + s3Client: S3Client, + @Value("\${aws.s3.bucketName}") bucketName: String, + ) = CommandLineRunner { + if (s3Client.listBuckets().buckets().none { it.name() == bucketName }) { + s3Client.createBucket { it.bucket(bucketName) } + } + s3Client.putBucketCors { request -> + request + .bucket(bucketName) + .corsConfiguration( + CORSConfiguration + .builder() + .corsRules( + CORSRule + .builder() + .allowedHeaders("*") + .allowedMethods("GET", "PUT", "POST", "DELETE", "HEAD") + .allowedOrigins("*") + .exposeHeaders("ETag") + .build(), + ).build(), + ) + } + } +} diff --git a/backend/app/src/main/resources/config/application.yml b/backend/app/src/main/resources/config/application.yml index 652bf68..2724245 100644 --- a/backend/app/src/main/resources/config/application.yml +++ b/backend/app/src/main/resources/config/application.yml @@ -6,6 +6,10 @@ spring: jackson: date-format: yyyy-MM-dd'T'HH:mm:ss.SSS'Z' time-zone: UTC + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB datasource: type: com.zaxxer.hikari.HikariDataSource driver-class-name: org.postgresql.Driver @@ -118,6 +122,19 @@ valtimo: accepted-packages: - com.ritense includeDocumentContentInResponse: true + resource: + s3: + temp-upload-listener: + # Stores a submitted upload in S3 and attaches it to the case. + enabled: true + +# Points at the LocalStack container of docker-compose.yml. A real deployment leaves the endpoint empty. +aws: + region: ${AWS_REGION:eu-west-1} + s3: + bucketRegion: ${AWS_S3_BUCKET_REGION:eu-west-1} + bucketName: ${AWS_S3_BUCKET_NAME:publictask-uploads} + endpoint: ${AWS_S3_ENDPOINT:http://localhost:4566} spring-actuator: username: admin diff --git a/backend/app/src/main/resources/config/case/public-task/1-0-0/bpmn/create-public-task-url.bpmn b/backend/app/src/main/resources/config/case/public-task/1-0-0/bpmn/create-public-task-url.bpmn index 10adc98..cd43590 100644 --- a/backend/app/src/main/resources/config/case/public-task/1-0-0/bpmn/create-public-task-url.bpmn +++ b/backend/app/src/main/resources/config/case/public-task/1-0-0/bpmn/create-public-task-url.bpmn @@ -1,67 +1,77 @@ - - - - + + + + Flow_0gwjvqr Flow_19ga70g - - + + Flow_1lf6z8q - - + + Flow_19ga70g Flow_1lf6z8q Flow_03ct38e - + - - + + Flow_03ct38e Flow_0gwjvqr - + - - + + - + - - + + - - + + - + - - + + - - + + - - + + - - + + diff --git a/backend/app/src/main/resources/config/case/public-task/1-0-0/bpmn/public-task.bpmn b/backend/app/src/main/resources/config/case/public-task/1-0-0/bpmn/public-task.bpmn index 0b168b7..ebdf98c 100644 --- a/backend/app/src/main/resources/config/case/public-task/1-0-0/bpmn/public-task.bpmn +++ b/backend/app/src/main/resources/config/case/public-task/1-0-0/bpmn/public-task.bpmn @@ -1,82 +1,90 @@ - - - - + + + + - + Flow_1io4evr - + - + - + - + Flow_12em9k5 - + - + - - - + + + - - - + + + - + Flow_1io4evr - + Flow_12em9k5 - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + diff --git a/backend/app/src/main/resources/config/case/public-task/1-0-0/case/definition/public-task.case-definition.json b/backend/app/src/main/resources/config/case/public-task/1-0-0/case/definition/public-task.case-definition.json index a87b699..27417f4 100644 --- a/backend/app/src/main/resources/config/case/public-task/1-0-0/case/definition/public-task.case-definition.json +++ b/backend/app/src/main/resources/config/case/public-task/1-0-0/case/definition/public-task.case-definition.json @@ -1,12 +1,12 @@ { - "key" : "public-task", - "versionTag" : "1.0.0", - "name" : "Public task", - "description" : null, - "createdBy" : null, - "createdDate" : null, - "basedOnVersionTag" : null, - "final" : false, - "canHaveAssignee" : false, - "autoAssignTasks" : false -} \ No newline at end of file + "key": "public-task", + "versionTag": "1.0.0", + "name": "Public task", + "description": null, + "createdBy": null, + "createdDate": null, + "basedOnVersionTag": null, + "final": false, + "canHaveAssignee": false, + "autoAssignTasks": false +} diff --git a/backend/app/src/main/resources/config/case/public-task/1-0-0/case/tab/public-task.case-tab.json b/backend/app/src/main/resources/config/case/public-task/1-0-0/case/tab/public-task.case-tab.json index 4e8d679..3a42bad 100644 --- a/backend/app/src/main/resources/config/case/public-task/1-0-0/case/tab/public-task.case-tab.json +++ b/backend/app/src/main/resources/config/case/public-task/1-0-0/case/tab/public-task.case-tab.json @@ -1,16 +1,23 @@ [ - { - "key" : "summary", - "name" : null, - "type" : "standard", - "contentKey" : "summary", - "showTasks" : false - }, - { - "key" : "progress", - "name" : null, - "type" : "standard", - "contentKey" : "progress", - "showTasks" : false - } -] \ No newline at end of file + { + "key": "summary", + "name": null, + "type": "standard", + "contentKey": "summary", + "showTasks": true + }, + { + "key": "progress", + "name": null, + "type": "standard", + "contentKey": "progress", + "showTasks": true + }, + { + "key": "documents", + "name": null, + "type": "standard", + "contentKey": "documents", + "showTasks": false + } +] diff --git a/backend/app/src/main/resources/config/case/public-task/1-0-0/document/definition/public-task.schema.document-definition.json b/backend/app/src/main/resources/config/case/public-task/1-0-0/document/definition/public-task.schema.document-definition.json index df025d7..7c5f2ea 100644 --- a/backend/app/src/main/resources/config/case/public-task/1-0-0/document/definition/public-task.schema.document-definition.json +++ b/backend/app/src/main/resources/config/case/public-task/1-0-0/document/definition/public-task.schema.document-definition.json @@ -1,12 +1,12 @@ { - "$id" : "public-task.schema", - "type" : "object", - "title" : "Public task", - "$schema" : "http://json-schema.org/draft-07/schema#", - "properties" : { - "publicTaskAssignee" : { - "type" : "string" - }, - "additionalProperties" : true - } + "$id": "public-task.schema", + "type": "object", + "title": "Public task", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "publicTaskAssignee": { + "type": "string" + }, + "additionalProperties": true + } } diff --git a/backend/app/src/main/resources/config/case/public-task/1-0-0/form/communicate-public-task-url.form.json b/backend/app/src/main/resources/config/case/public-task/1-0-0/form/communicate-public-task-url.form.json index b13fbd4..658dc7a 100644 --- a/backend/app/src/main/resources/config/case/public-task/1-0-0/form/communicate-public-task-url.form.json +++ b/backend/app/src/main/resources/config/case/public-task/1-0-0/form/communicate-public-task-url.form.json @@ -1,32 +1,32 @@ { - "display": "form", - "components": [ - { - "key": "publicTaskUrl", - "type": "textfield", - "input": true, - "label": "Public task URL", - "disabled": true, - "persistent": "client-only", - "properties": { - "sourceKey": "pv:url" - } - }, - { - "key": "publicTaskUrlCommunicated", - "type": "checkbox", - "input": true, - "label": "I confirm that I have communicated the URL of the public task to the assignee candidate", - "validate": { - "required": true - } - }, - { - "key": "submit", - "type": "button", - "input": true, - "label": "Submit", - "disableOnInvalid": true - } - ] + "display": "form", + "components": [ + { + "key": "publicTaskUrl", + "type": "textfield", + "input": true, + "label": "Public task URL", + "disabled": true, + "persistent": "client-only", + "properties": { + "sourceKey": "pv:url" + } + }, + { + "key": "publicTaskUrlCommunicated", + "type": "checkbox", + "input": true, + "label": "I confirm that I have communicated the URL of the public task to the assignee candidate", + "validate": { + "required": true + } + }, + { + "key": "submit", + "type": "button", + "input": true, + "label": "Submit", + "disableOnInvalid": true + } + ] } diff --git a/backend/app/src/main/resources/config/case/public-task/1-0-0/form/publick-task.form.json b/backend/app/src/main/resources/config/case/public-task/1-0-0/form/publick-task.form.json index 3bc9c49..e2627d9 100644 --- a/backend/app/src/main/resources/config/case/public-task/1-0-0/form/publick-task.form.json +++ b/backend/app/src/main/resources/config/case/public-task/1-0-0/form/publick-task.form.json @@ -1,22 +1,29 @@ { - "display" : "form", - "components" : [ - { - "key" : "naam", - "type" : "textfield", - "input" : true, - "label" : "Naam", - "validate" : { - "required" : true - } - }, - { - "key" : "submit", - "type" : "button", - "input" : true, - "label" : "Submit", - "tableView" : false, - "disableOnInvalid" : true - } - ] + "display": "form", + "components": [ + { + "key": "naam", + "type": "textfield", + "input": true, + "label": "Naam", + "validate": { + "required": true + } + }, + { + "key": "bijlagen", + "type": "valtimo-file", + "input": true, + "label": "Bijlagen", + "multiple": true + }, + { + "key": "submit", + "type": "button", + "input": true, + "label": "Submit", + "tableView": false, + "disableOnInvalid": true + } + ] } diff --git a/backend/app/src/main/resources/config/case/public-task/1-0-0/form/start-form-public-task.form.json b/backend/app/src/main/resources/config/case/public-task/1-0-0/form/start-form-public-task.form.json index 70c5f9b..de5cc9a 100644 --- a/backend/app/src/main/resources/config/case/public-task/1-0-0/form/start-form-public-task.form.json +++ b/backend/app/src/main/resources/config/case/public-task/1-0-0/form/start-form-public-task.form.json @@ -1,21 +1,21 @@ { - "display" : "form", - "components" : [ - { - "key" : "publicTaskAssignee", - "type" : "textfield", - "input" : true, - "label" : "Public task assignee", - "validate" : { - "required" : true - } - }, - { - "key" : "submit", - "type" : "button", - "input" : true, - "label" : "Submit", - "disableOnInvalid" : true - } - ] + "display": "form", + "components": [ + { + "key": "publicTaskAssignee", + "type": "textfield", + "input": true, + "label": "Public task assignee", + "validate": { + "required": true + } + }, + { + "key": "submit", + "type": "button", + "input": true, + "label": "Submit", + "disableOnInvalid": true + } + ] } diff --git a/backend/app/src/main/resources/config/case/public-task/1-0-0/process-document-link/public-task.process-document-link.json b/backend/app/src/main/resources/config/case/public-task/1-0-0/process-document-link/public-task.process-document-link.json index 0e5bcb4..215c593 100644 --- a/backend/app/src/main/resources/config/case/public-task/1-0-0/process-document-link/public-task.process-document-link.json +++ b/backend/app/src/main/resources/config/case/public-task/1-0-0/process-document-link/public-task.process-document-link.json @@ -1,12 +1,12 @@ [ - { - "processDefinitionKey" : "public-task", - "canInitializeDocument" : true, - "startableByUser" : true - }, - { - "processDefinitionKey" : "create-public-task-url", - "canInitializeDocument" : false, - "startableByUser" : false - } -] \ No newline at end of file + { + "processDefinitionKey": "public-task", + "canInitializeDocument": true, + "startableByUser": true + }, + { + "processDefinitionKey": "create-public-task-url", + "canInitializeDocument": false, + "startableByUser": false + } +] diff --git a/backend/app/src/main/resources/config/case/public-task/1-0-0/process-link/create-public-task-url.process-link.json b/backend/app/src/main/resources/config/case/public-task/1-0-0/process-link/create-public-task-url.process-link.json index 6addc32..8555390 100644 --- a/backend/app/src/main/resources/config/case/public-task/1-0-0/process-link/create-public-task-url.process-link.json +++ b/backend/app/src/main/resources/config/case/public-task/1-0-0/process-link/create-public-task-url.process-link.json @@ -1,23 +1,31 @@ [ - { - "activityId" : "st-create-public-task-url", - "activityType" : "bpmn:ServiceTask:start", - "pluginConfigurationId" : "a45c1762-7819-47bb-b492-959db9cfd810", - "pluginActionDefinitionKey" : "create-public-task", - "actionProperties" : { - "pvAssigneeCandidateContactData" : "pv:assigneeCandidate", - "timeToLive" : "7" + { + "activityId": "st-create-public-task-url", + "activityType": "bpmn:ServiceTask:start", + "pluginConfigurationId": "a45c1762-7819-47bb-b492-959db9cfd810", + "pluginActionDefinitionKey": "create-public-task", + "actionProperties": { + "pvAssigneeCandidateContactData": "pv:assigneeCandidate", + "timeToLive": "7", + "maxAttachments": 5, + "maxAttachmentSizeInBytes": 5242880, + "acceptedMimeTypes": "application/pdf,image/jpeg,image/png,text/csv", + "documentMetadata": { + "titel": "doc:publicTaskAssignee", + "auteur": "pv:assigneeCandidate", + "informatieobjecttype": "https://catalogi.example.org/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000" + } + }, + "processLinkType": "plugin" }, - "processLinkType" : "plugin" - }, - { - "activityId" : "ut-communicate-public-task-url", - "activityType" : "bpmn:UserTask:create", - "formDefinitionName" : "communicate-public-task-url", - "viewModelEnabled" : false, - "formDisplayType" : "modal", - "formSize" : "small", - "subtitles" : [], - "processLinkType" : "form" - } + { + "activityId": "ut-communicate-public-task-url", + "activityType": "bpmn:UserTask:create", + "formDefinitionName": "communicate-public-task-url", + "viewModelEnabled": false, + "formDisplayType": "modal", + "formSize": "small", + "subtitles": [], + "processLinkType": "form" + } ] diff --git a/backend/app/src/main/resources/config/case/public-task/1-0-0/process-link/public-task.process-link.json b/backend/app/src/main/resources/config/case/public-task/1-0-0/process-link/public-task.process-link.json index 8a4307d..d817bdd 100644 --- a/backend/app/src/main/resources/config/case/public-task/1-0-0/process-link/public-task.process-link.json +++ b/backend/app/src/main/resources/config/case/public-task/1-0-0/process-link/public-task.process-link.json @@ -1,22 +1,22 @@ [ - { - "activityId" : "StartEvent_1", - "activityType" : "bpmn:StartEvent:start", - "formDefinitionName" : "start-form-public-task", - "viewModelEnabled" : false, - "formDisplayType" : "modal", - "formSize" : "medium", - "subtitles" : null, - "processLinkType" : "form" - }, - { - "activityId" : "ut-public-task", - "activityType" : "bpmn:UserTask:create", - "formDefinitionName" : "publick-task", - "viewModelEnabled" : false, - "formDisplayType" : "panel", - "formSize" : "medium", - "subtitles" : [], - "processLinkType" : "form" - } -] \ No newline at end of file + { + "activityId": "StartEvent_1", + "activityType": "bpmn:StartEvent:start", + "formDefinitionName": "start-form-public-task", + "viewModelEnabled": false, + "formDisplayType": "modal", + "formSize": "medium", + "subtitles": null, + "processLinkType": "form" + }, + { + "activityId": "ut-public-task", + "activityType": "bpmn:UserTask:create", + "formDefinitionName": "publick-task", + "viewModelEnabled": false, + "formDisplayType": "panel", + "formSize": "medium", + "subtitles": [], + "processLinkType": "form" + } +] diff --git a/backend/app/src/main/resources/config/global/permission/document.permission.json b/backend/app/src/main/resources/config/global/permission/document.permission.json index e0c36cc..a964ada 100644 --- a/backend/app/src/main/resources/config/global/permission/document.permission.json +++ b/backend/app/src/main/resources/config/global/permission/document.permission.json @@ -10,7 +10,9 @@ "claim", "assign", "assignable", - "export" + "export", + "inspect", + "inspect_modify" ], "roleKey": "ROLE_ADMIN" }, diff --git a/backend/app/src/main/resources/config/plugin/public-task.pluginconfig.json b/backend/app/src/main/resources/config/plugin/public-task.pluginconfig.json index ee5fef3..1bf441e 100644 --- a/backend/app/src/main/resources/config/plugin/public-task.pluginconfig.json +++ b/backend/app/src/main/resources/config/plugin/public-task.pluginconfig.json @@ -2,7 +2,7 @@ { "id": "a45c1762-7819-47bb-b492-959db9cfd810", "title": "Public task (Autodeployed)", - "properties": { }, + "properties": {}, "pluginDefinitionKey": "public-task" } ] diff --git a/backend/plugin/build.gradle.kts b/backend/plugin/build.gradle.kts index ef7413e..fa0af8f 100644 --- a/backend/plugin/build.gradle.kts +++ b/backend/plugin/build.gradle.kts @@ -14,6 +14,7 @@ * limitations under the License. */ +val apacheTikaVersion: String by project val kotlinLoggingVersion: String by project val mockitoKotlinVersion: String by project @@ -35,6 +36,7 @@ dependencies { compileOnly("com.ritense.valtimo:form") compileOnly("com.ritense.valtimo:plugin-valtimo") compileOnly("com.ritense.valtimo:process-document") + compileOnly("com.ritense.valtimo:temporary-resource-storage") compileOnly("com.ritense.valtimo:value-resolver") compileOnly("org.springframework.boot:spring-boot-starter-webflux") @@ -42,6 +44,7 @@ dependencies { compileOnly("org.springframework.boot:spring-boot-starter-web") compileOnly("org.springframework.boot:spring-boot-starter-security") compileOnly("org.springframework.boot:spring-boot-starter-data-jpa") + compileOnly("org.apache.tika:tika-core:$apacheTikaVersion") compileOnly("io.github.oshai:kotlin-logging-jvm:$kotlinLoggingVersion") compileOnly("com.fasterxml.jackson.core:jackson-databind") @@ -58,6 +61,7 @@ dependencies { testImplementation("com.ritense.valtimo:plugin") testImplementation("com.ritense.valtimo:temporary-resource-storage") testImplementation("com.ritense.valtimo:test-utils-common") + testImplementation("com.ritense.valtimo:value-resolver") testImplementation("org.springframework.boot:spring-boot-starter-data-jpa") testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/backend/plugin/plugin.properties b/backend/plugin/plugin.properties index 6b50861..a80029d 100644 --- a/backend/plugin/plugin.properties +++ b/backend/plugin/plugin.properties @@ -1,3 +1,3 @@ pluginGroupId=com.ritense.valtimoplugins pluginArtifactId=publictask -pluginVersion=2.1.3 +pluginVersion=2.2.0 diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/autoconfiguration/PublicTaskAutoConfiguration.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/autoconfiguration/PublicTaskAutoConfiguration.kt index 5f750da..83b337f 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/autoconfiguration/PublicTaskAutoConfiguration.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/autoconfiguration/PublicTaskAutoConfiguration.kt @@ -19,7 +19,9 @@ package com.ritense.valtimoplugins.publictask.autoconfiguration import com.ritense.form.service.impl.DefaultFormSubmissionService import com.ritense.plugin.service.PluginService import com.ritense.processlink.service.ProcessLinkActivityService +import com.ritense.resource.service.TemporaryResourceStorageService import com.ritense.valtimo.contract.annotation.ProcessBean +import com.ritense.valtimo.contract.upload.ValtimoUploadProperties import com.ritense.valtimoplugins.publictask.config.PublicTaskSecurityConfigurer import com.ritense.valtimoplugins.publictask.htmlrenderer.config.FreemarkerConfig import com.ritense.valtimoplugins.publictask.htmlrenderer.service.HtmlRenderService @@ -27,10 +29,14 @@ import com.ritense.valtimoplugins.publictask.plugin.PublicTaskPluginFactory import com.ritense.valtimoplugins.publictask.repository.PublicTaskRepository import com.ritense.valtimoplugins.publictask.service.PublicTaskService import com.ritense.valtimoplugins.publictask.web.rest.PublicTaskResource +import com.ritense.valueresolver.ValueResolverService +import io.github.oshai.kotlinlogging.KotlinLogging import org.operaton.bpm.engine.RuntimeService +import org.springframework.beans.factory.ObjectProvider import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.autoconfigure.web.servlet.MultipartProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.annotation.Order @@ -57,6 +63,9 @@ class PublicTaskAutoConfiguration { processLinkActivityService: ProcessLinkActivityService, htmlRenderService: HtmlRenderService, defaultFormSubmissionService: DefaultFormSubmissionService, + temporaryResourceStorageService: TemporaryResourceStorageService, + multipartProperties: ObjectProvider, + uploadProperties: ObjectProvider, @Value("\${valtimo.app.scheme:https}") scheme: String, @Value("\${valtimo.app.hostname:}") hostname: String, @Value("\${valtimo.url:}") valtimoUrl: String, @@ -64,25 +73,45 @@ class PublicTaskAutoConfiguration { val baseUrl = when { valtimoUrl.isNotBlank() -> valtimoUrl - hostname.isNotBlank() -> - // The hostname may already include a scheme (e.g. "https://example.org"); only - // prepend the configured scheme when it does not, to avoid producing "https://https://...". - if (hostname.contains("://")) hostname else "$scheme://$hostname" + // Only prepend the scheme when the hostname does not already carry one. + hostname.isNotBlank() -> if (hostname.contains("://")) hostname else "$scheme://$hostname" else -> error( "Neither 'valtimo.url' nor 'valtimo.app.hostname' is configured for the public task URL", ) } + warnWhenAnyFileTypeIsAccepted(uploadProperties) return PublicTaskService( publicTaskRepository = publicTaskRepository, runtimeService = runtimeService, processLinkActivityService = processLinkActivityService, htmlRenderService = htmlRenderService, defaultFormSubmissionService = defaultFormSubmissionService, + temporaryResourceStorageService = temporaryResourceStorageService, baseUrl = baseUrl, + applicationMaxFileSizeInBytes = getSpringServletMultipartMaxFileSize(multipartProperties), ) } + /** `spring.servlet.multipart.max-file-size` */ + private fun getSpringServletMultipartMaxFileSize(multipartProperties: ObjectProvider): Long? = + multipartProperties + .getIfAvailable() + ?.maxFileSize + ?.toBytes() + ?.takeIf { it > 0 } + + private fun warnWhenAnyFileTypeIsAccepted(uploadProperties: ObjectProvider) { + if (uploadProperties.getIfAvailable()?.acceptedMimeTypes.isNullOrEmpty()) { + logger.warn { + "Valtimo accepts every file type, because 'valtimo.upload.accepted-mime-types' is not set. The " + + "public task upload endpoint is open by design, so set it, or set the accepted mime types of " + + "the Create Public Task process links that have upload fields, to the types the process " + + "actually needs." + } + } + } + @Bean @ConditionalOnMissingBean(PublicTaskResource::class) fun publicTaskResource(publicTaskService: PublicTaskService): PublicTaskResource = @@ -97,9 +126,15 @@ class PublicTaskAutoConfiguration { fun publicTaskPluginFactory( pluginService: PluginService, publicTaskService: PublicTaskService, + valueResolverService: ValueResolverService, ): PublicTaskPluginFactory = PublicTaskPluginFactory( pluginService = pluginService, publicTaskService = publicTaskService, + valueResolverService = valueResolverService, ) + + companion object { + private val logger = KotlinLogging.logger {} + } } diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskAttachment.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskAttachment.kt new file mode 100644 index 0000000..ebee69a --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskAttachment.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2015-2024 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimoplugins.publictask.domain + +// The value one uploaded file takes in the submission. Valtimo's UploadField reads `/data/resourceId`. +data class PublicTaskAttachment( + val originalName: String, + val name: String, + val size: Long, + val type: String, + val data: PublicTaskAttachmentData, + val storage: String = STORAGE_PROVIDER_NAME, +) { + companion object { + // The name the page registers its Form.io storage provider under. + const val STORAGE_PROVIDER_NAME = "publicTask" + } +} + +data class PublicTaskAttachmentData( + val resourceId: String, +) diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskAttachmentLimits.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskAttachmentLimits.kt new file mode 100644 index 0000000..7114226 --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskAttachmentLimits.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2015-2024 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimoplugins.publictask.domain + +// What one public task's upload endpoint accepts, fixed when it was created. A field can only narrow it. +data class PublicTaskAttachmentLimits( + val maxAttachments: Int = DEFAULT_MAX_ATTACHMENTS, + val maxSizeInBytes: Long = DEFAULT_MAX_SIZE_IN_BYTES, + // Empty leaves the types to 'valtimo.upload.accepted-mime-types' and to the field's file pattern. + val acceptedMimeTypes: List = emptyList(), +) { + fun accepts(mimeType: String): Boolean = + acceptedMimeTypes.isEmpty() || acceptedMimeTypes.contains(mimeType.lowercase()) + + companion object { + const val DEFAULT_MAX_ATTACHMENTS = 10 + + const val DEFAULT_MAX_SIZE_IN_BYTES = 10L * 1024 * 1024 + + /** Absent or negative falls back to the default, not to "unbounded". Zero is a limit and is kept. */ + fun of( + maxAttachments: Int?, + maxSizeInBytes: Long?, + acceptedMimeTypes: String?, + ): PublicTaskAttachmentLimits = + PublicTaskAttachmentLimits( + maxAttachments = maxAttachments?.takeIf { it >= 0 } ?: DEFAULT_MAX_ATTACHMENTS, + maxSizeInBytes = maxSizeInBytes?.takeIf { it >= 0 } ?: DEFAULT_MAX_SIZE_IN_BYTES, + acceptedMimeTypes = parseMimeTypes(acceptedMimeTypes), + ) + + /** Reads the comma separated list the process link screen collects. Lower case: case is not meaningful. */ + fun parseMimeTypes(acceptedMimeTypes: String?): List = + acceptedMimeTypes + ?.split(',') + ?.map { it.trim().lowercase() } + ?.filter { it.isNotEmpty() } + ?: emptyList() + } +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskData.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskData.kt index 7687da8..0b02507 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskData.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskData.kt @@ -26,6 +26,8 @@ data class PublicTaskData( val assigneeCandidateContactData: String, val taskExpirationDate: String, var isCompletedByPublicTask: Boolean, + val attachmentLimits: PublicTaskAttachmentLimits, + val documentMetadata: PublicTaskDocumentMetadata, ) { companion object { fun from( @@ -33,6 +35,8 @@ data class PublicTaskData( processBusinessKey: String, assigneeCandidateContactData: String, timeToLive: String?, + attachmentLimits: PublicTaskAttachmentLimits, + documentMetadata: PublicTaskDocumentMetadata, ): PublicTaskData = PublicTaskData( publicTaskId = UUID.randomUUID(), @@ -41,6 +45,8 @@ data class PublicTaskData( assigneeCandidateContactData = assigneeCandidateContactData, taskExpirationDate = LocalDate.now().plusDays(timeToLive?.toLong() ?: 28L).toString(), isCompletedByPublicTask = false, + attachmentLimits = attachmentLimits, + documentMetadata = documentMetadata, ) } } diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskDocumentMetadata.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskDocumentMetadata.kt new file mode 100644 index 0000000..f034787 --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskDocumentMetadata.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2015-2024 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimoplugins.publictask.domain + +import com.fasterxml.jackson.core.type.TypeReference +import com.ritense.resource.domain.MetadataType +import com.ritense.valtimo.contract.json.MapperSingleton +import io.github.oshai.kotlinlogging.KotlinLogging + +// Written on every file of one public task; the Documenten API cannot default informatieobjecttype or titel. +data class PublicTaskDocumentMetadata( + val fields: Map = emptyMap(), +) { + fun toJson(): String = if (fields.isEmpty()) "" else MapperSingleton.get().writeValueAsString(fields) + + companion object { + // Owned by storage and this plugin: setting `user` or `documentId` would turn the submission check off. + private val RESERVED_KEYS: Set = MetadataType.entries.map { it.key }.toSet() + + private val logger = KotlinLogging.logger {} + + private val MAP_TYPE = object : TypeReference>() {} + + /** Reads what the process link configured. Empty means "not set"; a reserved key is dropped with a warning. */ + fun of(fields: Map?): PublicTaskDocumentMetadata { + if (fields.isNullOrEmpty()) { + return PublicTaskDocumentMetadata() + } + val configured = + buildMap { + fields.forEach { (key, value) -> + val name = key.trim() + if (name.isNotEmpty() && !value.isNullOrBlank()) { + put(name, value) + } + } + } + val reserved = configured.keys.filter { it in RESERVED_KEYS } + if (reserved.isNotEmpty()) { + logger.warn { + "Ignoring document metadata ${reserved.sorted()} of a public task process link: those keys " + + "are set by the plugin itself and cannot be configured" + } + } + return PublicTaskDocumentMetadata(configured.filterKeys { it !in RESERVED_KEYS }) + } + + fun fromJson(json: String?): PublicTaskDocumentMetadata { + if (json.isNullOrBlank()) { + return PublicTaskDocumentMetadata() + } + return try { + PublicTaskDocumentMetadata(MapperSingleton.get().readValue(json, MAP_TYPE)) + } catch (e: Exception) { + // The upload goes ahead: the applicant could not act on this error anyway. + logger.warn(e) { "Could not read the document metadata of a public task, so none is applied" } + PublicTaskDocumentMetadata() + } + } + } +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskEntity.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskEntity.kt index 1f88bab..cac1da1 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskEntity.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/domain/PublicTaskEntity.kt @@ -38,4 +38,24 @@ data class PublicTaskEntity( val taskExpirationDate: String = "", @field:Column(name = "is_completed_by_public_task") val isCompletedByPublicTask: Boolean = false, -) + @field:Column(name = "attachment_count") + val attachmentCount: Int = 0, + @field:Column(name = "max_attachments") + val maxAttachments: Int = PublicTaskAttachmentLimits.DEFAULT_MAX_ATTACHMENTS, + @field:Column(name = "max_attachment_size_in_bytes") + val maxAttachmentSizeInBytes: Long = PublicTaskAttachmentLimits.DEFAULT_MAX_SIZE_IN_BYTES, + @field:Column(name = "accepted_mime_types") + val acceptedMimeTypes: String = "", + // JSON, because the values are free text a delimiter would not survive. + @field:Column(name = "document_metadata") + val documentMetadataJson: String = "", +) { + fun attachmentLimits(): PublicTaskAttachmentLimits = + PublicTaskAttachmentLimits( + maxAttachments = maxAttachments, + maxSizeInBytes = maxAttachmentSizeInBytes, + acceptedMimeTypes = PublicTaskAttachmentLimits.parseMimeTypes(acceptedMimeTypes), + ) + + fun documentMetadata(): PublicTaskDocumentMetadata = PublicTaskDocumentMetadata.fromJson(documentMetadataJson) +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/plugin/PublicTaskPlugin.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/plugin/PublicTaskPlugin.kt index db30751..f06f5fb 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/plugin/PublicTaskPlugin.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/plugin/PublicTaskPlugin.kt @@ -20,8 +20,12 @@ import com.ritense.plugin.annotation.Plugin import com.ritense.plugin.annotation.PluginAction import com.ritense.plugin.annotation.PluginActionProperty import com.ritense.processlink.domain.ActivityTypeWithEventName +import com.ritense.valtimoplugins.publictask.domain.PublicTaskAttachmentLimits import com.ritense.valtimoplugins.publictask.domain.PublicTaskData +import com.ritense.valtimoplugins.publictask.domain.PublicTaskDocumentMetadata import com.ritense.valtimoplugins.publictask.service.PublicTaskService +import com.ritense.valueresolver.ValueResolverService +import io.github.oshai.kotlinlogging.KotlinLogging import org.operaton.bpm.engine.delegate.DelegateExecution import java.util.UUID @@ -32,7 +36,9 @@ import java.util.UUID ) class PublicTaskPlugin( private val publicTaskService: PublicTaskService, + private val valueResolverService: ValueResolverService, ) { + /** Creates a public task and hands its URL to the process. The attachment limits bound its upload endpoint. */ @PluginAction( key = "create-public-task", title = "Create Public Task", @@ -43,6 +49,10 @@ class PublicTaskPlugin( execution: DelegateExecution, @PluginActionProperty pvAssigneeCandidateContactData: String, @PluginActionProperty timeToLive: String?, + @PluginActionProperty maxAttachments: Int?, + @PluginActionProperty maxAttachmentSizeInBytes: Long?, + @PluginActionProperty acceptedMimeTypes: String?, + @PluginActionProperty documentMetadata: Map?, ) { val publicTaskData = PublicTaskData.from( @@ -50,6 +60,13 @@ class PublicTaskPlugin( processBusinessKey = execution.processBusinessKey, assigneeCandidateContactData = pvAssigneeCandidateContactData, timeToLive = timeToLive, + attachmentLimits = + PublicTaskAttachmentLimits.of( + maxAttachments = maxAttachments, + maxSizeInBytes = maxAttachmentSizeInBytes, + acceptedMimeTypes = acceptedMimeTypes, + ), + documentMetadata = PublicTaskDocumentMetadata.of(resolved(execution, documentMetadata)), ) publicTaskService.createAndSendPublicTaskUrl( @@ -57,4 +74,47 @@ class PublicTaskPlugin( publicTaskData = publicTaskData, ) } + + /** + * Valtimo resolves a placeholder only in an action property that is a string, and this one is a map, so + * its values are resolved here instead. + */ + private fun resolved( + execution: DelegateExecution, + documentMetadata: Map?, + ): Map? = + documentMetadata?.mapValues { (key, value) -> + if (value.isNullOrBlank()) value else resolvedValue(execution, key, value) + } + + /** + * One value at a time, so that a value which resolves to nothing costs only its own key. + */ + private fun resolvedValue( + execution: DelegateExecution, + key: String, + value: String, + ): String? { + val resolved = + try { + valueResolverService + .resolveValues(execution.processInstanceId, execution, listOf(value))[value] + } catch (e: Exception) { + // Everything before a ':' reads as a prefix, and an unknown one is refused rather than + // resolved - which is what ordinary text with a colon in it looks like from here. + logger.debug(e) { "Kept document metadata '$key' of a public task as it was written" } + return value + } + if (resolved == null) { + logger.warn { + "Document metadata '$key' of a public task resolves to nothing, so the files of this task " + + "are filed without it. Check that '$value' exists at the moment this task runs." + } + } + return resolved?.toString() + } + + companion object { + private val logger = KotlinLogging.logger {} + } } diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/plugin/PublicTaskPluginFactory.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/plugin/PublicTaskPluginFactory.kt index f0882c8..8e193cb 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/plugin/PublicTaskPluginFactory.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/plugin/PublicTaskPluginFactory.kt @@ -19,10 +19,12 @@ package com.ritense.valtimoplugins.publictask.plugin import com.ritense.plugin.PluginFactory import com.ritense.plugin.service.PluginService import com.ritense.valtimoplugins.publictask.service.PublicTaskService +import com.ritense.valueresolver.ValueResolverService class PublicTaskPluginFactory( pluginService: PluginService, private val publicTaskService: PublicTaskService, + private val valueResolverService: ValueResolverService, ) : PluginFactory(pluginService) { - override fun create(): PublicTaskPlugin = PublicTaskPlugin(publicTaskService) + override fun create(): PublicTaskPlugin = PublicTaskPlugin(publicTaskService, valueResolverService) } diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/repository/PublicTaskRepository.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/repository/PublicTaskRepository.kt index fb85733..e19e352 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/repository/PublicTaskRepository.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/repository/PublicTaskRepository.kt @@ -18,6 +18,41 @@ package com.ritense.valtimoplugins.publictask.repository import com.ritense.valtimoplugins.publictask.domain.PublicTaskEntity import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.transaction.annotation.Propagation.REQUIRES_NEW +import org.springframework.transaction.annotation.Transactional import java.util.UUID -interface PublicTaskRepository : JpaRepository +interface PublicTaskRepository : JpaRepository { + /** Claims a slot in its own transaction, so simultaneous uploads cannot both take the last free one. */ + @Transactional(propagation = REQUIRES_NEW) + @Modifying + @Query( + """ + update PublicTaskEntity publicTask + set publicTask.attachmentCount = publicTask.attachmentCount + 1 + where publicTask.publicTaskId = :publicTaskId + and publicTask.attachmentCount < publicTask.maxAttachments + """, + ) + fun reserveAttachmentSlot( + @Param("publicTaskId") publicTaskId: UUID, + ): Int + + /** Gives back a slot claimed by [reserveAttachmentSlot] for an upload that was refused. */ + @Transactional(propagation = REQUIRES_NEW) + @Modifying + @Query( + """ + update PublicTaskEntity publicTask + set publicTask.attachmentCount = publicTask.attachmentCount - 1 + where publicTask.publicTaskId = :publicTaskId + and publicTask.attachmentCount > 0 + """, + ) + fun releaseAttachmentSlot( + @Param("publicTaskId") publicTaskId: UUID, + ): Int +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/FormIoFilePattern.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/FormIoFilePattern.kt new file mode 100644 index 0000000..fad23a6 --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/FormIoFilePattern.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2015-2024 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimoplugins.publictask.service + +// Form.io's "File Pattern" on the server. The three deviations from it are listed in documentation/plugin.md. +object FormIoFilePattern { + fun matches( + pattern: String, + fileName: String, + mimeType: String?, + ): Boolean { + val glob = globToRegex(pattern) + val candidates = listOfNotNull(mimeType, fileName) + val included = glob.regexp.isEmpty() || candidates.anyMatches(glob.regexp) + return included && glob.excludes.none { candidates.anyMatches(it) } + } + + // Compiled once per pattern rather than once per candidate. + private fun List.anyMatches(regexp: String): Boolean = + Regex(regexp, RegexOption.IGNORE_CASE).let { regex -> any(regex::containsMatchIn) } + + private fun globToRegex(pattern: String): Glob { + val glob = pattern.replace(WHITESPACE, "") + if (glob.length > 2 && glob.startsWith('/') && glob.endsWith('/')) { + return Glob(regexp = glob.substring(1, glob.length - 1)) + } + + val parts = glob.split(',') + if (parts.size > 1) { + val (included, excluded) = parts.map { globToRegex(it) }.partition { it.regexp.isNotEmpty() } + return Glob( + regexp = included.joinToString("|") { "(${it.regexp})" }, + excludes = excluded.flatMap { it.excludes }, + ) + } + + if (glob.startsWith('!')) { + return Glob(regexp = "", excludes = listOf(globToRegex(glob.substring(1)).regexp)) + } + + // ".pdf" is shorthand for "*.pdf": an extension, not a whole name. + val expanded = if (glob.startsWith('.')) "*$glob" else glob + val quoted = expanded.replace(SPECIAL_CHARACTERS) { "\\" + it.value } + return Glob(regexp = ("^" + quoted + "$").replace("""\*""", ".*").replace("""\?""", ".")) + } + + private data class Glob( + val regexp: String, + val excludes: List = emptyList(), + ) + + private val WHITESPACE = Regex("""\s""") + + // What Form.io escapes before turning `*` and `?` into their regular expression equivalents. + private val SPECIAL_CHARACTERS = Regex("""[.\\+*?\[^\]$(){}=!<>|:\-]""") +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskFormRewriter.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskFormRewriter.kt new file mode 100644 index 0000000..697eff0 --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskFormRewriter.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2015-2024 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimoplugins.publictask.service + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.form.domain.FormIoFormDefinition.PROPERTY_KEY +import com.ritense.form.domain.FormIoFormDefinition.TYPE_KEY +import com.ritense.valtimoplugins.publictask.domain.PublicTaskAttachment.Companion.STORAGE_PROVIDER_NAME + +// Valtimo's upload fields render as nothing outside its UI, so they become Form.io `file` components. +object PublicTaskFormRewriter { + // Handed to the storage provider untouched: how the page names the field a file was chosen in. + const val COMPONENT_KEY_OPTION = "componentKey" + + /** A copy of [formDefinition] in which every upload component uploads to the public task endpoint. */ + fun rewriteUploadComponents(formDefinition: JsonNode): JsonNode = + formDefinition.deepCopy().also { copy -> + PublicTaskUploadField.forEachUploadComponent(copy) { it.rewriteToPublicTaskUpload() } + } + + private fun ObjectNode.rewriteToPublicTaskUpload() { + val componentKey = path(PROPERTY_KEY).textValue() + put(TYPE_KEY, "file") + put("storage", STORAGE_PROVIDER_NAME) + // Nothing is served back, so a link or preview would fetch a file that does not exist. + put("uploadOnly", true) + put("image", false) + // Would otherwise let the definition point the browser at an upload target of its own choosing. + remove("url") + // Replaced, not extended: the definition must not be able to put anything of its own in the request. + putObject("options").put(COMPONENT_KEY_OPTION, componentKey) + } +} diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskService.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskService.kt index 1024409..4ed8aef 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskService.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskService.kt @@ -16,22 +16,32 @@ package com.ritense.valtimoplugins.publictask.service +import com.fasterxml.jackson.core.JsonPointer import com.fasterxml.jackson.databind.JsonNode import com.ritense.authorization.AuthorizationContext.Companion.runWithoutAuthorization import com.ritense.form.domain.FormTaskOpenResultProperties import com.ritense.form.service.impl.DefaultFormSubmissionService import com.ritense.processlink.exception.ProcessLinkNotFoundException import com.ritense.processlink.service.ProcessLinkActivityService +import com.ritense.resource.domain.MetadataType +import com.ritense.resource.service.TemporaryResourceStorageService +import com.ritense.valtimo.contract.upload.MimeTypeDeniedException +import com.ritense.valtimo.contract.upload.VirusDetectedException +import com.ritense.valtimoplugins.publictask.domain.PublicTaskAttachment +import com.ritense.valtimoplugins.publictask.domain.PublicTaskAttachmentData +import com.ritense.valtimoplugins.publictask.domain.PublicTaskAttachmentLimits import com.ritense.valtimoplugins.publictask.domain.PublicTaskData import com.ritense.valtimoplugins.publictask.domain.PublicTaskEntity import com.ritense.valtimoplugins.publictask.htmlrenderer.service.HtmlRenderService import com.ritense.valtimoplugins.publictask.repository.PublicTaskRepository import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.tika.Tika import org.operaton.bpm.engine.RuntimeService import org.operaton.bpm.engine.delegate.DelegateExecution import org.operaton.bpm.engine.delegate.DelegateTask import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.web.multipart.MultipartFile import org.springframework.web.util.UriComponentsBuilder import java.time.LocalDate import java.time.format.DateTimeParseException @@ -43,8 +53,14 @@ class PublicTaskService( private val processLinkActivityService: ProcessLinkActivityService, private val htmlRenderService: HtmlRenderService, private val defaultFormSubmissionService: DefaultFormSubmissionService, + private val temporaryResourceStorageService: TemporaryResourceStorageService, private val baseUrl: String, + // 'spring.servlet.multipart.max-file-size': the ceiling on every size limit here. Positive, or absent. + private val applicationMaxFileSizeInBytes: Long?, ) { + // Built once: constructing a Tika scans the classpath for every parser and detector it can find. + private val tika = Tika() + fun startNotifyAssigneeCandidateProcess(task: DelegateTask) { runtimeService .createMessageCorrelation(NOTIFY_ASSIGNEE_PROCESS_MESSAGE_NAME) @@ -76,12 +92,15 @@ class PublicTaskService( runWithoutAuthorization { processLinkActivityService.openTask(userTaskId).properties as FormTaskOpenResultProperties } + val form = PublicTaskFormRewriter.rewriteUploadComponents(operatonTaskData.prefilledForm) htmlRenderService.generatePublicTaskHtml( fileName = PUBLIC_TASK_FILE_NAME, variables = mapOf( - "form_io_form" to operatonTaskData.prefilledForm.toPrettyString(), + "form_io_form" to form.toPrettyString(), "public_task_url" to publicTaskUrl(publicTaskId), + "public_task_attachment_url" to publicTaskUrl(publicTaskId, ATTACHMENT_PATH_SEGMENT), + "max_attachment_size_in_bytes" to maxAttachmentSizeFor(publicTaskEntity.attachmentLimits()), ), ) } catch (e: Exception) { @@ -91,12 +110,216 @@ class PublicTaskService( return ResponseEntity(formHtml, HttpStatus.OK) } + /** Stores an attachment and returns the value it takes in the submission. [componentKey] is optional. */ + fun storePublicTaskAttachment( + publicTaskId: UUID, + componentKey: String?, + file: MultipartFile, + ): ResponseEntity { + val publicTaskEntity = findAvailablePublicTask(publicTaskId) ?: return TASK_NOT_AVAILABLE_ERROR + + if (file.isEmpty) { + return ResponseEntity.badRequest().body("The uploaded file is empty") + } + val limits = publicTaskEntity.attachmentLimits() + // Checked before the limit of the field, which has to resolve the form first. + val maxSizeForTask = maxAttachmentSizeFor(limits) + if (file.size > maxSizeForTask) { + return tooLargeResponse(maxSizeForTask) + } + + val fileName = sanitizedFileName(file.originalFilename) + // Detected once, and only where a limit or a refusal asks for it. + val mimeType = lazy { detectedMimeType(file, fileName) } + + return withAttachmentSlot(publicTaskId, limits.maxAttachments) { + val refusal = + try { + attachmentRefusal( + publicTaskEntity = publicTaskEntity, + componentKey = componentKey, + fileName = fileName, + fileSize = file.size, + limits = limits, + maxSizeForTask = maxSizeForTask, + mimeType = mimeType, + ) + } catch (e: Exception) { + logger.warn(e) { + "Could not establish what user task ${publicTaskEntity.userTaskId} accepts, so an " + + "attachment for it was refused" + } + return@withAttachmentSlot taskNotAvailableResponse(e) + } + if (refusal != null) { + return@withAttachmentSlot refusal + } + + val resourceId = + try { + temporaryResourceStorageService.store( + inputStream = file.inputStream, + metadata = + buildMap { + // From the process link. Put first, so the keys below win. + putAll(publicTaskEntity.documentMetadata().fields) + put(MetadataType.FILE_NAME.key, fileName) + file.contentType?.let { put(MetadataType.CONTENT_TYPE.key, it) } + // There is no logged in user, so the origin is recorded instead. + put(MetadataType.USER.key, PUBLIC_TASK_UPLOAD_USER) + put(MetadataType.DOCUMENT_ID.key, publicTaskEntity.processBusinessKey) + }, + ) + } catch (e: Exception) { + return@withAttachmentSlot attachmentRefusedResponse(e, mimeType, publicTaskEntity.userTaskId) + } + + logger.debug { "Stored an attachment for user task ${publicTaskEntity.userTaskId}" } + + ResponseEntity.ok( + PublicTaskAttachment( + originalName = fileName, + name = fileName, + size = file.size, + type = file.contentType ?: "", + data = PublicTaskAttachmentData(resourceId = resourceId), + ), + ) + } + } + + /** Claims a slot up front so simultaneous uploads cannot pass the limit; releases it on any non-2xx. */ + private fun withAttachmentSlot( + publicTaskId: UUID, + maxAttachments: Int, + body: () -> ResponseEntity, + ): ResponseEntity { + if (publicTaskRepository.reserveAttachmentSlot(publicTaskId) == 0) { + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body("No more than $maxAttachments files can be added to this task") + } + return body().also { + if (!it.statusCode.is2xxSuccessful) { + publicTaskRepository.releaseAttachmentSlot(publicTaskId) + } + } + } + + /** The response for a file the task or its upload field does not accept, or `null` when it does. */ + private fun attachmentRefusal( + publicTaskEntity: PublicTaskEntity, + componentKey: String?, + fileName: String, + fileSize: Long, + limits: PublicTaskAttachmentLimits, + maxSizeForTask: Long, + mimeType: Lazy, + ): ResponseEntity? { + val fieldLimits = + if (componentKey == null) { + null + } else { + uploadFieldLimits(publicTaskEntity, componentKey) + ?: return refusedResponse( + publicTaskEntity, + HttpStatus.BAD_REQUEST, + "This file was not chosen in a field of this form", + "an attachment for upload field '$componentKey', which this form does not have", + ) + } + + val maxSize = minOf(maxSizeForTask, fieldLimits?.maxSizeInBytes ?: Long.MAX_VALUE) + if (fileSize > maxSize) { + return tooLargeResponse(maxSize) + } + + val filePattern = fieldLimits?.filePattern + if (limits.acceptedMimeTypes.isEmpty() && filePattern == null) { + // Nothing narrows the type; 'valtimo.upload.accepted-mime-types' still applies in storage. + return null + } + + val detected = mimeType.value + if (!limits.accepts(detected)) { + return refusedResponse( + publicTaskEntity, + HttpStatus.UNSUPPORTED_MEDIA_TYPE, + unacceptedTypeMessage(detected), + "an attachment detected as '$detected', which this task does not accept", + ) + } + if (filePattern != null && !FormIoFilePattern.matches(filePattern, fileName, detected)) { + return refusedResponse( + publicTaskEntity, + HttpStatus.UNSUPPORTED_MEDIA_TYPE, + unacceptedTypeMessage(detected), + "an attachment detected as '$detected' against file pattern '$filePattern'", + ) + } + return null + } + + /** Names the detected type, never what the task accepts: that is configuration and this endpoint is open. */ + private fun unacceptedTypeMessage(mimeType: String): String = + if (MIME_TYPE.matches(mimeType)) { + "A file of type $mimeType cannot be uploaded" + } else { + UNACCEPTED_TYPE_MESSAGE + } + + private fun uploadFieldLimits( + publicTaskEntity: PublicTaskEntity, + componentKey: String, + ): PublicTaskUploadFieldLimits? { + val taskData = + runWithoutAuthorization { + processLinkActivityService.openTask(publicTaskEntity.userTaskId).properties + as FormTaskOpenResultProperties + } + return PublicTaskUploadField.limitsOf(taskData.prefilledForm, componentKey) + } + + /** The type the content is, not the type the request claims. [fileName] is only a fallback hint. */ + private fun detectedMimeType( + file: MultipartFile, + fileName: String, + ): String = + file.inputStream + .use { tika.detect(it, fileName) } + .substringBefore(';') + .trim() + .lowercase() + + private fun maxAttachmentSizeFor(limits: PublicTaskAttachmentLimits): Long = + minOf(limits.maxSizeInBytes, applicationMaxFileSizeInBytes ?: Long.MAX_VALUE) + + private fun tooLargeResponse(maxSizeInBytes: Long): ResponseEntity = + ResponseEntity + .status(HttpStatus.PAYLOAD_TOO_LARGE) + .body("This file is larger than the maximum of $maxSizeInBytes bytes") + + /** [message] is for the applicant, [reason] only for the log: it describes configuration. */ + private fun refusedResponse( + publicTaskEntity: PublicTaskEntity, + status: HttpStatus, + message: String, + reason: String, + ): ResponseEntity { + logger.info { "Refused $reason for user task ${publicTaskEntity.userTaskId}" } + return ResponseEntity.status(status).body(message) + } + fun completeUserTaskWithPublicTaskSubmission( publicTaskId: UUID, submission: JsonNode, ): ResponseEntity { val publicTaskEntity = findAvailablePublicTask(publicTaskId) ?: return TASK_NOT_AVAILABLE_ERROR + if (!attachmentsWereUploadedForThisTask(submission, publicTaskEntity)) { + return ATTACHMENT_NOT_AVAILABLE_ERROR + } + val operatonTask = try { runWithoutAuthorization { @@ -130,11 +353,73 @@ class PublicTaskService( return ResponseEntity("Your response has been submitted", HttpStatus.OK) } - /** - * Returns the public task only while it is still available: not completed through the public form and not past - * its expiration date. Both showing the form and submitting it go through this, so that the two cannot drift - * apart and leave the form - which contains case data - retrievable for longer than the task itself lives. - */ + /** Whether every resource the submission points at is one this case uploaded. UUIDs are Valtimo resources. */ + private fun attachmentsWereUploadedForThisTask( + submission: JsonNode, + publicTaskEntity: PublicTaskEntity, + ): Boolean { + val (atResourceId, atId) = submittedResourceIds(submission) + return atResourceId.all { resourceId -> + isExistingValtimoResourceId(resourceId) || + isUsableAttachmentOfThisCase(resourceId, publicTaskEntity) + } && + atId.none { resourceId -> + isThisCasesAttachment(resourceId, publicTaskEntity) == false + } + } + + /** The ids UploadField acts on, kept apart because the two are held to different standards. */ + private fun submittedResourceIds(submission: JsonNode): SubmittedResourceIds { + // Sets: the same file chosen in two fields is one id to check, not two. + val atResourceId = mutableSetOf() + val atId = mutableSetOf() + + fun collectFrom(node: JsonNode) { + if (node.isObject) { + node.at(RESOURCE_ID_POINTER).takeIf { it.isTextual }?.let { atResourceId.add(it.textValue()) } + node.at(ID_POINTER).takeIf { it.isTextual }?.let { atId.add(it.textValue()) } + } + node.forEach { collectFrom(it) } + } + collectFrom(submission) + + return SubmittedResourceIds(atResourceId = atResourceId, atId = atId) + } + + private fun isExistingValtimoResourceId(resourceId: String): Boolean = + runCatching { UUID.fromString(resourceId) }.isSuccess + + /** `true` when this case uploaded the file, `false` when someone else did, `null` when storage has none. */ + private fun isThisCasesAttachment( + resourceId: String, + publicTaskEntity: PublicTaskEntity, + ): Boolean? = + try { + val metadata = temporaryResourceStorageService.getResourceMetadata(resourceId) + metadata[MetadataType.USER.key] == PUBLIC_TASK_UPLOAD_USER && + metadata[MetadataType.DOCUMENT_ID.key] == publicTaskEntity.processBusinessKey + } catch (e: Exception) { + null + } + + /** Whether this case uploaded the file. A reference storage does not know is refused too. */ + private fun isUsableAttachmentOfThisCase( + resourceId: String, + publicTaskEntity: PublicTaskEntity, + ): Boolean { + val isThisCases = isThisCasesAttachment(resourceId, publicTaskEntity) + if (isThisCases == null) { + logger.info { "Refused a submission for user task ${publicTaskEntity.userTaskId}: unusable file reference" } + } + return isThisCases == true + } + + private data class SubmittedResourceIds( + val atResourceId: Set, + val atId: Set, + ) + + /** The public task only while it is available: not completed, and not past its expiration date. */ private fun findAvailablePublicTask(publicTaskId: UUID): PublicTaskEntity? = publicTaskRepository .findById(publicTaskId) @@ -155,15 +440,63 @@ class PublicTaskService( return !expirationDate.isBefore(LocalDate.now()) } - private fun publicTaskUrl(publicTaskId: UUID): String = + private fun publicTaskUrl( + publicTaskId: UUID, + vararg pathSegments: String, + ): String = UriComponentsBuilder .fromUriString(baseUrl.removeSuffix("/")) .path(PUBLIC_TASK_URL) - .pathSegment(publicTaskId.toString()) + .pathSegment(publicTaskId.toString(), *pathSegments) .build() .toUriString() + /** The name arrives from an anonymous browser and ends up in case metadata, so it is stripped. */ + private fun sanitizedFileName(originalFilename: String?): String { + val name = + originalFilename + ?.substringAfterLast('/') + ?.substringAfterLast('\\') + ?.filterNot { it.isInvisible() } + ?.trim() + ?.take(MAX_FILE_NAME_LENGTH) + .orEmpty() + return name.ifBlank { FALLBACK_FILE_NAME } + } + + // Format category too: U+202E alone makes 'factuur\u202Efdp.exe' read as 'factuurexe.pdf'. + private fun Char.isInvisible(): Boolean = isISOControl() || category == CharCategory.FORMAT + + private fun attachmentRefusedResponse( + e: Exception, + mimeType: Lazy, + userTaskId: UUID, + ): ResponseEntity = + when (e) { + is MimeTypeDeniedException -> { + logger.info { "Refused an attachment of an unaccepted type for user task $userTaskId" } + ResponseEntity + .status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + // Refused by 'valtimo.upload.accepted-mime-types' rather than here; named all the same. + .body(unacceptedTypeMessage(mimeType.value)) + } + + is VirusDetectedException -> { + logger.warn { "Refused an infected attachment for user task $userTaskId" } + ResponseEntity + .status(HttpStatus.UNPROCESSABLE_ENTITY) + .body("This file did not pass the virus scan") + } + + else -> { + logger.error(e) { "Could not store an attachment for user task $userTaskId" } + SERVER_SIDE_ERROR + } + } + private fun savePublicTaskEntity(publicTaskData: PublicTaskData) { + val limits = publicTaskData.attachmentLimits + warnWhenTheContainerAllowsLess(limits.maxSizeInBytes) publicTaskRepository .save( PublicTaskEntity( @@ -173,14 +506,30 @@ class PublicTaskService( assigneeCandidateContactData = publicTaskData.assigneeCandidateContactData, taskExpirationDate = publicTaskData.taskExpirationDate, isCompletedByPublicTask = publicTaskData.isCompletedByPublicTask, + maxAttachments = limits.maxAttachments, + maxAttachmentSizeInBytes = limits.maxSizeInBytes, + acceptedMimeTypes = limits.acceptedMimeTypes.joinToString(","), + documentMetadataJson = publicTaskData.documentMetadata.toJson(), ), ).also { - // Only the user task is logged: the public task id is what grants access to the form, so it must - // not end up in log files. + // Not the public task id: it grants access to the form, so it stays out of logs. logger.debug { "Saved public task entity for user task ${it.userTaskId}" } } } + private fun warnWhenTheContainerAllowsLess(requestedMaxSizeInBytes: Long) { + val containerLimit = applicationMaxFileSizeInBytes ?: return + if (containerLimit >= requestedMaxSizeInBytes) { + return + } + logger.warn { + "Attachments of this public task are capped at $containerLimit bytes instead of the " + + "$requestedMaxSizeInBytes its process link asks for, because " + + "'spring.servlet.multipart.max-file-size' does not allow more. Raise it (and " + + "'spring.servlet.multipart.max-request-size' with it) to accept larger attachments." + } + } + private fun taskNotAvailableResponse(e: Exception): ResponseEntity = when (e) { is ProcessLinkNotFoundException, is NullPointerException -> TASK_NOT_AVAILABLE_ERROR @@ -192,10 +541,28 @@ class PublicTaskService( private const val PUBLIC_TASK_URL = "/api/v1/public-task" + const val ATTACHMENT_PATH_SEGMENT = "attachment" + private const val NOTIFY_ASSIGNEE_PROCESS_MESSAGE_NAME = "startNotifyAssigneeMessage" private const val PUBLIC_TASK_FILE_NAME = "public_task_html" + private const val PUBLIC_TASK_UPLOAD_USER = "public-task" + + private const val FALLBACK_FILE_NAME = "attachment" + + private const val MAX_FILE_NAME_LENGTH = 200 + + // Used when the type could not be established, or is not one that can be shown as it is. + private const val UNACCEPTED_TYPE_MESSAGE = "This type of file cannot be uploaded" + + private val MIME_TYPE = Regex("[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}") + + // Both pointers UploadField reads, in its own order of preference. + private val ID_POINTER: JsonPointer = JsonPointer.compile("/id") + + private val RESOURCE_ID_POINTER: JsonPointer = JsonPointer.compile("/data/resourceId") + private val SERVER_SIDE_ERROR = ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) @@ -205,5 +572,10 @@ class PublicTaskService( ResponseEntity .status(HttpStatus.NOT_FOUND) .body("This task does not exist (anymore) or is already completed.") + + private val ATTACHMENT_NOT_AVAILABLE_ERROR = + ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body("This submission refers to a file that was not uploaded for this task") } } diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskUploadField.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskUploadField.kt new file mode 100644 index 0000000..177737a --- /dev/null +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskUploadField.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2015-2024 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimoplugins.publictask.service + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.form.domain.FormIoFormDefinition.PROPERTY_KEY +import com.ritense.form.domain.submission.formfield.UploadField +import io.github.oshai.kotlinlogging.KotlinLogging + +/** The upload components of a form definition, and what each accepts. [UploadField] decides what counts as one. */ +object PublicTaskUploadField { + private const val FILE_MAX_SIZE = "fileMaxSize" + + private const val FILE_PATTERN = "filePattern" + + /** What the field with [componentKey] accepts, or `null` when the definition has no such upload component. */ + fun limitsOf( + formDefinition: JsonNode, + componentKey: String, + ): PublicTaskUploadFieldLimits? { + val component = find(formDefinition, componentKey) ?: return null + return PublicTaskUploadFieldLimits( + maxSizeInBytes = component.fileMaxSizeInBytes(componentKey), + filePattern = component.narrowingFilePattern(), + ) + } + + /** Runs [action] on every upload component in [node]. The whole tree is walked, layout types included. */ + fun forEachUploadComponent( + node: JsonNode, + action: (ObjectNode) -> Unit, + ) { + node.forEach { child -> + if (child.isUploadComponent()) { + action(child as ObjectNode) + } + forEachUploadComponent(child, action) + } + } + + /** The first upload component under [componentKey]; stops there rather than walking the rest. */ + private fun find( + node: JsonNode, + componentKey: String, + ): ObjectNode? { + for (child in node) { + if (child.isUploadComponent() && child.path(PROPERTY_KEY).textValue() == componentKey) { + return child as ObjectNode + } + find(child, componentKey)?.let { return it } + } + return null + } + + private fun JsonNode.isUploadComponent(): Boolean = this is ObjectNode && UploadField.isUploadComponent(this) + + /** Form.io's "Maximum File Size", as bytes. Unreadable means no limit of its own, not a limit of zero. */ + private fun JsonNode.fileMaxSizeInBytes(componentKey: String): Long? { + val configured = path(FILE_MAX_SIZE).textValue()?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val parsed = parseFileSize(configured) + if (parsed == null) { + logger.warn { + "Ignoring the maximum file size '$configured' of upload field '$componentKey': it is not a " + + "size Form.io can read either. Use a value like '10MB'." + } + } + return parsed + } + + /** Form.io's "File Pattern", or `null` when it narrows nothing. The builder writes `*` into every field. */ + private fun JsonNode.narrowingFilePattern(): String? = + path(FILE_PATTERN) + .textValue() + ?.trim() + ?.takeIf { it.isNotEmpty() && it != "*" } + + /** A number with an optional `KB`, `MB` or `GB` suffix. Mirrors Form.io's own `translateScalars`. */ + internal fun parseFileSize(value: String): Long? { + val size = value.trim().lowercase() + val (number, multiplier) = + when { + size.endsWith("kb") -> size.dropLast(2) to 1024L + size.endsWith("mb") -> size.dropLast(2) to 1024L * 1024 + size.endsWith("gb") -> size.dropLast(2) to 1024L * 1024 * 1024 + size.endsWith("b") -> size.dropLast(1) to 1L + else -> size to 1L + } + val amount = number.trim().toDoubleOrNull()?.takeIf { it >= 0 } ?: return null + return (amount * multiplier).toLong() + } + + private val logger = KotlinLogging.logger {} +} + +/** What one upload field accepts on top of the task's own limits. Both are optional. */ +data class PublicTaskUploadFieldLimits( + val maxSizeInBytes: Long?, + val filePattern: String?, +) diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/web/rest/PublicTaskResource.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/web/rest/PublicTaskResource.kt index ccf5a1b..e1a14c1 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/web/rest/PublicTaskResource.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/publictask/web/rest/PublicTaskResource.kt @@ -18,6 +18,7 @@ package com.ritense.valtimoplugins.publictask.web.rest import com.fasterxml.jackson.databind.JsonNode import com.ritense.valtimoplugins.publictask.service.PublicTaskService +import org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -26,6 +27,7 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile import java.util.UUID @RestController @@ -44,11 +46,15 @@ class PublicTaskResource( @RequestBody submission: JsonNode, ): ResponseEntity = publicTaskService.completeUserTaskWithPublicTaskSubmission(publicTaskId, submission) - /** - * Kept so that public task links which were sent out before the id moved into the path keep working. New links - * use the path form, because an id in the query string ends up in Referer headers, proxy logs and browser - * history. - */ + /** Receives a file chosen in the public form. `componentKey` names the upload field and is optional. */ + @PostMapping("/{publicTaskId}/attachment", consumes = [MULTIPART_FORM_DATA_VALUE]) + fun uploadAttachment( + @PathVariable publicTaskId: UUID, + @RequestParam("file") file: MultipartFile, + @RequestParam(value = "componentKey", required = false) componentKey: String?, + ): ResponseEntity = publicTaskService.storePublicTaskAttachment(publicTaskId, componentKey, file) + + /** Kept so links sent out before the id moved into the path keep working. */ @Deprecated("Use GET /api/v1/public-task/{publicTaskId}") @GetMapping(params = ["publicTaskId"]) fun sendPublicTaskHtmlForQueryParameter( diff --git a/backend/plugin/src/main/resources/config/liquibase/changelog/20240229-add-public-task-plugin-entity.xml b/backend/plugin/src/main/resources/config/liquibase/changelog/20240229-add-public-task-plugin-entity.xml index 78d401a..66e6485 100644 --- a/backend/plugin/src/main/resources/config/liquibase/changelog/20240229-add-public-task-plugin-entity.xml +++ b/backend/plugin/src/main/resources/config/liquibase/changelog/20240229-add-public-task-plugin-entity.xml @@ -48,4 +48,37 @@ newDataType="VARCHAR(255)"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/plugin/src/main/resources/config/template/public_task_html.ftl b/backend/plugin/src/main/resources/config/template/public_task_html.ftl index 9faa94c..6455e5e 100644 --- a/backend/plugin/src/main/resources/config/template/public_task_html.ftl +++ b/backend/plugin/src/main/resources/config/template/public_task_html.ftl @@ -45,6 +45,140 @@ .formio-component button:hover { background-color: #0056b3; } + + /* Form.io's own wording is collapsed rather than removed, so it stays there for a screen reader. */ + .formio-component-file .fileSelector { + display: flex; + align-items: center; + justify-content: center; + min-height: 140px; + padding: 24px; + border: 2px dashed #adb5bd; + border-radius: 8px; + background-color: #fbfcfc; + cursor: pointer; + font-size: 0; + } + + .formio-component-file .fileSelector::after { + content: 'Click here to upload a file'; + font-size: 1rem; + color: #495057; + } + + .formio-component-file .fileSelector:hover, + .formio-component-file .fileSelector.drop-target { + border-color: #007bff; + background-color: #f0f7ff; + } + + /* Form.io emits Bootstrap 4's .sr-only, which Bootstrap 5 renamed and no longer styles. */ + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + /* Form.io draws both remove buttons as an empty Font Awesome ; this page loads no icon font. */ + .formio-component-file [ref="removeLink"], + .formio-component-file [ref="fileStatusRemove"] { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 4px; + cursor: pointer; + color: #b02a37; + font-style: normal; + } + + .formio-component-file [ref="removeLink"]::before, + .formio-component-file [ref="fileStatusRemove"]::before { + content: '\2715'; + font-size: 1rem; + line-height: 1; + } + + .formio-component-file [ref="removeLink"]:hover, + .formio-component-file [ref="fileStatusRemove"]:hover, + .formio-component-file [ref="removeLink"]:focus, + .formio-component-file [ref="fileStatusRemove"]:focus { + background-color: #f8d7da; + } + + /* A refused file was not added, so everything but the reason and its dismiss button is collapsed. */ + .formio-component-file .file:has(.alert-danger) { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + padding: 10px 12px; + border-radius: 8px; + background-color: #f8d7da; + color: #842029; + } + + .formio-component-file .file:has(.alert-danger) > .row { + margin: 0; + } + + /* The reason, which names the type the file turned out to be. */ + .formio-component-file .file:has(.alert-danger) > .row:last-child { + order: 1; + flex: 1 1 auto; + min-width: 0; + } + + /* The line Form.io puts the name and size on. Only the remove button inside it is kept. */ + .formio-component-file .file:has(.alert-danger) > .row:first-child { + order: 2; + flex: 0 0 auto; + } + + .formio-component-file .file:has(.alert-danger) .fileName { + flex: 0 0 auto; + width: auto; + max-width: none; + padding: 0; + font-size: 0; + } + + .formio-component-file .file:has(.alert-danger) .fileSize { + display: none; + } + + .formio-component-file .file:has(.alert-danger) .col-sm-12 { + padding: 0; + } + + .formio-component-file .file:has(.alert-danger) .alert { + margin: 0; + padding: 0; + border: 0; + background: none; + color: inherit; + } + + .formio-component-file .file:has(.alert-danger) [ref="fileStatusRemove"] { + color: #842029; + } + + .formio-component-file .file:has(.alert-danger) [ref="fileStatusRemove"]:hover, + .formio-component-file .file:has(.alert-danger) [ref="fileStatusRemove"]:focus { + background-color: #f1aeb5; + } + + /* Until a file has been added the list is a column heading and nothing else. */ + .formio-component-file .list-group:not(:has(.list-group-item:not(.list-group-header))) { + display: none; + } @@ -58,12 +192,126 @@ therefore keeps the JSON intact while removing every sequence (" - +<#-- Pinned and hash-checked: the styling and handlers below are written against this version's markup. --> + ") } + @Test + fun `the page uploads to the public task, and not to a target the form definition chooses`() { + val html = render() + + assertThat(html).contains("Formio.Providers.addProvider('storage', 'publicTask', publicTaskStorage)") + assertThat(html).contains( + "const attachmentUrl = 'https://valtimo.example.org/api/v1/public-task/$PUBLIC_TASK_ID/attachment'", + ) + assertThat(html).contains("request.open('POST', attachmentUrl)") + } + + @Test + fun `the attachment url is escaped for the javascript string literal it is placed in`() { + val html = + render( + publicTaskAttachmentUrl = + """https://valtimo.example.org/api/v1/public-task/1/attachment' + alert(1) + '""", + ) + + assertThat(html).doesNotContain("""attachment' + alert(1) + '""") + assertThat(html).contains("""attachment\' + alert(1) + \'""") + } + + @Test + fun `the upload field is one large target carrying one message`() { + val html = render() + + assertThat(html).contains("content: 'Click here to upload a file'") + assertThat(html).contains("font-size: 0;") + assertThat(html).contains(".list-group:not(:has(.list-group-item:not(.list-group-header)))") + assertThat(html).contains("""browse.click()""") + assertThat(html).contains("""new DragEvent('drop', {dataTransfer: event.dataTransfer, bubbles: false})""") + } + + @Test + fun `a file that was added can be removed again`() { + val html = render() + + // Form.io's remove buttons are empty Font Awesome s, and this page loads no icon font. + assertThat(html).contains(""".formio-component-file [ref="removeLink"]""") + assertThat(html).contains(""".formio-component-file [ref="fileStatusRemove"]""") + assertThat(html).contains("""content: '\2715';""") + // A click on those buttons is Form.io's to handle, so it must not be forwarded to the browse link. + assertThat(html).contains("""const FILE_LIST = '.list-group, .file';""") + assertThat(html).contains("""event.target.closest(FILE_LIST)""") + } + + @Test + fun `a file that was refused is not shown as one that was added`() { + val html = render() + + // Form.io lists a refused file's name and size above the reason; only the reason is left. + assertThat(html).contains(".formio-component-file .file:has(.alert-danger) .fileSize") + assertThat(html).contains(".formio-component-file .file:has(.alert-danger) .fileName") + } + + @Test + fun `the form renderer is pinned, and checked against its hash`() { + val html = render() + + // The styling and the handlers in this page are written against the markup of this exact version. + assertThat(html).contains("https://cdn.form.io/formiojs/4.21.2/formio.full.min.js") + assertThat(html).contains("integrity=\"sha384-") + assertThat(html).doesNotContain("https://cdn.form.io/formiojs/formio.full.min.js") + } + + @Test + fun `text meant for a screen reader is not shown to everyone`() { + val html = render() + + // Bootstrap 5 renamed .sr-only; left unstyled, a failed upload states its message twice. + assertThat(html).contains(".sr-only {") + assertThat(html).contains("clip: rect(0, 0, 0, 0);") + } + + @Test + fun `the page tells the server which field a file was chosen in`() { + val html = render() + + assertThat(html).contains("const componentKey = options && options.componentKey;") + assertThat(html).contains("body.append('componentKey', componentKey)") + } + + @Test + fun `the maximum attachment size reaches the page as a number, not as a formatted one`() { + val html = render(maxAttachmentSizeInBytes = 10_485_760) + + // Freemarker would otherwise render this as "10,485,760", which is not valid JavaScript. + assertThat(html).contains("const maxAttachmentSizeInBytes = 10485760;") + } + private fun render( formIoForm: String = "{}", publicTaskUrl: String = "https://valtimo.example.org/api/v1/public-task/$PUBLIC_TASK_ID", + publicTaskAttachmentUrl: String = "$publicTaskUrl/attachment", + maxAttachmentSizeInBytes: Long = 10_485_760, ): String = htmlRenderService.generatePublicTaskHtml( fileName = "public_task_html", @@ -100,6 +190,8 @@ internal class PublicTaskHtmlTemplateTest : BaseTest() { mapOf( "form_io_form" to formIoForm, "public_task_url" to publicTaskUrl, + "public_task_attachment_url" to publicTaskAttachmentUrl, + "max_attachment_size_in_bytes" to maxAttachmentSizeInBytes, ), ) diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/plugin/PublicTaskPluginTest.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/plugin/PublicTaskPluginTest.kt new file mode 100644 index 0000000..ab34317 --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/plugin/PublicTaskPluginTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimoplugins.publictask.plugin + +import com.ritense.valtimoplugins.publictask.BaseTest +import com.ritense.valtimoplugins.publictask.domain.PublicTaskData +import com.ritense.valtimoplugins.publictask.service.PublicTaskService +import com.ritense.valueresolver.ValueResolverService +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.entry +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.operaton.bpm.engine.delegate.DelegateExecution +import java.util.UUID + +internal class PublicTaskPluginTest : BaseTest() { + private lateinit var publicTaskService: PublicTaskService + private lateinit var valueResolverService: ValueResolverService + private lateinit var execution: DelegateExecution + private lateinit var publicTaskPlugin: PublicTaskPlugin + + @BeforeEach + fun setUp() { + publicTaskService = mock() + valueResolverService = mock() + execution = mock() + publicTaskPlugin = PublicTaskPlugin(publicTaskService, valueResolverService) + + whenever(execution.getVariableLocal("userTaskId")).thenReturn(USER_TASK_ID.toString()) + whenever(execution.processBusinessKey).thenReturn(BUSINESS_KEY) + whenever(execution.processInstanceId).thenReturn(PROCESS_INSTANCE_ID) + } + + @Test + fun `a metadata value that points at case data is resolved before it is kept with the task`() { + givenResolved("doc:aanvraag.onderwerp" to "Vergunning dakkapel") + + createPublicTask(mapOf("titel" to "doc:aanvraag.onderwerp")) + + assertThat(savedMetadata()).containsExactly(entry("titel", "Vergunning dakkapel")) + } + + @Test + fun `a metadata value that points at a process variable is resolved too`() { + givenResolved("pv:informatieobjecttypeUrl" to "https://catalogi.example.org/informatieobjecttypen/1") + + createPublicTask(mapOf("informatieobjecttype" to "pv:informatieobjecttypeUrl")) + + assertThat(savedMetadata()) + .containsExactly(entry("informatieobjecttype", "https://catalogi.example.org/informatieobjecttypen/1")) + } + + @Test + fun `a fixed metadata value is kept as it is`() { + givenResolved("Bijlage bij de aanvraag" to "Bijlage bij de aanvraag") + + createPublicTask(mapOf("titel" to "Bijlage bij de aanvraag")) + + assertThat(savedMetadata()).containsExactly(entry("titel", "Bijlage bij de aanvraag")) + } + + @Test + fun `a value that resolves to something other than text is filed as text`() { + givenResolved("pv:volgnummer" to 42L) + + createPublicTask(mapOf("beschrijving" to "pv:volgnummer")) + + assertThat(savedMetadata()).containsExactly(entry("beschrijving", "42")) + } + + @Test + fun `ordinary text that happens to contain a colon is kept rather than refused`() { + // Everything before a ':' reads as a prefix to the resolver, and an unknown one throws. + whenever(valueResolverService.resolveValues(any(), any(), any())) + .thenThrow(RuntimeException("No resolver factory found for value prefix Bijlage")) + + createPublicTask(mapOf("titel" to "Bijlage: factuur 2024")) + + assertThat(savedMetadata()).containsExactly(entry("titel", "Bijlage: factuur 2024")) + } + + @Test + fun `a value that resolves to nothing is left out rather than filed as a placeholder`() { + whenever(valueResolverService.resolveValues(any(), any(), any())).thenReturn(emptyMap()) + + createPublicTask(mapOf("titel" to "pv:bestaatNiet")) + + assertThat(savedMetadata()).isEmpty() + } + + @Test + fun `a process link without metadata resolves nothing`() { + createPublicTask(null) + + assertThat(savedMetadata()).isEmpty() + } + + private fun givenResolved(vararg resolved: Pair) { + whenever(valueResolverService.resolveValues(any(), any(), any())) + .thenAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + val requested = invocation.arguments[2] as Collection + resolved.toMap().filterKeys { it in requested } + } + } + + private fun createPublicTask(documentMetadata: Map?) { + publicTaskPlugin.createPublicTask( + execution = execution, + pvAssigneeCandidateContactData = "pv:assigneeCandidate", + timeToLive = null, + maxAttachments = null, + maxAttachmentSizeInBytes = null, + acceptedMimeTypes = null, + documentMetadata = documentMetadata, + ) + } + + private fun savedMetadata(): Map { + val captor = argumentCaptor() + verify(publicTaskService).createAndSendPublicTaskUrl(any(), captor.capture()) + return captor.firstValue.documentMetadata.fields + } + + private companion object { + private val USER_TASK_ID: UUID = UUID.fromString("11111111-1111-1111-1111-111111111111") + + private const val BUSINESS_KEY = "22222222-2222-2222-2222-222222222222" + + private const val PROCESS_INSTANCE_ID = "33333333-3333-3333-3333-333333333333" + } +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/repository/PublicTaskRepositoryIT.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/repository/PublicTaskRepositoryIT.kt new file mode 100644 index 0000000..d0b900e --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/repository/PublicTaskRepositoryIT.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimoplugins.publictask.repository + +import com.ritense.valtimoplugins.publictask.BaseIntegrationTest +import com.ritense.valtimoplugins.publictask.domain.PublicTaskAttachmentLimits +import com.ritense.valtimoplugins.publictask.domain.PublicTaskEntity +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import java.time.LocalDate +import java.util.UUID + +internal class PublicTaskRepositoryIT : BaseIntegrationTest() { + @Autowired + lateinit var publicTaskRepository: PublicTaskRepository + + @Test + fun `a public task starts with all of its attachment slots free`() { + val publicTaskId = givenAPublicTask() + + assertThat(publicTaskRepository.findById(publicTaskId).get().attachmentCount).isZero() + } + + @Test + fun `claiming a slot is refused once the limit is reached, and the count stops there`() { + val publicTaskId = givenAPublicTask(maxAttachments = 2) + + assertThat(publicTaskRepository.reserveAttachmentSlot(publicTaskId)).isEqualTo(1) + assertThat(publicTaskRepository.reserveAttachmentSlot(publicTaskId)).isEqualTo(1) + assertThat(publicTaskRepository.reserveAttachmentSlot(publicTaskId)).isEqualTo(0) + + assertThat(publicTaskRepository.findById(publicTaskId).get().attachmentCount).isEqualTo(2) + } + + @Test + fun `the limit a slot is claimed against is the one stored on the task itself`() { + val oneSlot = givenAPublicTask(maxAttachments = 1) + val threeSlots = givenAPublicTask(maxAttachments = 3) + + assertThat(publicTaskRepository.reserveAttachmentSlot(oneSlot)).isEqualTo(1) + assertThat(publicTaskRepository.reserveAttachmentSlot(oneSlot)).isEqualTo(0) + + assertThat(publicTaskRepository.reserveAttachmentSlot(threeSlots)).isEqualTo(1) + assertThat(publicTaskRepository.reserveAttachmentSlot(threeSlots)).isEqualTo(1) + } + + @Test + fun `a task with no slots at all cannot be uploaded to`() { + val publicTaskId = givenAPublicTask(maxAttachments = 0) + + assertThat(publicTaskRepository.reserveAttachmentSlot(publicTaskId)).isEqualTo(0) + } + + @Test + fun `a released slot can be claimed again`() { + val publicTaskId = givenAPublicTask(maxAttachments = 1) + publicTaskRepository.reserveAttachmentSlot(publicTaskId) + + assertThat(publicTaskRepository.reserveAttachmentSlot(publicTaskId)).isEqualTo(0) + assertThat(publicTaskRepository.releaseAttachmentSlot(publicTaskId)).isEqualTo(1) + assertThat(publicTaskRepository.reserveAttachmentSlot(publicTaskId)).isEqualTo(1) + + assertThat(publicTaskRepository.findById(publicTaskId).get().attachmentCount).isEqualTo(1) + } + + @Test + fun `the count is never pushed below zero by a release`() { + val publicTaskId = givenAPublicTask() + + assertThat(publicTaskRepository.releaseAttachmentSlot(publicTaskId)).isEqualTo(0) + assertThat(publicTaskRepository.findById(publicTaskId).get().attachmentCount).isZero() + } + + @Test + fun `claiming a slot of a task that does not exist reports that there was none`() { + assertThat(publicTaskRepository.reserveAttachmentSlot(UUID.randomUUID())).isEqualTo(0) + } + + @Test + fun `the slots of one public task are not spent by another`() { + val publicTaskId = givenAPublicTask() + val otherPublicTaskId = givenAPublicTask() + + publicTaskRepository.reserveAttachmentSlot(publicTaskId) + + assertThat(publicTaskRepository.findById(publicTaskId).get().attachmentCount).isEqualTo(1) + assertThat(publicTaskRepository.findById(otherPublicTaskId).get().attachmentCount).isZero() + } + + @Test + fun `the limits a task was created with are what it is read back with`() { + val publicTaskId = givenAPublicTask(maxAttachments = 4) + + val publicTask = publicTaskRepository.findById(publicTaskId).get() + + assertThat(publicTask.attachmentLimits()).isEqualTo( + PublicTaskAttachmentLimits( + maxAttachments = 4, + maxSizeInBytes = 2048, + acceptedMimeTypes = listOf("application/pdf", "image/jpeg"), + ), + ) + } + + private fun givenAPublicTask(maxAttachments: Int = PublicTaskAttachmentLimits.DEFAULT_MAX_ATTACHMENTS): UUID = + publicTaskRepository + .save( + PublicTaskEntity( + publicTaskId = UUID.randomUUID(), + userTaskId = UUID.randomUUID(), + processBusinessKey = UUID.randomUUID().toString(), + assigneeCandidateContactData = "citizen@example.org", + taskExpirationDate = LocalDate.now().plusDays(1).toString(), + isCompletedByPublicTask = false, + maxAttachments = maxAttachments, + maxAttachmentSizeInBytes = 2048, + acceptedMimeTypes = "application/pdf,image/jpeg", + ), + ).publicTaskId +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/FormIoFilePatternTest.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/FormIoFilePatternTest.kt new file mode 100644 index 0000000..f35dce6 --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/FormIoFilePatternTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimoplugins.publictask.service + +import com.ritense.valtimoplugins.publictask.BaseTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource + +internal class FormIoFilePatternTest : BaseTest() { + @ParameterizedTest + @CsvSource( + // pattern, file name, detected type, accepted + "application/pdf, factuur.pdf, application/pdf, true", + "application/pdf, factuur.pdf, application/zip, false", + "image/*, foto.jpg, image/jpeg, true", + "image/*, foto.jpg, application/zip, false", + ".pdf, factuur.pdf, application/pdf, true", + ".pdf, factuur.txt, text/plain, false", + "'application/pdf,image/jpeg', foto.jpg, image/jpeg, true", + "'application/pdf,image/jpeg', tekening.png, image/png, false", + "APPLICATION/PDF, factuur.pdf, application/pdf, true", + "*, factuur.pdf, application/pdf, true", + ) + fun `a pattern accepts what Form io accepts`( + pattern: String, + fileName: String, + mimeType: String, + accepted: Boolean, + ) { + assertThat(FormIoFilePattern.matches(pattern, fileName, mimeType)).isEqualTo(accepted) + } + + @ParameterizedTest + @CsvSource( + "'!.exe', factuur.pdf, application/pdf, true", + "'!.exe', virus.exe, application/x-dosexec, false", + "'!application/x-dosexec', virus.pdf, application/x-dosexec, false", + ) + fun `a pattern can exclude instead of include`( + pattern: String, + fileName: String, + mimeType: String, + accepted: Boolean, + ) { + assertThat(FormIoFilePattern.matches(pattern, fileName, mimeType)).isEqualTo(accepted) + } + + @Test + fun `a pattern that both includes and excludes asks for both, whichever order it is written in`() { + // Form.io's own answer here depends on the order the parts are in; this is the stricter reading. + listOf("application/pdf,!.exe", "!.exe,application/pdf").forEach { pattern -> + assertThat(FormIoFilePattern.matches(pattern, "factuur.pdf", "application/pdf")).isTrue() + assertThat(FormIoFilePattern.matches(pattern, "factuur.zip", "application/zip")).isFalse() + assertThat(FormIoFilePattern.matches(pattern, "factuur.exe", "application/x-dosexec")).isFalse() + } + } + + @Test + fun `a pattern is matched against the type the content was detected as, not the one that was claimed`() { + // A pattern naming the type refuses a renamed file; one naming an extension speaks about the name. + assertThat(FormIoFilePattern.matches("application/pdf", "factuur.pdf", "application/x-dosexec")).isFalse() + assertThat(FormIoFilePattern.matches(".pdf", "factuur.pdf", "application/x-dosexec")).isTrue() + } + + @Test + fun `a pattern still decides when the content could not be typed`() { + assertThat(FormIoFilePattern.matches(".pdf", "factuur.pdf", null)).isTrue() + assertThat(FormIoFilePattern.matches("application/pdf", "factuur.pdf", null)).isFalse() + } + + @Test + fun `a pattern written as a regular expression is used as one`() { + assertThat(FormIoFilePattern.matches("/^image\\/(jpeg|png)$/", "foto.jpg", "image/jpeg")).isTrue() + assertThat(FormIoFilePattern.matches("/^image\\/(jpeg|png)$/", "foto.gif", "image/gif")).isFalse() + } + + @Test + fun `a pattern matches the whole value rather than a part of it`() { + assertThat(FormIoFilePattern.matches("application/pdf", "factuur.pdfx", "application/pdfx")).isFalse() + assertThat(FormIoFilePattern.matches(".pdf", "factuur.pdf.exe", "application/x-dosexec")).isFalse() + } +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskFormRewriterTest.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskFormRewriterTest.kt new file mode 100644 index 0000000..10236aa --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskFormRewriterTest.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimoplugins.publictask.service + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.form.domain.submission.formfield.UploadField +import com.ritense.valtimoplugins.publictask.BaseTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +internal class PublicTaskFormRewriterTest : BaseTest() { + @ParameterizedTest + @ValueSource(strings = ["file", "valtimo-file", "documenten-api-file"]) + fun `every upload component Valtimo recognises is made renderable by plain Form io`(componentType: String) { + val form = formWith("""{"key": "bijlagen", "type": "$componentType", "input": true}""") + + val rewritten = PublicTaskFormRewriter.rewriteUploadComponents(form) + + val component = rewritten.at("/components/0") + assertThat(component.get("type").textValue()).isEqualTo("file") + assertThat(component.get("storage").textValue()).isEqualTo("publicTask") + assertThat(component.get("key").textValue()).isEqualTo("bijlagen") + } + + @ParameterizedTest + @ValueSource(strings = ["file", "valtimo-file", "documenten-api-file"]) + fun `a rewritten component is still an upload component to Valtimo, so its file is not stranded`( + componentType: String, + ) { + val form = formWith("""{"key": "bijlagen", "type": "$componentType", "input": true}""") + + val rewritten = PublicTaskFormRewriter.rewriteUploadComponents(form) + + // If the submission does not come back as something UploadField picks up, the file is stranded. + val component = rewritten.at("/components/0") as ObjectNode + assertThat(UploadField.isUploadComponent(component)).isTrue() + } + + @Test + fun `an upload component nested in a layout component is rewritten too`() { + val form = + formWith( + """ + { + "key": "kolommen", + "type": "columns", + "columns": [ + { "components": [ { "key": "bijlage", "type": "valtimo-file", "input": true } ] } + ] + } + """.trimIndent(), + ) + + val rewritten = PublicTaskFormRewriter.rewriteUploadComponents(form) + + assertThat(rewritten.at("/components/0/columns/0/components/0/type").textValue()).isEqualTo("file") + assertThat(rewritten.at("/components/0/columns/0/components/0/storage").textValue()).isEqualTo("publicTask") + } + + @Test + fun `an upload component cannot keep an upload target of its own`() { + val form = + formWith( + """ + { + "key": "bijlage", + "type": "file", + "input": true, + "storage": "url", + "url": "https://attacker.example.org/collect", + "options": "{\"withCredentials\": true}" + } + """.trimIndent(), + ) + + val rewritten = PublicTaskFormRewriter.rewriteUploadComponents(form) + + val component = rewritten.at("/components/0") + assertThat(component.get("storage").textValue()).isEqualTo("publicTask") + assertThat(component.has("url")).isFalse() + assertThat(component.get("options")).isEqualTo(objectMapper.readTree("""{"componentKey": "bijlage"}""")) + } + + @Test + fun `an upload component tells the page which field a file was chosen in`() { + val form = formWith("""{"key": "bijlage", "type": "valtimo-file", "input": true}""") + + val rewritten = PublicTaskFormRewriter.rewriteUploadComponents(form) + + assertThat(rewritten.at("/components/0/options/componentKey").textValue()).isEqualTo("bijlage") + } + + @Test + fun `the limits an upload field asks for survive the rewrite, because the server reads them back`() { + val form = + formWith( + """ + { + "key": "bijlage", + "type": "valtimo-file", + "input": true, + "fileMaxSize": "2MB", + "filePattern": "application/pdf" + } + """.trimIndent(), + ) + + val rewritten = PublicTaskFormRewriter.rewriteUploadComponents(form) + + val component = rewritten.at("/components/0") + assertThat(component.get("fileMaxSize").textValue()).isEqualTo("2MB") + assertThat(component.get("filePattern").textValue()).isEqualTo("application/pdf") + } + + @Test + fun `an uploaded file is listed rather than offered back for download`() { + val form = formWith("""{"key": "bijlage", "type": "file", "input": true, "image": true}""") + + val rewritten = PublicTaskFormRewriter.rewriteUploadComponents(form) + + assertThat(rewritten.at("/components/0/uploadOnly").booleanValue()).isTrue() + assertThat(rewritten.at("/components/0/image").booleanValue()).isFalse() + } + + @Test + fun `components that are not uploads are left exactly as they were`() { + val form = + formWith( + """{"key": "naam", "type": "textfield", "input": true}""", + """{"key": "toelichting", "type": "textarea", "input": true}""", + """{"key": "uitleg", "type": "content", "input": false, "html": "

file

"}""", + ) + + val rewritten = PublicTaskFormRewriter.rewriteUploadComponents(form) + + assertThat(rewritten).isEqualTo(form) + } + + @Test + fun `the original form definition is not modified`() { + val form = formWith("""{"key": "bijlage", "type": "valtimo-file", "input": true}""") + val before = form.deepCopy() + + PublicTaskFormRewriter.rewriteUploadComponents(form) + + assertThat(form).isEqualTo(before) + } +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskServiceTest.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskServiceTest.kt index dd22536..827c8e8 100644 --- a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskServiceTest.kt +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskServiceTest.kt @@ -17,20 +17,36 @@ package com.ritense.valtimoplugins.publictask.service import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.ritense.form.domain.FormTaskOpenResultProperties import com.ritense.form.service.impl.DefaultFormSubmissionService +import com.ritense.form.web.rest.dto.FormSubmissionResult +import com.ritense.processlink.exception.ProcessLinkNotFoundException import com.ritense.processlink.service.ProcessLinkActivityService +import com.ritense.processlink.web.rest.dto.ProcessLinkActivityResult +import com.ritense.resource.domain.MetadataType +import com.ritense.resource.service.TemporaryResourceStorageService +import com.ritense.valtimo.contract.upload.MimeTypeDeniedException +import com.ritense.valtimo.contract.upload.VirusDetectedException import com.ritense.valtimoplugins.publictask.BaseTest +import com.ritense.valtimoplugins.publictask.domain.PublicTaskAttachment +import com.ritense.valtimoplugins.publictask.domain.PublicTaskDocumentMetadata import com.ritense.valtimoplugins.publictask.domain.PublicTaskEntity import com.ritense.valtimoplugins.publictask.htmlrenderer.service.HtmlRenderService import com.ritense.valtimoplugins.publictask.repository.PublicTaskRepository import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.operaton.bpm.engine.RuntimeService import org.springframework.http.HttpStatus +import org.springframework.mock.web.MockMultipartFile import java.time.LocalDate import java.util.Optional import java.util.UUID @@ -41,15 +57,20 @@ internal class PublicTaskServiceTest : BaseTest() { private val processLinkActivityService: ProcessLinkActivityService = mock() private val htmlRenderService: HtmlRenderService = mock() private val defaultFormSubmissionService: DefaultFormSubmissionService = mock() + private val temporaryResourceStorageService: TemporaryResourceStorageService = mock() - private val publicTaskService = + private val publicTaskService = publicTaskService() + + private fun publicTaskService(containerMaxFileSizeInBytes: Long? = null) = PublicTaskService( publicTaskRepository = publicTaskRepository, runtimeService = runtimeService, processLinkActivityService = processLinkActivityService, htmlRenderService = htmlRenderService, defaultFormSubmissionService = defaultFormSubmissionService, + temporaryResourceStorageService = temporaryResourceStorageService, baseUrl = "https://valtimo.example.org", + applicationMaxFileSizeInBytes = containerMaxFileSizeInBytes, ) @Test @@ -86,8 +107,7 @@ internal class PublicTaskServiceTest : BaseTest() { fun `a task that is open and expires today is still rendered`() { givenPublicTask(expirationDate = LocalDate.now()) - // The task is looked up in the process engine, which proves the availability check did not short-circuit. - // The mocked engine returns no task, so the response itself is still "not available". + // The lookup proves the availability check did not short-circuit. publicTaskService.createPublicTaskHtml(PUBLIC_TASK_ID) verify(processLinkActivityService).openTask(USER_TASK_ID) @@ -123,19 +143,683 @@ internal class PublicTaskServiceTest : BaseTest() { verifyNoInteractions(processLinkActivityService, htmlRenderService) } + @Test + fun `an attachment is stored in temporary resource storage and reported back for the submission`() { + givenPublicTask() + givenAFreeAttachmentSlot() + whenever(temporaryResourceStorageService.store(any(), any())).thenReturn(RESOURCE_ID) + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + val attachment = response.body as PublicTaskAttachment + // Where UploadField reads the resource id from when the form is submitted. + assertThat(attachment.data.resourceId).isEqualTo(RESOURCE_ID) + assertThat(attachment.originalName).isEqualTo("bijlage.pdf") + assertThat(attachment.size).isEqualTo(CONTENT.size.toLong()) + } + + @Test + fun `an attachment is filed against the case document, without a user, so its origin stays visible`() { + givenPublicTask() + givenAFreeAttachmentSlot() + whenever(temporaryResourceStorageService.store(any(), any())).thenReturn(RESOURCE_ID) + + publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + val metadata = argumentCaptor>() + verify(temporaryResourceStorageService).store(any(), metadata.capture()) + assertThat(metadata.firstValue) + .containsEntry(MetadataType.FILE_NAME.key, "bijlage.pdf") + .containsEntry(MetadataType.CONTENT_TYPE.key, "application/pdf") + .containsEntry(MetadataType.USER.key, "public-task") + .containsEntry(MetadataType.DOCUMENT_ID.key, BUSINESS_KEY) + } + + @Test + fun `an attachment is filed with the metadata its process link configured`() { + // Without this the Documenten API has no informatieobjecttype or titel to file the document under. + givenPublicTask( + documentMetadata = + mapOf( + "informatieobjecttype" to "https://catalogi.example.org/informatieobjecttypen/1", + "titel" to "Bijlage bij de aanvraag", + ), + ) + givenAFreeAttachmentSlot() + whenever(temporaryResourceStorageService.store(any(), any())).thenReturn(RESOURCE_ID) + + publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + assertThat(storedMetadata()) + .containsEntry("informatieobjecttype", "https://catalogi.example.org/informatieobjecttypen/1") + .containsEntry("titel", "Bijlage bij de aanvraag") + } + + @Test + fun `configured metadata cannot take over the keys a submission is checked against`() { + // Stored unfiltered, as an older or hand-written row would be: the keys below still have to win. + givenPublicTask( + documentMetadataJson = + """ + {"user":"a-logged-in-user","documentId":"a-different-case","filename":"iets-anders.pdf"} + """.trimIndent(), + ) + givenAFreeAttachmentSlot() + whenever(temporaryResourceStorageService.store(any(), any())).thenReturn(RESOURCE_ID) + + publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + assertThat(storedMetadata()) + .containsEntry(MetadataType.USER.key, "public-task") + .containsEntry(MetadataType.DOCUMENT_ID.key, BUSINESS_KEY) + .containsEntry(MetadataType.FILE_NAME.key, "bijlage.pdf") + } + + @Test + fun `a filename cannot carry a path into the metadata`() { + assertThat(fileNameStoredFor("../../etc/passwd.pdf")).isEqualTo("passwd.pdf") + assertThat(fileNameStoredFor("""..\..\Windows\System32\config.pdf""")).isEqualTo("config.pdf") + } + + @Test + fun `a filename cannot carry control characters into the metadata`() { + assertThat(fileNameStoredFor("bij\u0000lage\u001B[31m.pdf")).isEqualTo("bijlage[31m.pdf") + } + + @Test + fun `a filename cannot disguise what the file is with a direction override`() { + // U+202E is not a control character, so it survives an isISOControl filter. + assertThat(fileNameStoredFor("factuur‮fdp.exe")).isEqualTo("factuurfdp.exe") + assertThat(fileNameStoredFor("bij​lage⁦.pdf")).isEqualTo("bijlage.pdf") + } + + @Test + fun `a filename that is left with nothing usable falls back to a placeholder`() { + assertThat(fileNameStoredFor(" ")).isEqualTo("attachment") + assertThat(fileNameStoredFor(null)).isEqualTo("attachment") + } + + @Test + fun `an overlong filename is cut back`() { + assertThat(fileNameStoredFor("a".repeat(500))).hasSize(200) + } + + @Test + fun `uploading an attachment is refused once the task has expired`() { + givenPublicTask(expirationDate = LocalDate.now().minusDays(1)) + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + verifyNoInteractions(temporaryResourceStorageService) + } + + @Test + fun `uploading an attachment is refused once the task has been completed`() { + givenPublicTask(completed = true) + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + verifyNoInteractions(temporaryResourceStorageService) + } + + @Test + fun `uploading an attachment to an unknown task is refused`() { + whenever(publicTaskRepository.findById(PUBLIC_TASK_ID)).thenReturn(Optional.empty()) + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + verifyNoInteractions(temporaryResourceStorageService) + } + + @Test + fun `an attachment over the size limit is refused before it is read`() { + givenPublicTask() + + val response = + publicTaskService.storePublicTaskAttachment( + PUBLIC_TASK_ID, + null, + aFile(content = ByteArray(MAX_ATTACHMENT_SIZE.toInt() + 1)), + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE) + verifyNoInteractions(temporaryResourceStorageService) + verify(publicTaskRepository, never()).reserveAttachmentSlot(any()) + } + + @Test + fun `the size limit of the task is the one its process link asked for`() { + givenPublicTask(maxAttachmentSizeInBytes = 8) + givenAFreeAttachmentSlot() + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile(content = ByteArray(9))) + + assertThat(response.statusCode).isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE) + assertThat(response.body?.toString()).contains("8 bytes") + } + + @Test + fun `a task cannot accept more than the servlet container will receive`() { + // A larger file never reaches this plugin, so a process link asking for more has to be capped. + givenPublicTask(maxAttachmentSizeInBytes = 1_000_000) + givenAFreeAttachmentSlot() + + val response = + publicTaskService(containerMaxFileSizeInBytes = 16) + .storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile(content = ByteArray(17))) + + assertThat(response.statusCode).isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE) + assertThat(response.body?.toString()).contains("16 bytes") + } + + @Test + fun `an attachment over the size limit of the field it was chosen in is refused`() { + givenPublicTask() + givenAFreeAttachmentSlot() + givenAFormWithUploadField(fileMaxSize = "16B") + + val response = + publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, COMPONENT_KEY, aFile(content = ByteArray(17))) + + assertThat(response.statusCode).isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE) + assertThat(response.body?.toString()).contains("16 bytes") + verifyNoInteractions(temporaryResourceStorageService) + verify(publicTaskRepository).releaseAttachmentSlot(PUBLIC_TASK_ID) + } + + @Test + fun `a field cannot accept more than the task it is in`() { + givenPublicTask(maxAttachmentSizeInBytes = 16) + givenAFreeAttachmentSlot() + givenAFormWithUploadField(fileMaxSize = "1GB") + + val response = + publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, COMPONENT_KEY, aFile(content = ByteArray(17))) + + assertThat(response.statusCode).isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE) + verifyNoInteractions(temporaryResourceStorageService) + } + + @Test + fun `an attachment of a type the field it was chosen in does not accept is refused`() { + givenPublicTask() + givenAFreeAttachmentSlot() + givenAFormWithUploadField(filePattern = "application/pdf") + + // Named like a pdf, but the content is plain text, which is what the pattern is held against. + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, COMPONENT_KEY, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + // The applicant is told what their file is; what the task accepts is configuration and stays back. + assertThat(response.body?.toString()).isEqualTo("A file of type text/plain cannot be uploaded") + verifyNoInteractions(temporaryResourceStorageService) + verify(publicTaskRepository).releaseAttachmentSlot(PUBLIC_TASK_ID) + } + + @Test + fun `an attachment of a type the field it was chosen in accepts is stored`() { + givenPublicTask() + givenAFreeAttachmentSlot() + givenAFormWithUploadField(filePattern = "text/plain") + whenever(temporaryResourceStorageService.store(any(), any())).thenReturn(RESOURCE_ID) + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, COMPONENT_KEY, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + } + + @Test + fun `an attachment of a type the task does not accept is refused`() { + givenPublicTask(acceptedMimeTypes = "application/pdf,image/jpeg") + givenAFreeAttachmentSlot() + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + assertThat(response.body?.toString()).isEqualTo("A file of type text/plain cannot be uploaded") + verifyNoInteractions(temporaryResourceStorageService) + verify(publicTaskRepository).releaseAttachmentSlot(PUBLIC_TASK_ID) + } + + @Test + fun `the type the task accepts is the one the content was detected as, not the one that was claimed`() { + givenPublicTask(acceptedMimeTypes = "application/pdf") + givenAFreeAttachmentSlot() + + // The request and the name say pdf; the bytes say otherwise, and the bytes are what counts. + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + verifyNoInteractions(temporaryResourceStorageService) + } + + @Test + fun `renaming an executable does not make it the type it was renamed to`() { + givenPublicTask(acceptedMimeTypes = "application/pdf") + givenAFreeAttachmentSlot() + + // The name is only a hint, and never one that can talk over the content. + val response = + publicTaskService.storePublicTaskAttachment( + PUBLIC_TASK_ID, + null, + aFile(fileName = "factuur.pdf", content = EXECUTABLE), + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + assertThat(response.body?.toString()).isEqualTo("A file of type application/x-msdownload cannot be uploaded") + verifyNoInteractions(temporaryResourceStorageService) + } + + @Test + fun `a type that only its name can tell apart is still the type it is`() { + // A CSV is plain text by content, so a task accepting 'text/csv' needs the name read as well. + givenPublicTask(acceptedMimeTypes = "text/csv") + givenAFreeAttachmentSlot() + whenever(temporaryResourceStorageService.store(any(), any())).thenReturn(RESOURCE_ID) + + val response = + publicTaskService.storePublicTaskAttachment( + PUBLIC_TASK_ID, + null, + aFile(fileName = "bezwaar.csv", content = "id,naam\n1,Ruben\n".toByteArray()), + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + } + + @Test + fun `an upload naming a field this form does not have is refused`() { + givenPublicTask() + givenAFreeAttachmentSlot() + givenAFormWithUploadField(componentKey = "bijlagen") + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, "iets-anders", aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.BAD_REQUEST) + verifyNoInteractions(temporaryResourceStorageService) + verify(publicTaskRepository).releaseAttachmentSlot(PUBLIC_TASK_ID) + } + + @Test + fun `an upload that does not name a field is held to the limits of the task alone`() { + // A template written before the field name was sent along: the form is not resolved at all. + givenPublicTask() + givenAFreeAttachmentSlot() + whenever(temporaryResourceStorageService.store(any(), any())).thenReturn(RESOURCE_ID) + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + verifyNoInteractions(processLinkActivityService) + } + + @Test + fun `the type is not detected when neither the task nor the field narrows it`() { + givenPublicTask(acceptedMimeTypes = "") + givenAFreeAttachmentSlot() + givenAFormWithUploadField() + whenever(temporaryResourceStorageService.store(any(), any())).thenReturn(RESOURCE_ID) + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, COMPONENT_KEY, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + } + + @Test + fun `an upload is refused when what its form accepts cannot be established`() { + givenPublicTask() + givenAFreeAttachmentSlot() + whenever(processLinkActivityService.openTask(USER_TASK_ID)) + .thenThrow(ProcessLinkNotFoundException("no process link for this task")) + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, COMPONENT_KEY, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + verifyNoInteractions(temporaryResourceStorageService) + verify(publicTaskRepository).releaseAttachmentSlot(PUBLIC_TASK_ID) + } + + @Test + fun `an empty attachment is refused`() { + givenPublicTask() + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile(content = ByteArray(0))) + + assertThat(response.statusCode).isEqualTo(HttpStatus.BAD_REQUEST) + verifyNoInteractions(temporaryResourceStorageService) + } + + @Test + fun `an attachment is refused once the task has used up its slots`() { + givenPublicTask() + whenever(publicTaskRepository.reserveAttachmentSlot(PUBLIC_TASK_ID)).thenReturn(0) + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.CONFLICT) + verifyNoInteractions(temporaryResourceStorageService) + } + + @Test + fun `a slot is claimed before the file is written, so that simultaneous uploads cannot pass the limit`() { + givenPublicTask() + givenAFreeAttachmentSlot() + whenever(temporaryResourceStorageService.store(any(), any())).thenReturn(RESOURCE_ID) + + publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + val order = org.mockito.Mockito.inOrder(publicTaskRepository, temporaryResourceStorageService) + order.verify(publicTaskRepository).reserveAttachmentSlot(PUBLIC_TASK_ID) + order.verify(temporaryResourceStorageService).store(any(), any()) + } + + @Test + fun `an attachment of a type that is not accepted is refused and does not cost a slot`() { + givenPublicTask() + givenAFreeAttachmentSlot() + whenever(temporaryResourceStorageService.store(any(), any())) + .thenThrow(MimeTypeDeniedException("application/x-dosexec is not whitelisted for uploads.")) + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + // Refused by the application rather than by the task, and named the same way all the same. + assertThat(response.body?.toString()).isEqualTo("A file of type text/plain cannot be uploaded") + verify(publicTaskRepository).releaseAttachmentSlot(PUBLIC_TASK_ID) + } + + @Test + fun `an infected attachment is refused and does not cost a slot`() { + givenPublicTask() + givenAFreeAttachmentSlot() + whenever(temporaryResourceStorageService.store(any(), any())) + .thenThrow(VirusDetectedException("virus detected")) + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY) + verify(publicTaskRepository).releaseAttachmentSlot(PUBLIC_TASK_ID) + } + + @Test + fun `a failure to store an attachment does not leak the reason to the applicant`() { + givenPublicTask() + givenAFreeAttachmentSlot() + whenever(temporaryResourceStorageService.store(any(), any())) + .thenThrow(IllegalStateException("/var/valtimo/temp is not writable")) + + val response = publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile()) + + assertThat(response.statusCode).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR) + assertThat(response.body?.toString()).doesNotContain("/var/valtimo/temp") + verify(publicTaskRepository).releaseAttachmentSlot(PUBLIC_TASK_ID) + } + + @Test + fun `a submission may only point at files this public task uploaded for this case`() { + givenPublicTask() + givenAStoredAttachment(documentId = BUSINESS_KEY) + givenAnOpenTask() + + val submission = submissionWith(RESOURCE_ID) + val response = publicTaskService.completeUserTaskWithPublicTaskSubmission(PUBLIC_TASK_ID, submission) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + verify(defaultFormSubmissionService).handleSubmission( + processLinkId = PROCESS_LINK_ID, + formData = submission, + documentDefinitionName = null, + documentId = BUSINESS_KEY, + taskInstanceId = USER_TASK_ID.toString(), + ) + } + + @Test + fun `a submission pointing at a file uploaded for another case is refused`() { + givenPublicTask() + givenAStoredAttachment(documentId = "a-different-case") + + val response = + publicTaskService.completeUserTaskWithPublicTaskSubmission(PUBLIC_TASK_ID, submissionWith(RESOURCE_ID)) + + assertThat(response.statusCode).isEqualTo(HttpStatus.BAD_REQUEST) + verifyNoInteractions(processLinkActivityService, defaultFormSubmissionService) + } + + @Test + fun `a submission pointing at a file that was not uploaded through the public endpoint is refused`() { + givenPublicTask() + givenAStoredAttachment(documentId = BUSINESS_KEY, user = "a-logged-in-user") + + val response = + publicTaskService.completeUserTaskWithPublicTaskSubmission(PUBLIC_TASK_ID, submissionWith(RESOURCE_ID)) + + assertThat(response.statusCode).isEqualTo(HttpStatus.BAD_REQUEST) + verifyNoInteractions(processLinkActivityService, defaultFormSubmissionService) + } + + @Test + fun `a submission pointing at a file storage does not know is refused`() { + givenPublicTask() + whenever(temporaryResourceStorageService.getResourceMetadata(RESOURCE_ID)) + .thenThrow(IllegalArgumentException("No resource found with id '$RESOURCE_ID'")) + + val response = + publicTaskService.completeUserTaskWithPublicTaskSubmission(PUBLIC_TASK_ID, submissionWith(RESOURCE_ID)) + + assertThat(response.statusCode).isEqualTo(HttpStatus.BAD_REQUEST) + verifyNoInteractions(processLinkActivityService, defaultFormSubmissionService) + } + + @Test + fun `a file reference nested in a submission is checked too`() { + givenPublicTask() + givenAStoredAttachment(documentId = "a-different-case") + + val submission = + JsonNodeFactory.instance.objectNode().apply { + putObject("panel") + .putArray("bijlagen") + .addObject() + .putObject("data") + .put("resourceId", RESOURCE_ID) + } + + val response = publicTaskService.completeUserTaskWithPublicTaskSubmission(PUBLIC_TASK_ID, submission) + + assertThat(response.statusCode).isEqualTo(HttpStatus.BAD_REQUEST) + } + + @Test + fun `a submission without any file reference is passed on untouched`() { + givenPublicTask() + givenAnOpenTask() + + val response = publicTaskService.completeUserTaskWithPublicTaskSubmission(PUBLIC_TASK_ID, SUBMISSION) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + verifyNoInteractions(temporaryResourceStorageService) + } + + @Test + fun `a submission that parks another case's file under 'id' is refused`() { + givenPublicTask() + givenAStoredAttachment(documentId = "a-different-case") + + // UploadField reads '/id' before '/data/resourceId', so this is the id it would act on. + val submission = + JsonNodeFactory.instance.objectNode().apply { + putArray("bijlagen").addObject().put("id", RESOURCE_ID) + } + + val response = publicTaskService.completeUserTaskWithPublicTaskSubmission(PUBLIC_TASK_ID, submission) + + assertThat(response.statusCode).isEqualTo(HttpStatus.BAD_REQUEST) + verifyNoInteractions(processLinkActivityService, defaultFormSubmissionService) + } + + @Test + fun `a submission that hides another case's file behind an acceptable resource id is refused`() { + givenPublicTask() + givenAStoredAttachment(documentId = BUSINESS_KEY) + givenAStoredAttachment(resourceId = OTHER_RESOURCE_ID, documentId = "a-different-case") + + // The check must not stop at the 'data.resourceId' that does belong to this case. + val submission = + JsonNodeFactory.instance.objectNode().apply { + putArray("bijlagen") + .addObject() + .put("id", OTHER_RESOURCE_ID) + .putObject("data") + .put("resourceId", RESOURCE_ID) + } + + val response = publicTaskService.completeUserTaskWithPublicTaskSubmission(PUBLIC_TASK_ID, submission) + + assertThat(response.statusCode).isEqualTo(HttpStatus.BAD_REQUEST) + verifyNoInteractions(processLinkActivityService, defaultFormSubmissionService) + } + + @Test + fun `a value under 'id' that is no file at all is passed on untouched`() { + givenPublicTask() + givenAnOpenTask() + whenever(temporaryResourceStorageService.getResourceMetadata("gemeente-1234")) + .thenThrow(IllegalArgumentException("No resource found with id 'gemeente-1234'")) + + // 'id' is a key any component may carry: a select storing the whole record it was given, here. + val submission = + JsonNodeFactory.instance.objectNode().apply { + putObject("gemeente").put("id", "gemeente-1234").put("naam", "Den Haag") + } + + val response = publicTaskService.completeUserTaskWithPublicTaskSubmission(PUBLIC_TASK_ID, submission) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + } + + @Test + fun `an existing Valtimo resource in a prefilled form is accepted on its UUID form alone`() { + givenPublicTask() + givenAnOpenTask() + + // A resource id in UUID form is an existing Valtimo resource, which is how a prefilled field arrives. + val response = + publicTaskService.completeUserTaskWithPublicTaskSubmission( + PUBLIC_TASK_ID, + submissionWith(UUID.randomUUID().toString()), + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + verifyNoInteractions(temporaryResourceStorageService) + } + + private fun submissionWith(resourceId: String) = + JsonNodeFactory.instance.objectNode().apply { + putArray("bijlagen").addObject().putObject("data").put("resourceId", resourceId) + } + + private fun givenAnOpenTask() { + whenever(processLinkActivityService.openTask(USER_TASK_ID)) + .thenReturn(ProcessLinkActivityResult(PROCESS_LINK_ID, "form", null, null, Any())) + val submissionResult: FormSubmissionResult = mock() + whenever(submissionResult.errors()).thenReturn(emptyList()) + whenever( + defaultFormSubmissionService.handleSubmission(any(), any(), anyOrNull(), anyOrNull(), anyOrNull()), + ).thenReturn(submissionResult) + } + + private fun givenAStoredAttachment( + documentId: String, + user: String = "public-task", + resourceId: String = RESOURCE_ID, + ) { + whenever(temporaryResourceStorageService.getResourceMetadata(resourceId)).thenReturn( + mapOf( + MetadataType.USER.key to user, + MetadataType.DOCUMENT_ID.key to documentId, + MetadataType.FILE_NAME.key to "bijlage.pdf", + ), + ) + } + + private fun givenAFreeAttachmentSlot() { + whenever(publicTaskRepository.reserveAttachmentSlot(PUBLIC_TASK_ID)).thenReturn(1) + } + + private fun givenAFormWithUploadField( + componentKey: String = COMPONENT_KEY, + fileMaxSize: String? = null, + filePattern: String? = null, + ) { + val component = + JsonNodeFactory.instance.objectNode().apply { + put("key", componentKey) + put("type", "valtimo-file") + put("input", true) + fileMaxSize?.let { put("fileMaxSize", it) } + filePattern?.let { put("filePattern", it) } + } + val form = JsonNodeFactory.instance.objectNode().apply { putArray("components").add(component) } + whenever(processLinkActivityService.openTask(USER_TASK_ID)).thenReturn( + ProcessLinkActivityResult( + PROCESS_LINK_ID, + "form", + null, + null, + FormTaskOpenResultProperties(formDefinitionId = FORM_DEFINITION_ID, prefilledForm = form), + ), + ) + } + + private fun fileNameStoredFor(fileName: String?): String { + reset(temporaryResourceStorageService, publicTaskRepository) + givenPublicTask() + givenAFreeAttachmentSlot() + whenever(temporaryResourceStorageService.store(any(), any())).thenReturn(RESOURCE_ID) + + publicTaskService.storePublicTaskAttachment(PUBLIC_TASK_ID, null, aFile(fileName = fileName)) + + return storedMetadata()[MetadataType.FILE_NAME.key] as String + } + + private fun storedMetadata(): Map { + val metadata = argumentCaptor>() + verify(temporaryResourceStorageService).store(any(), metadata.capture()) + return metadata.firstValue + } + + private fun aFile( + fileName: String? = "bijlage.pdf", + content: ByteArray = CONTENT, + ) = MockMultipartFile("file", fileName, "application/pdf", content) + private fun givenPublicTask( expirationDate: LocalDate? = LocalDate.now().plusDays(1), completed: Boolean = false, + maxAttachmentSizeInBytes: Long = MAX_ATTACHMENT_SIZE, + acceptedMimeTypes: String = "", + documentMetadata: Map = emptyMap(), + documentMetadataJson: String = PublicTaskDocumentMetadata.of(documentMetadata).toJson(), ) { whenever(publicTaskRepository.findById(PUBLIC_TASK_ID)).thenReturn( Optional.of( PublicTaskEntity( publicTaskId = PUBLIC_TASK_ID, userTaskId = USER_TASK_ID, - processBusinessKey = "3e6b0dd5-3b4b-4bd4-a1ea-b9f0e4e1c7cb", + processBusinessKey = BUSINESS_KEY, assigneeCandidateContactData = "citizen@example.org", taskExpirationDate = expirationDate?.toString() ?: "", isCompletedByPublicTask = completed, + maxAttachments = MAX_ATTACHMENTS, + maxAttachmentSizeInBytes = maxAttachmentSizeInBytes, + acceptedMimeTypes = acceptedMimeTypes, + documentMetadataJson = documentMetadataJson, ), ), ) @@ -146,6 +830,27 @@ internal class PublicTaskServiceTest : BaseTest() { private val USER_TASK_ID = UUID.fromString("a0d1f5c2-1e3b-4a67-8c9d-0e1f2a3b4c5d") + private val PROCESS_LINK_ID = UUID.fromString("6f2b1c8e-4d3a-4a1b-9c7e-2f5a8d0b3e6c") + + private val FORM_DEFINITION_ID = UUID.fromString("b3c0d1e2-4f56-4a78-9b0c-1d2e3f4a5b6c") + + private const val BUSINESS_KEY = "3e6b0dd5-3b4b-4bd4-a1ea-b9f0e4e1c7cb" + + private const val RESOURCE_ID = "8402349873245-1234" + + private const val OTHER_RESOURCE_ID = "1298347981234-5678" + + private const val MAX_ATTACHMENT_SIZE = 64L + + private const val MAX_ATTACHMENTS = 3 + + private const val COMPONENT_KEY = "bijlagen" + + private val CONTENT = "a small pdf".toByteArray() + + // 'MZ' makes a Windows executable. Under MAX_ATTACHMENT_SIZE, so the type refuses it and not the size. + private val EXECUTABLE = byteArrayOf(0x4D, 0x5A) + ByteArray(30) + private val SUBMISSION = JsonNodeFactory.instance.objectNode().put("naam", "Ruben") } } diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskUploadFieldTest.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskUploadFieldTest.kt new file mode 100644 index 0000000..bc19419 --- /dev/null +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/service/PublicTaskUploadFieldTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2026 Ritense BV, the Netherlands. + * + * Licensed under EUPL, Version 1.2 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ritense.valtimoplugins.publictask.service + +import com.ritense.valtimoplugins.publictask.BaseTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource + +internal class PublicTaskUploadFieldTest : BaseTest() { + @ParameterizedTest + @ValueSource(strings = ["file", "valtimo-file", "documenten-api-file"]) + fun `the limits of an upload field are read for every upload component Valtimo recognises`(type: String) { + val form = formWith("""{"key": "bijlage", "type": "$type", "input": true, "fileMaxSize": "2MB"}""") + + assertThat(PublicTaskUploadField.limitsOf(form, "bijlage")?.maxSizeInBytes).isEqualTo(2 * 1024 * 1024) + } + + @Test + fun `an upload field nested in a layout component is found too`() { + val form = + formWith( + """ + { + "key": "kolommen", + "type": "columns", + "columns": [ + { + "components": [ + { "key": "bijlage", "type": "file", "input": true, "filePattern": "application/pdf" } + ] + } + ] + } + """.trimIndent(), + ) + + assertThat(PublicTaskUploadField.limitsOf(form, "bijlage")?.filePattern).isEqualTo("application/pdf") + } + + @Test + fun `a field that asks for nothing of its own has no limits of its own`() { + val form = formWith("""{"key": "bijlage", "type": "file", "input": true}""") + + assertThat(PublicTaskUploadField.limitsOf(form, "bijlage")) + .isEqualTo(PublicTaskUploadFieldLimits(maxSizeInBytes = null, filePattern = null)) + } + + @Test + fun `a key that is not an upload field of this form has no limits at all`() { + val form = + formWith( + """{"key": "naam", "type": "textfield", "input": true}""", + """{"key": "bijlage", "type": "file", "input": true}""", + ) + + assertThat(PublicTaskUploadField.limitsOf(form, "naam")).isNull() + assertThat(PublicTaskUploadField.limitsOf(form, "onbekend")).isNull() + assertThat(PublicTaskUploadField.limitsOf(form, "bijlage")).isNotNull() + } + + @Test + fun `the file pattern the form builder writes into every component narrows nothing`() { + // Form.io's own default; read as a restriction it would refuse everything. + val form = formWith("""{"key": "bijlage", "type": "file", "input": true, "filePattern": "*"}""") + + assertThat(PublicTaskUploadField.limitsOf(form, "bijlage")?.filePattern).isNull() + } + + @Test + fun `a blank maximum size or file pattern narrows nothing`() { + val form = + formWith("""{"key": "bijlage", "type": "file", "input": true, "fileMaxSize": "", "filePattern": " "}""") + + assertThat(PublicTaskUploadField.limitsOf(form, "bijlage")) + .isEqualTo(PublicTaskUploadFieldLimits(maxSizeInBytes = null, filePattern = null)) + } + + @Test + fun `a maximum size that Form io cannot read either is treated as no limit of its own`() { + val form = formWith("""{"key": "bijlage", "type": "file", "input": true, "fileMaxSize": "twee megabyte"}""") + + assertThat(PublicTaskUploadField.limitsOf(form, "bijlage")?.maxSizeInBytes).isNull() + } + + @ParameterizedTest + @CsvSource( + "10MB, 10485760", + "10mb, 10485760", + "512KB, 524288", + "1GB, 1073741824", + "1024B, 1024", + "1024, 1024", + "1.5MB, 1572864", + "' 2 MB ', 2097152", + ) + fun `the size format of the form builder is read the way Form io reads it`( + configured: String, + expected: Long, + ) { + assertThat(PublicTaskUploadField.parseFileSize(configured)).isEqualTo(expected) + } + + @ParameterizedTest + @ValueSource(strings = ["", " ", "MB", "-1MB", "twee", "10 megabytes", "1e", "??"]) + fun `a size that is not a size is not read as one`(configured: String) { + assertThat(PublicTaskUploadField.parseFileSize(configured)).isNull() + } +} diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/web/rest/PublicTaskResourceIT.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/web/rest/PublicTaskResourceIT.kt index d8ec84a..0a025e2 100644 --- a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/web/rest/PublicTaskResourceIT.kt +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/publictask/web/rest/PublicTaskResourceIT.kt @@ -21,8 +21,10 @@ import org.hamcrest.Matchers.containsString import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.mock.web.MockMultipartFile import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.util.UUID @@ -34,16 +36,24 @@ internal class PublicTaskResourceIT : BaseIntegrationTest() { @Test fun `GET public-task endpoint is mapped to the controller and does not fall through to static resource handling`() { - // Regression guard: PublicTaskResource must be registered via PublicTaskAutoConfiguration, not only via - // component scanning. On a real Valtimo host the plugin package is not scanned, so an unmapped endpoint - // would fall through to the static resource handler ("No static resource api/v1/public-task"). - // Requesting an unknown task must therefore reach the controller and return its "task not available" body. + // Regression guard: the resource must come from the autoconfiguration, as a host does not scan it. mockMvc .perform(get("/api/v1/public-task/{publicTaskId}", UUID.randomUUID().toString())) .andExpect(status().isNotFound) .andExpect(content().string(containsString("This task does not exist"))) } + @Test + fun `POST attachment endpoint is mapped and reachable without authentication`() { + // Must not be behind authentication, and an unknown task must be refused by the service. + mockMvc + .perform( + multipart("/api/v1/public-task/{publicTaskId}/attachment", UUID.randomUUID().toString()) + .file(MockMultipartFile("file", "bijlage.pdf", "application/pdf", "content".toByteArray())), + ).andExpect(status().isNotFound) + .andExpect(content().string(containsString("This task does not exist"))) + } + @Test fun `GET public-task endpoint still accepts the public task id as a query parameter`() { // Links that were sent to applicants before the id moved into the path must keep working. diff --git a/documentation/developer.md b/documentation/developer.md new file mode 100644 index 0000000..609f222 --- /dev/null +++ b/documentation/developer.md @@ -0,0 +1,198 @@ +# Public Task Plugin — implementation notes + +This file covers the parts of the Public Task Plugin that are not configured in the Valtimo user +interface: wiring the plugin into a process, replacing the generated HTML template, and the application +settings a public upload endpoint depends on. + +For configuring the plugin itself, see [plugin.md](plugin.md). + +## Wiring the plugin into a process + +The plugin is designed to be added to an existing user task. Valtimo can only link one action to a task, +and that has to be the form, so the public task is created from a subprocess instead: + +1. Add a **Task listener** with a **Create: Expression** to the user task, calling + `${publicTaskService.startNotifyAssigneeCandidateProcess(task)}`. + + ![example public task process](img/public-task-process.png) + +2. That correlates a message which starts a subprocess. Add this subprocess to the implementation. + + ![example create url process](img/create-url-process.png) + +3. Link the process link to the **Create Public Task URL** task. +4. Implement a notification function to send the URL to the assignee candidate. + +The plugin's controller has three endpoints. The first returns the HTML for the form of the user task, +the second accepts the submission and completes the task, the third accepts an uploaded file. + +Examples of both processes are in the plugin repository. This is one way to implement it, not the only +one. + +## Public task URL + +The base of the URL sent to the assignee candidate is resolved from application configuration, in this +order: + +1. `valtimo.url` — a full URL including scheme. Used when set. +2. `valtimo.app.hostname` — a hostname without scheme. Used when `valtimo.url` is not set; the scheme + comes from `valtimo.app.scheme`, which defaults to `https`. +3. When neither is configured, the plugin fails to start with a clear error. + +| Property | Environment variable | Example | +|------------------------|------------------------|------------------------------| +| `valtimo.url` | `VALTIMO_URL` | `https://my-app.example.com` | +| `valtimo.app.hostname` | `VALTIMO_APP_HOSTNAME` | `my-app.example.com` | +| `valtimo.app.scheme` | `VALTIMO_APP_SCHEME` | `https` | + +Existing setups based on `VALTIMO_URL` are unchanged; deployments that only configure +`VALTIMO_APP_HOSTNAME`, such as Ritense Cloud applications, generate the URL correctly. + +The public task id is a path segment: `/api/v1/public-task/`. Older links carrying +the id as a `publicTaskId` query parameter are still accepted so that URLs already sent out keep working, +but that form is deprecated — an id in the query string ends up in `Referer` headers, proxy logs and +browser history. + +## Application settings a public upload endpoint depends on + +Three application settings decide what the open upload endpoint accepts. None of them are plugin +settings, and the plugin cannot raise or lower them per task. Their defaults are not what a publicly +reachable upload wants, so **check them before putting upload fields in a public form**: + +| Property | Default | Set it to | +|--------------------------------------------------------------------|---------|----------------------------------------------------------------------------| +| `spring.servlet.multipart.max-file-size` | `1MB` | At least the largest **Maximum file size** of any process link. See below. | +| `valtimo.upload.accepted-mime-types` | empty | The types the application accepts at all. Empty means **every** file type. | +| `valtimo.virusscan.clamav.TemporaryResourceStorageService.enabled` | `false` | `true`, with ClamAV configured, for anything reachable from the internet. | + +A file over `spring.servlet.multipart.max-file-size` is refused by the servlet container while the +request is being parsed, before the plugin sees it, and the applicant gets the application's generic +error rather than the plugin's message. A public task is therefore capped at that limit whatever its +process link asks for, and a warning is logged when a task is created that asks for more. Raise +`spring.servlet.multipart.max-request-size` along with it. + +`valtimo.upload.accepted-mime-types` is the application-wide floor: **Accepted file types** on a process +link and **File Pattern** on a field can only narrow it, never widen it. A warning is logged at startup +when it is empty — an unauthenticated upload endpoint combined with an unrestricted set of file types is +worth noticing before deployment rather than after. + +### Retention + +Files wait in temporary resource storage between being uploaded and the form being submitted. +`valtimo.temporaryResourceStorage.retentionInMinutes` decides how long they are kept and defaults to +**60 minutes**. A public form is often opened well after the link was sent and filled in over a longer +period than a task inside Valtimo, so with the default an applicant can lose their attachments before +they press submit. Raise it to cover the time a form may realistically be left open. + +## Writing your own template + +The generated HTML is an example that implementations are expected to replace. Two things carry over into +a template of your own. + +**Keep the form definition in a `