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 `