FINERACT-2246: New command processing - document management

This commit is contained in:
Aleksandar Vidakovic
2026-02-16 14:56:40 +01:00
committed by Aleksandar Vidakovic
parent 8a61e30a45
commit 2ff0c62f40
172 changed files with 5928 additions and 3100 deletions
@@ -114,6 +114,23 @@ dependencyManagement {
exclude 'org.apache.commons:commons-compress'
exclude 'xml-apis:xml-apis'
}
dependency ('org.apache.tika:tika-parser-image-module:3.2.3') {
exclude 'org.bouncycastle:bcprov-jdk15on'
exclude 'org.bouncycastle:bcjmail-jdk15on'
exclude 'org.bouncycastle:bcprov-jdk18on'
exclude 'org.bouncycastle:bcjmail-jdk18on'
exclude 'commons-logging:commons-logging'
exclude 'org.apache.logging.log4j:log4j-api'
exclude 'org.slf4j:slf4j-api'
exclude 'commons-io:commons-io'
exclude 'commons-codec:commons-codec'
exclude 'org.apache.commons:commons-lang3'
exclude 'org.apache.poi:poi'
exclude 'org.apache.poi:poi-scratchpad'
exclude 'org.glassfish.jaxb:jaxb-runtime'
exclude 'org.apache.commons:commons-compress'
exclude 'xml-apis:xml-apis'
}
dependency ('org.apache.httpcomponents:httpclient:4.5.14') {
exclude 'commons-logging:commons-logging:'
}
@@ -265,6 +282,12 @@ dependencyManagement {
dependency 'com.lmax:disruptor:3.4.4'
dependency ('com.github.romankh3:image-comparison:4.4.0') {
exclude 'org.junit.jupiter:junit-jupiter-api'
exclude 'org.junit.jupiter:junit-jupiter-engine'
exclude 'org.mockito:mockito-core'
}
dependency 'com.ibm.icu:icu4j:76.1'
dependency 'org.yakworks:spring-icu4j:0.4.2'
dependency 'org.apache.commons:commons-lang3:3.18.0'
+2 -1
View File
@@ -18,12 +18,13 @@
services:
fineract:
# user: "${FINERACT_USER}:${FINERACT_GROUP}"
image: fineract:latest
user: "${FINERACT_USER}:${FINERACT_GROUP}"
volumes:
- ${PWD}/config/docker/logback/logback-override.xml:/app/logback-override.xml
- ${PWD}/config/docker/aws/etc/credentials:/etc/aws/credentials:ro
- ${PWD}/build/fineract/logs:/var/logs/fineract:rw
# - ${PWD}/build/fineract/tmp:/tmp:rw
healthcheck:
test: ["CMD", 'sh', '-c', 'echo -e "Checking for the availability of Fineract server deployment"; while ! nc -z "localhost" 8443; do sleep 1; printf "-"; done; echo -e " >> Fineract server has started";' ]
timeout: 1s
+2 -1
View File
@@ -54,6 +54,7 @@ FINERACT_MANAGEMENT_ENDPOINT_WEB_EXPOSURE_INCLUDE=health,info,prometheus
FINERACT_MANAGEMENT_METRICS_TAGS_APPLICATION=fineract
FINERACT_REMOTE_JOB_MESSAGE_HANDLER_SPRING_EVENTS_ENABLED=true
FINERACT_INSECURE_HTTP_CLIENT=true
FINERACT_CONTENT_FILESYSTEM_ROOT_FOLDER=/tmp
SPRING_PROFILES_ACTIVE=test,diagnostics
OTEL_SERVICE_NAME=fineract
JAVA_TOOL_OPTIONS=" -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5000 -Xmx1G -XX:MinRAMPercentage=25 -XX:MaxRAMPercentage=80 -XX:TieredStopAtLevel=1 -XX:+UseContainerSupport -XX:+UseStringDeduplication --add-exports=java.naming/com.sun.jndi.ldap=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.management/javax.management=ALL-UNNAMED --add-opens=java.naming/javax.naming=ALL-UNNAMED"
JAVA_TOOL_OPTIONS="-Xmx1G -XX:MinRAMPercentage=25 -XX:MaxRAMPercentage=80 -XX:TieredStopAtLevel=1 -XX:+UseContainerSupport -XX:+UseStringDeduplication -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5000 --add-exports=java.naming/com.sun.jndi.ldap=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.management/javax.management=ALL-UNNAMED --add-opens=java.naming/javax.naming=ALL-UNNAMED"
@@ -34,7 +34,6 @@
<!-- Core Module Entities -->
<class>org.apache.fineract.useradministration.domain.Role</class>
<class>org.apache.fineract.portfolio.fund.domain.Fund</class>
<class>org.apache.fineract.infrastructure.documentmanagement.domain.Image</class>
<class>org.apache.fineract.organisation.workingdays.domain.WorkingDays</class>
<class>org.apache.fineract.useradministration.domain.Permission</class>
<class>org.apache.fineract.useradministration.domain.AppUserClientMapping</class>
@@ -34,7 +34,6 @@
<!-- Core Module Entities -->
<class>org.apache.fineract.useradministration.domain.Role</class>
<class>org.apache.fineract.portfolio.fund.domain.Fund</class>
<class>org.apache.fineract.infrastructure.documentmanagement.domain.Image</class>
<class>org.apache.fineract.organisation.workingdays.domain.WorkingDays</class>
<class>org.apache.fineract.useradministration.domain.Permission</class>
<class>org.apache.fineract.useradministration.domain.AppUserClientMapping</class>
@@ -34,7 +34,6 @@
<!-- Core Module Entities -->
<class>org.apache.fineract.useradministration.domain.Role</class>
<class>org.apache.fineract.portfolio.fund.domain.Fund</class>
<class>org.apache.fineract.infrastructure.documentmanagement.domain.Image</class>
<class>org.apache.fineract.organisation.workingdays.domain.WorkingDays</class>
<class>org.apache.fineract.useradministration.domain.Permission</class>
<class>org.apache.fineract.useradministration.domain.AppUserClientMapping</class>
@@ -22,10 +22,10 @@ import feign.Param;
import feign.RequestLine;
import feign.Response;
import java.util.List;
import org.apache.fineract.client.models.DeleteEntityTypeEntityIdDocumentsResponse;
import org.apache.fineract.client.models.DocumentCreateResponse;
import org.apache.fineract.client.models.DocumentData;
import org.apache.fineract.client.models.PostEntityTypeEntityIdDocumentsResponse;
import org.apache.fineract.client.models.PutEntityTypeEntityIdDocumentsResponse;
import org.apache.fineract.client.models.DocumentDeleteResponse;
import org.apache.fineract.client.models.DocumentUpdateResponse;
/**
* This class was originally generated by <a href="https://openapi-generator.tech">OpenAPI Generator</a>, but then had
@@ -44,7 +44,7 @@ public interface DocumentsApiFixed {
* @return PostEntityTypeEntityIdDocumentsResponse
*/
@RequestLine("POST /v1/{entityType}/{entityId}/documents")
PostEntityTypeEntityIdDocumentsResponse createDocument(@Param("entityType") String entityType, @Param("entityId") Long entityId,
DocumentCreateResponse createDocument(@Param("entityType") String entityType, @Param("entityId") Long entityId,
org.apache.fineract.client.feign.FineractMultipartEncoder.MultipartData multipartData);
/**
@@ -59,7 +59,7 @@ public interface DocumentsApiFixed {
* @return DeleteEntityTypeEntityIdDocumentsResponse
*/
@RequestLine("DELETE /v1/{entityType}/{entityId}/documents/{documentId}")
DeleteEntityTypeEntityIdDocumentsResponse deleteDocument(@Param("entityType") String entityType, @Param("entityId") Long entityId,
DocumentDeleteResponse deleteDocument(@Param("entityType") String entityType, @Param("entityId") Long entityId,
@Param("documentId") Long documentId);
/**
@@ -114,6 +114,6 @@ public interface DocumentsApiFixed {
* @return PutEntityTypeEntityIdDocumentsResponse
*/
@RequestLine("PUT /v1/{entityType}/{entityId}/documents/{documentId}")
PutEntityTypeEntityIdDocumentsResponse updateDocument(@Param("entityType") String entityType, @Param("entityId") Long entityId,
DocumentUpdateResponse updateDocument(@Param("entityType") String entityType, @Param("entityId") Long entityId,
@Param("documentId") Long documentId, org.apache.fineract.client.feign.FineractMultipartEncoder.MultipartData multipartData);
}
@@ -35,7 +35,6 @@ import java.util.List;
import java.util.concurrent.TimeUnit;
import org.apache.fineract.client.feign.FineractFeignClientConfig;
import org.apache.fineract.client.feign.services.DocumentsApiFixed;
import org.apache.fineract.client.models.DeleteEntityTypeEntityIdDocumentsResponse;
import org.apache.fineract.client.models.DocumentData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -149,7 +148,7 @@ class DocumentsApiFixedIntegrationTest {
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseBody)));
DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
DeleteEntityTypeEntityIdDocumentsResponse response = api.deleteDocument("clients", 123L, 456L);
var response = api.deleteDocument("clients", 123L, 456L);
assertThat(response).isNotNull();
assertThat(response.getResourceId()).isEqualTo(456L);
@@ -162,7 +161,7 @@ class DocumentsApiFixedIntegrationTest {
.willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json").withBody(responseBody)));
DocumentsApiFixed api = config.createClient(DocumentsApiFixed.class);
DeleteEntityTypeEntityIdDocumentsResponse response = api.deleteDocument("loans", 789L, 999L);
var response = api.deleteDocument("loans", 789L, 999L);
assertThat(response).isNotNull();
assertThat(response.getResourceId()).isEqualTo(999L);
+14
View File
@@ -17,6 +17,7 @@
* under the License.
*/
apply plugin: 'org.openapi.generator'
apply plugin: 'idea'
description = 'Fineract Client'
apply from: 'dependencies.gradle'
@@ -35,6 +36,19 @@ openApiValidate {
recommend = true
}
openApiGenerate {
outputDir = "$buildDir/generated/temp-java".toString()
}
idea {
module {
// Marks the generated directory as a Source Root
sourceDirs += file("$buildDir/generated/temp-java/src/main/java")
// Or use generatedSourceDirs for explicit "Generated Source" marking
generatedSourceDirs += file("$buildDir/generated/temp-java/src/main/java")
}
}
task buildJavaSdk(type: org.openapitools.generator.gradle.plugin.tasks.GenerateTask) {
generatorName = 'java'
verbose = false
@@ -20,10 +20,10 @@ package org.apache.fineract.client.services;
import java.util.List;
import okhttp3.ResponseBody;
import org.apache.fineract.client.models.DeleteEntityTypeEntityIdDocumentsResponse;
import org.apache.fineract.client.models.DocumentCreateResponse;
import org.apache.fineract.client.models.DocumentData;
import org.apache.fineract.client.models.PostEntityTypeEntityIdDocumentsResponse;
import org.apache.fineract.client.models.PutEntityTypeEntityIdDocumentsResponse;
import org.apache.fineract.client.models.DocumentDeleteResponse;
import org.apache.fineract.client.models.DocumentUpdateResponse;
import retrofit2.Call;
import retrofit2.http.DELETE;
import retrofit2.http.GET;
@@ -54,7 +54,7 @@ public interface DocumentsApiFixed {
*/
@retrofit2.http.Multipart
@POST("v1/{entityType}/{entityId}/documents")
Call<PostEntityTypeEntityIdDocumentsResponse> createDocument(@retrofit2.http.Path("entityType") String entityType,
Call<DocumentCreateResponse> createDocument(@retrofit2.http.Path("entityType") String entityType,
@retrofit2.http.Path("entityId") Long entityId, @retrofit2.http.Part okhttp3.MultipartBody.Part file,
@retrofit2.http.Part("name") String name, @retrofit2.http.Part("description") String description);
@@ -70,7 +70,7 @@ public interface DocumentsApiFixed {
* @return Call&lt;DeleteEntityTypeEntityIdDocumentsResponse&gt;
*/
@DELETE("v1/{entityType}/{entityId}/documents/{documentId}")
Call<DeleteEntityTypeEntityIdDocumentsResponse> deleteDocument(@retrofit2.http.Path("entityType") String entityType,
Call<DocumentDeleteResponse> deleteDocument(@retrofit2.http.Path("entityType") String entityType,
@retrofit2.http.Path("entityId") Long entityId, @retrofit2.http.Path("documentId") Long documentId);
/**
@@ -139,7 +139,7 @@ public interface DocumentsApiFixed {
*/
@retrofit2.http.Multipart
@PUT("v1/{entityType}/{entityId}/documents/{documentId}")
Call<PutEntityTypeEntityIdDocumentsResponse> updateDocument(@retrofit2.http.Path("entityType") String entityType,
Call<DocumentUpdateResponse> updateDocument(@retrofit2.http.Path("entityType") String entityType,
@retrofit2.http.Path("entityId") Long entityId, @retrofit2.http.Path("documentId") Long documentId,
@retrofit2.http.Part okhttp3.MultipartBody.Part file, @retrofit2.http.Part("name") String name,
@retrofit2.http.Part("description") String description);
@@ -33,7 +33,6 @@
<!-- Core Module Entities -->
<class>org.apache.fineract.useradministration.domain.Role</class>
<class>org.apache.fineract.portfolio.fund.domain.Fund</class>
<class>org.apache.fineract.infrastructure.documentmanagement.domain.Image</class>
<class>org.apache.fineract.organisation.workingdays.domain.WorkingDays</class>
<class>org.apache.fineract.useradministration.domain.Permission</class>
<class>org.apache.fineract.useradministration.domain.AppUserClientMapping</class>
@@ -114,6 +114,7 @@ public class DefaultCommandAuditor implements CommandAuditor {
final var now = Instant.now();
final var commandEntity = mapper.map(command);
commandEntity.setExecutedAt(now);
commandEntity.setUpdatedAt(now);
commandEntity.setState(ERROR);
@@ -138,7 +139,17 @@ public class DefaultCommandAuditor implements CommandAuditor {
@Retry(name = "commandAuditFallback", fallbackMethod = "fatal")
@SuppressWarnings("UnusedMethod")
private void fallback(Command<?> command, Throwable t) throws Exception {
void fallback(Command<?> command, Throwable t) throws Exception {
if (Boolean.TRUE.equals(properties.getFileDeadLetterQueueEnabled())) {
fallback(command, null, t);
} else {
fatal(command, t);
}
}
@Retry(name = "commandAuditFallback", fallbackMethod = "fatal")
@SuppressWarnings("UnusedMethod")
void fallback(Command<?> command, Object response, Throwable t) throws Exception {
if (Boolean.TRUE.equals(properties.getFileDeadLetterQueueEnabled())) {
setError(command, t);
write(command);
@@ -148,7 +159,12 @@ public class DefaultCommandAuditor implements CommandAuditor {
}
@SuppressWarnings("UnusedMethod")
private void fatal(Command<?> command, Throwable t) {
void fatal(Command<?> command, Throwable t) {
fatal(command, null, t);
}
@SuppressWarnings("UnusedMethod")
void fatal(Command<?> command, Object response, Throwable t) {
// note: last line of defense; if this fails then all is lost
setError(command, t);
log.error("Command audit error: {}", command, t);
@@ -28,7 +28,7 @@ import org.springframework.data.jdbc.core.convert.JdbcCustomConversions;
import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories;
@Configuration
@EnableJdbcRepositories(basePackages = { "org.apache.fineract.command.persistence" })
@EnableJdbcRepositories(basePackages = { "org.apache.fineract.**.domain", "org.apache.fineract.**.persistence" })
@ComponentScan("org.apache.fineract.command.persistence")
class CommandPersistenceConfiguration {
@@ -19,7 +19,13 @@
package org.apache.fineract.infrastructure.bulkimport.data;
import java.time.LocalDate;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
public final class ImportData {
@SuppressWarnings("unused")
@@ -27,6 +33,8 @@ public final class ImportData {
@SuppressWarnings("unused")
private Long documentId;
@SuppressWarnings("unused")
private Integer entityType;
@SuppressWarnings("unused")
private String name;
@SuppressWarnings("unused")
private LocalDate importTime;
@@ -18,12 +18,10 @@
*/
package org.apache.fineract.infrastructure.bulkimport.service;
import jakarta.ws.rs.core.Response;
import java.io.InputStream;
import java.util.Collection;
import org.apache.fineract.infrastructure.bulkimport.data.GlobalEntityType;
import org.apache.fineract.infrastructure.bulkimport.data.ImportData;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
public interface BulkImportWorkbookService {
@@ -33,8 +31,5 @@ public interface BulkImportWorkbookService {
Collection<ImportData> getImports(GlobalEntityType type);
DocumentData getOutputTemplateLocation(String importDocumentId);
Response getOutputTemplate(String importDocumentId);
ImportData getImport(Long id);
}
@@ -364,6 +364,7 @@ public class FineractProperties {
private List<String> regexWhitelist;
private boolean mimeWhitelistEnabled;
private List<String> mimeWhitelist;
private Integer defaultBufferSize;
private FineractContentFilesystemProperties filesystem;
private FineractContentS3Properties s3;
}
@@ -0,0 +1,51 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.adapter;
import java.io.Serial;
import java.io.Serializable;
import java.util.Optional;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
// NOTE: this is a trick to decouple the entity image IDs from the image service
@Deprecated
public interface EntityImageIdAdapter {
boolean accept(String entityType);
Optional<ImageIdResult> get(Long entityId);
Optional<ImageIdResult> set(Long entityId, Long imageId);
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
class ImageIdResult implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long id;
private String displayName;
}
}
@@ -40,7 +40,6 @@ public class CurrencyUpdateCommandHandler implements CommandHandler<CurrencyUpda
@Transactional
@Override
public CurrencyUpdateResponse handle(final Command<CurrencyUpdateRequest> command) {
// NOTE: fallback method needs to be in the same class
return writePlatformService.updateAllowedCurrencies(command.getPayload());
}
@@ -22,7 +22,6 @@ import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDate;
@@ -33,7 +32,6 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.documentmanagement.domain.Image;
import org.apache.fineract.organisation.office.domain.Office;
@Getter
@@ -81,9 +79,8 @@ public class Staff extends AbstractPersistableCustom<Long> {
@JoinColumn(name = "organisational_role_parent_staff_id")
private Staff organisationalRoleParentStaff;
@OneToOne(optional = true)
@JoinColumn(name = "image_id")
private Image image;
@Column(name = "image_id")
private Long imageId;
public static Staff fromJson(final Office staffOffice, final JsonCommand command) {
@@ -262,8 +259,4 @@ public class Staff extends AbstractPersistableCustom<Long> {
return this.office;
}
public void setImage(Image image) {
this.image = image;
}
}
@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.portfolio.client.adapter;
import static java.util.Objects.nonNull;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.documentmanagement.adapter.EntityImageIdAdapter;
import org.apache.fineract.portfolio.client.domain.ClientRepository;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
@Component
@Deprecated
class ClientImageIdAdapter implements EntityImageIdAdapter {
private static final String ENTITY_TYPE = "clients";
private final ClientRepository repository;
@Override
public boolean accept(String entityType) {
return ENTITY_TYPE.equalsIgnoreCase(entityType);
}
@Override
@Transactional(readOnly = true)
public Optional<ImageIdResult> get(Long entityId) {
return repository.findById(entityId).filter(client -> nonNull(client.getImageId()))
.map(client -> ImageIdResult.builder().id(client.getImageId()).displayName(client.getDisplayName()).build());
}
@Override
@Transactional
public Optional<ImageIdResult> set(Long entityId, Long imageId) {
final var result = get(entityId);
if (imageId == null) {
repository.removeImageId(entityId);
} else {
repository.updateByIdAndImageId(entityId, imageId);
}
return result;
}
}
@@ -27,7 +27,6 @@ import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDate;
@@ -46,7 +45,6 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.documentmanagement.domain.Image;
import org.apache.fineract.infrastructure.security.service.RandomPasswordGenerator;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.organisation.staff.domain.Staff;
@@ -72,13 +70,9 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@JoinColumn(name = "transfer_to_office_id")
private Office transferToOffice;
@OneToOne(optional = true)
@JoinColumn(name = "image_id")
private Image image;
@Column(name = "image_id")
private Long imageId;
/**
* A value from {@link ClientStatus}.
*/
@Column(name = "status_enum", nullable = false)
private Integer status;
@@ -494,10 +488,6 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return this.office.getId();
}
public void setImage(final Image image) {
this.image = image;
}
public String mobileNo() {
return this.mobileNo;
}
@@ -510,10 +500,6 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.mobileNo = mobileNo;
}
public boolean isNotStaff() {
return !isStaff();
}
public boolean isStaff() {
return this.isStaff;
}
@@ -22,6 +22,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.portfolio.client.domain.search.SearchingClientRepository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -46,4 +47,11 @@ public interface ClientRepository extends JpaRepository<Client, Long>, JpaSpecif
@Query("SELECT c.id FROM Client c WHERE c.externalId = :externalId")
Long findIdByExternalId(@Param("externalId") ExternalId externalId);
@Modifying
@Query("UPDATE Client client SET client.imageId = :imageId WHERE client.id = :clientId")
void updateByIdAndImageId(@Param("clientId") Long staffId, @Param("imageId") Long imageId);
@Modifying
@Query("UPDATE Client client SET client.imageId = null WHERE client.id = :clientId")
void removeImageId(@Param("clientId") Long clientId);
}
@@ -0,0 +1,89 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.util;
import jakarta.ws.rs.container.AsyncResponse;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.StreamingOutput;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
@Slf4j
public final class StreamResponseUtil {
public static String DISPOSITION_TYPE_ATTACHMENT = "attachment";
public static String DISPOSITION_TYPE_INLINE = "inline";
private static final ExecutorService executor = Executors.newCachedThreadPool();
private StreamResponseUtil() {}
public static Response ok(final StreamResponseData content) {
final var stream = new StreamingOutput() {
@Override
public void write(OutputStream out) throws IOException {
IOUtils.copy(content.getStream(), out);
}
};
if (StringUtils.isEmpty(content.getDispositionType())) {
return Response.ok(stream, content.getType()).build();
} else {
return Response.ok(stream, content.getType()).header(HttpHeaders.CONTENT_DISPOSITION,
String.format("%s; filename=\"%s\"", content.getDispositionType(), content.getFileName())).build();
}
}
public static Future<?> ok(final AsyncResponse asyncResponse, final StreamResponseData content) {
return executor.submit(() -> {
if (StringUtils.isEmpty(content.getDispositionType())) {
asyncResponse.resume(Response.ok(content.getStream(), content.getType()).build());
} else {
asyncResponse.resume(Response.ok(content.getStream(), content.getType()).header(HttpHeaders.CONTENT_DISPOSITION,
String.format("%s; filename=\"%s\"", content.getDispositionType(), content.getFileName())).build());
}
});
}
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public static final class StreamResponseData {
private InputStream stream;
private String type;
private String fileName;
private String dispositionType;
private Long size;
}
}
@@ -34,7 +34,6 @@
<!-- Core Module Entities -->
<class>org.apache.fineract.useradministration.domain.Role</class>
<class>org.apache.fineract.portfolio.fund.domain.Fund</class>
<class>org.apache.fineract.infrastructure.documentmanagement.domain.Image</class>
<class>org.apache.fineract.organisation.workingdays.domain.WorkingDays</class>
<class>org.apache.fineract.useradministration.domain.Permission</class>
<class>org.apache.fineract.useradministration.domain.AppUserClientMapping</class>
+9 -15
View File
@@ -24,27 +24,25 @@ dependencies {
// implementation dependencies are directly used (compiled against) in src/main (and src/test)
//
implementation(project(path: ':fineract-core'))
implementation(
project(path: ':fineract-core'),
project(path: ':fineract-command'),
)
implementation(
'org.springframework.boot:spring-boot-starter-web',
'org.springframework.boot:spring-boot-starter-validation',
'org.springframework.boot:spring-boot-starter-security',
'org.springframework.boot:spring-boot-starter-data-jdbc',
'jakarta.ws.rs:jakarta.ws.rs-api',
'org.glassfish.jersey.media:jersey-media-multipart',
'com.google.guava:guava',
'com.google.code.gson:gson',
'com.fasterxml.jackson.core:jackson-databind',
'org.apache.commons:commons-lang3',
'com.jayway.jsonpath:json-path',
'com.github.spotbugs:spotbugs-annotations',
'io.swagger.core.v3:swagger-annotations-jakarta',
'com.squareup.retrofit2:converter-gson',
'org.springdoc:springdoc-openapi-starter-webmvc-ui',
'org.mapstruct:mapstruct',
'io.github.resilience4j:resilience4j-spring-boot3',
@@ -53,6 +51,7 @@ dependencies {
'org.apache.tika:tika-core',
'org.apache.tika:tika-parser-microsoft-module',
'org.apache.tika:tika-parser-miscoffice-module',
'org.apache.tika:tika-parser-image-module',
)
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
@@ -62,12 +61,6 @@ dependencies {
}
implementation ('software.amazon.awssdk:auth') {
}
implementation ('org.springframework.boot:spring-boot-starter-data-jpa') {
exclude group: 'org.hibernate'
}
implementation('org.eclipse.persistence:org.eclipse.persistence.jpa') {
exclude group: 'org.eclipse.persistence', module: 'jakarta.persistence'
}
// testCompile dependencies are ONLY used in src/test, not src/main.
// Do NOT repeat dependencies which are ALREADY in implementation or runtimeOnly!
//
@@ -80,4 +73,5 @@ dependencies {
exclude group: 'org.skyscreamer'
}
testImplementation ('org.mockito:mockito-inline')
testImplementation 'com.github.romankh3:image-comparison'
}
@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.config;
import static org.apache.fineract.infrastructure.contentstore.processor.ContentProcessor.BEAN_NAME_EXECUTOR;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@RequiredArgsConstructor
class ContentStoreConfig {
@Bean(BEAN_NAME_EXECUTOR)
ExecutorService contentProcessorExecutor() {
// TODO: make this more configurable?
return Executors.newCachedThreadPool();
}
}
@@ -16,36 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.documentmanagement.domain;
package org.apache.fineract.infrastructure.contentstore.data;
import java.util.HashMap;
import java.util.Map;
public enum StorageType {
public enum ContentStoreType {
FILE_SYSTEM(1), //
S3(2); //
private final Integer value;
StorageType(final Integer value) {
ContentStoreType(final Integer value) {
this.value = value;
}
public Integer getValue() {
return this.value;
}
private static final Map<Integer, StorageType> intToEnumMap = new HashMap<>();
static {
for (final StorageType type : StorageType.values()) {
intToEnumMap.put(type.value, type);
}
}
public static StorageType fromInt(final int i) {
final StorageType type = intToEnumMap.get(Integer.valueOf(i));
return type;
}
}
@@ -16,16 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.documentmanagement.api;
package org.apache.fineract.infrastructure.contentstore.detector;
/**
* Created by sanyam on 10/8/17.
*/
final class ImagesApiResourceSwagger {
private ImagesApiResourceSwagger() {
}
public interface ContentDetector {
ContentDetectorContext detect(ContentDetectorContext ctx);
}
@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.detector;
import java.io.InputStream;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public final class ContentDetectorContext {
private InputStream inputStream;
@Builder.Default
private boolean inputStreamEnabled = false;
private String fileName;
private String mimeType;
private String extension;
private String format;
public ContentDetectorContext clone(String mimeType, String extension, String format) {
return new ContentDetectorContext(this.inputStream, this.inputStreamEnabled, this.fileName, mimeType, extension, format);
}
public ContentDetectorContext clone(InputStream inputStream, String mimeType, String extension, String format) {
return new ContentDetectorContext(inputStream, this.inputStreamEnabled, this.fileName, mimeType, extension, format);
}
}
@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.detector;
public interface ContentDetectorManager {
ContentDetectorContext detect(ContentDetectorContext ctx);
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.detector;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public final class DefaultContentDetectorManager implements ContentDetectorManager {
private final TikaContentDetector tikaContentDetector;
@Override
public ContentDetectorContext detect(ContentDetectorContext ctx) {
return tikaContentDetector.detect(ctx);
}
}
@@ -0,0 +1,45 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.detector;
import java.nio.file.Files;
import java.nio.file.Paths;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.fineract.infrastructure.contentstore.exception.ContentDetectorException;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public final class FileContentDetector implements ContentDetector {
@Override
public ContentDetectorContext detect(ContentDetectorContext ctx) {
try {
final var mimeType = Files.probeContentType(Paths.get(ctx.getFileName()));
final var format = FilenameUtils.getExtension(ctx.getFileName());
return ctx.clone(mimeType, "." + format, format);
} catch (Exception e) {
throw new ContentDetectorException(e);
}
}
}
@@ -0,0 +1,88 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.detector;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.contentstore.exception.ContentDetectorException;
import org.apache.tika.Tika;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.mime.MimeTypeException;
import org.apache.tika.mime.MimeTypes;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public final class TikaContentDetector implements ContentDetector {
private final Tika tika = new Tika();
@Override
public ContentDetectorContext detect(ContentDetectorContext ctx) {
if (ctx.getInputStream() != null && !ctx.isInputStreamEnabled()) {
log.warn(
"Input stream provided, but not explicitly enabled for detection. This operation is potentially making the input stream unusable, especially with HTTP multipart streams. Input stream will be ignored!");
}
try {
if (ctx.getInputStream() != null && ctx.isInputStreamEnabled()) {
final var stream = TikaInputStream.get(ctx.getInputStream());
final var metadata = new Metadata();
metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, ctx.getFileName());
final var mimeType = tika.detect(stream, metadata);
final var extension = getExtension(mimeType);
stream.reset();
// note: original stream should be still usable if "mark" is supported
return ctx.clone(mimeType, extension, extension.substring(1));
} else if (StringUtils.isNotEmpty(ctx.getFileName())) {
final var mimeType = tika.detect(ctx.getFileName());
final var extension = getExtension(mimeType);
return ctx.clone(mimeType, extension, extension.substring(1));
}
} catch (Exception e) {
throw new ContentDetectorException(e);
}
throw new ContentDetectorException(
new IllegalArgumentException("Could not run detection, because required arguments were missing."));
}
private String getExtension(String mimeType) {
try {
return Optional.ofNullable(MimeTypes.getDefaultMimeTypes().forName(mimeType)).orElseGet(() -> {
try {
return MimeTypes.getDefaultMimeTypes().forName(MimeTypes.OCTET_STREAM);
} catch (MimeTypeException e) {
throw new ContentDetectorException(e);
}
}).getExtension();
} catch (Exception e) {
throw new ContentDetectorException(e);
}
}
}
@@ -16,17 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.documentmanagement.exception;
package org.apache.fineract.infrastructure.contentstore.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformException;
/**
* A {@link RuntimeException} thrown when document management functionality is invoked for invalid Entity Types
*/
public class InvalidEntityTypeForDocumentManagementException extends AbstractPlatformResourceNotFoundException {
public class ContentDetectorException extends AbstractPlatformException {
public InvalidEntityTypeForDocumentManagementException(final String entityType) {
super("error.documentmanagement.entitytype.invalid", "Document Management is not support for the Entity Type: " + entityType,
entityType);
public ContentDetectorException(Exception e) {
super("error.msg.content.detector", e.getMessage(), e);
}
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformException;
public class ContentPathSanitizerException extends AbstractPlatformException {
public ContentPathSanitizerException(String message) {
super("error.msg.content.sanitizer", message);
}
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformException;
public class ContentPolicyException extends AbstractPlatformException {
public ContentPolicyException(String message) {
super("error.msg.content.policy", message);
}
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformException;
public class ContentProcessorException extends AbstractPlatformException {
public ContentProcessorException(String message) {
super("error.msg.content.processor", message);
}
public ContentProcessorException(Exception e) {
super("error.msg.content.processor", e.getMessage(), e);
}
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformException;
public class ContentStoreException extends AbstractPlatformException {
public ContentStoreException(Exception e) {
super("error.msg.content.store", e.getMessage(), e);
}
}
@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.policy;
public interface ContentPolicy {
void check(ContentPolicyContext ctx);
}
@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.policy;
import java.io.InputStream;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public final class ContentPolicyContext {
private String path;
private Long size;
private InputStream inputStream;
private String mimeType;
private String extension;
}
@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.policy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class DefaultDeleteContentPolicy implements ContentPolicy {
private final TraversalContentPolicy traversalContentPolicy;
private final FineractProperties properties;
@Override
public void check(ContentPolicyContext ctx) {
traversalContentPolicy.check(ctx);
}
}
@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.policy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class DefaultDownloadContentPolicy implements ContentPolicy {
private final TraversalContentPolicy traversalContentPolicy;
private final FineractProperties properties;
@Override
public void check(ContentPolicyContext ctx) {
traversalContentPolicy.check(ctx);
}
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.policy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class DefaultPostUploadContentPolicy implements ContentPolicy {
private final MimeContentPolicy mimeContentPolicy;
@Override
public void check(ContentPolicyContext ctx) {
mimeContentPolicy.check(ctx);
}
}
@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.policy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class DefaultPreUploadContentPolicy implements ContentPolicy {
private final WhitelistContentPolicy whitelistContentPolicy;
private final TraversalContentPolicy traversalContentPolicy;
@Override
public void check(ContentPolicyContext ctx) {
traversalContentPolicy.check(ctx);
whitelistContentPolicy.check(ctx);
}
}
@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.policy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.Strings;
import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorContext;
import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorManager;
import org.apache.fineract.infrastructure.contentstore.exception.ContentPolicyException;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class MimeContentPolicy implements ContentPolicy {
private final ContentDetectorManager contentDetectorManager;
@Override
public void check(ContentPolicyContext ctx) {
if (ctx.getInputStream() != null) {
var result = contentDetectorManager
.detect(ContentDetectorContext.builder().inputStream(ctx.getInputStream()).inputStreamEnabled(true).build());
if (!Strings.CI.equals(result.getMimeType(), ctx.getMimeType())) {
throw new ContentPolicyException(String.format("Detected file type (%s), but mime type (%s) was provided. Mismatch!",
result.getMimeType(), ctx.getMimeType()));
}
}
}
}
@@ -0,0 +1,43 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.policy;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.contentstore.exception.ContentPolicyException;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class TraversalContentPolicy implements ContentPolicy {
private final Pattern overwriteSiblingsPattern = Pattern.compile(".*((\\.)?\\./+)+(/+)?.*");
private final FineractProperties properties;
@Override
public void check(ContentPolicyContext ctx) {
if (overwriteSiblingsPattern.matcher(ctx.getPath()).matches()) {
throw new ContentPolicyException(String.format("Trying to overwrite a sibling file: %s", ctx.getPath()));
}
}
}
@@ -0,0 +1,72 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.policy;
import java.util.List;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.contentstore.exception.ContentPolicyException;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class WhitelistContentPolicy implements ContentPolicy {
private final FineractProperties properties;
private List<Pattern> regexWhitelist;
@EventListener(ApplicationStartedEvent.class)
void onStartup() {
regexWhitelist = properties.getContent().getRegexWhitelist().stream().map(Pattern::compile).toList();
}
@Override
public void check(ContentPolicyContext ctx) {
if (properties.getContent().isRegexWhitelistEnabled()) {
final var fileName = FilenameUtils.getName(ctx.getPath());
boolean matches = regexWhitelist.stream().anyMatch(p -> p.matcher(fileName).matches());
if (!matches) {
throw new ContentPolicyException(String.format("File name not allowed: %s", fileName));
}
}
if (properties.getContent().isMimeWhitelistEnabled()) {
final var fileName = FilenameUtils.getName(ctx.getPath());
if (StringUtils.isEmpty(ctx.getMimeType())) {
throw new ContentPolicyException(String.format("Could not detect mime type for filename %s!", fileName));
}
if (!properties.getContent().getMimeWhitelist().contains(ctx.getMimeType())) {
throw new ContentPolicyException(
String.format("Detected mime type %s for filename %s not allowed!", ctx.getMimeType(), fileName));
}
}
}
}
@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.processor;
import static java.util.Objects.requireNonNullElse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64InputStream;
import org.apache.fineract.infrastructure.contentstore.util.ContentPipe;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class Base64DecoderContentProcessor implements ContentProcessor {
private static final String BASE64_DECODE_PREFIX = "base64.decode.";
public static final String BASE64_DECODE_PARAM_BUFFER_SIZE = BASE64_DECODE_PREFIX + "buffer-size";
private final ContentPipe pipe;
private final FineractProperties properties;
@Override
public ContentProcessorContext process(final ContentProcessorContext ctx) {
final Integer bufferSize = ctx.getParameter(BASE64_DECODE_PARAM_BUFFER_SIZE,
requireNonNullElse(properties.getContent().getDefaultBufferSize(), 8192));
final var pipedInputStream = pipe.pipe(ctx.getInputStream(), (in, out) -> {
pipe.write(new Base64InputStream(in), out, new byte[bufferSize]);
});
return ctx.clone(pipedInputStream);
}
}
@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.processor;
import static java.util.Objects.requireNonNullElse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64OutputStream;
import org.apache.fineract.infrastructure.contentstore.util.ContentPipe;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class Base64EncoderContentProcessor implements ContentProcessor {
private static final String BASE64_ENCODE_PREFIX = "base64.encode.";
public static final String BASE64_ENCODE_PARAM_BUFFER_SIZE = BASE64_ENCODE_PREFIX + "buffer-size";
private final ContentPipe pipe;
private final FineractProperties properties;
@Override
public ContentProcessorContext process(final ContentProcessorContext ctx) {
final Integer bufferSize = ctx.getParameter(BASE64_ENCODE_PARAM_BUFFER_SIZE,
requireNonNullElse(properties.getContent().getDefaultBufferSize(), 8192));
final var pipedInputStream = pipe.pipe(ctx.getInputStream(), (in, out) -> {
pipe.write(in, new Base64OutputStream(out), new byte[bufferSize]);
});
return ctx.clone(pipedInputStream);
}
}
@@ -0,0 +1,59 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.processor;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Map;
@FunctionalInterface
public interface ContentProcessor {
String BEAN_NAME_EXECUTOR = "contentProcessorExecutor";
ContentProcessorContext process(ContentProcessorContext context);
default void apply() {
// do nothing by default
}
default ContentProcessorContext process(InputStream is) {
return process(new ContentProcessorContext(is));
}
default ContentProcessorContext process(InputStream is, Map<String, Object> parameters) {
return process(new ContentProcessorContext(is, parameters));
}
default ContentProcessor then(ContentProcessor next) {
return (context) -> next.process(this.process(context));
}
@FunctionalInterface
interface InputOutputStreamConsumer {
void accept(InputStream input, OutputStream output) throws Exception;
}
@FunctionalInterface
interface OutputStreamConsumer {
void accept(OutputStream output) throws Exception;
}
}
@@ -0,0 +1,76 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.processor;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Getter
public final class ContentProcessorContext {
private final InputStream inputStream;
private final Map<String, Object> parameters;
private final Map<String, Object> results;
public ContentProcessorContext(InputStream inputStream) {
this(inputStream, Map.of(), new HashMap<>());
}
public ContentProcessorContext(InputStream inputStream, Map<String, Object> parameters) {
this(inputStream, parameters, new HashMap<>());
}
ContentProcessorContext(InputStream inputStream, Map<String, Object> parameters, Map<String, Object> results) {
this.inputStream = inputStream;
this.parameters = parameters;
this.results = results;
}
public <R> R getParameter(String key) {
return getParameter(key, null);
}
public <R> R getParameter(String key, R defaultValue) {
final var val = parameters.get(key);
return val != null ? (R) val : defaultValue;
}
public void setResult(String key, Object value) {
results.put(key, value);
}
public <R> R getResult(String key) {
return getResult(key, null);
}
public <R> R getResult(String key, R defaultValue) {
final var val = results.get(key);
return val != null ? (R) val : defaultValue;
}
public ContentProcessorContext clone(InputStream inputStream) {
return new ContentProcessorContext(inputStream, this.parameters, this.results);
}
}
@@ -0,0 +1,153 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.processor;
import static java.util.Objects.requireNonNullElse;
import java.io.ByteArrayOutputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.contentstore.util.ContentPipe;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class DataUrlDecoderContentProcessor implements ContentProcessor {
private static final String DATA_URL_PREFIX = "dataurl.decode.";
public static final String DATA_URL_DECODE_RESULT_CONTENT_TYPE = DATA_URL_PREFIX + "result.content-type";
public static final String DATA_URL_DECODE_RESULT_ENCODING = DATA_URL_PREFIX + "result.encoding";
public static final String DATA_URL_DECODE_PARAM_BUFFER_SIZE = DATA_URL_PREFIX + "param.buffer-size";
private final ContentPipe pipe;
private final FineractProperties properties;
@Override
public ContentProcessorContext process(final ContentProcessorContext ctx) {
final Integer bufferSize = ctx.getParameter(DATA_URL_DECODE_PARAM_BUFFER_SIZE,
requireNonNullElse(properties.getContent().getDefaultBufferSize(), 8192));
final var pipedInputStream = pipe.pipe(ctx.getInputStream(), (in, out) -> {
var dataUrlIn = new DataUrlInputStream(in);
ctx.setResult(DATA_URL_DECODE_RESULT_CONTENT_TYPE, dataUrlIn.getContentType());
ctx.setResult(DATA_URL_DECODE_RESULT_ENCODING, dataUrlIn.getEncoding());
pipe.write(dataUrlIn, out, new byte[bufferSize]);
});
return ctx.clone(pipedInputStream);
}
private static class DataUrlInputStream extends FilterInputStream {
private static final String DEFAULT_MEDIA_TYPE = "text/plain;charset=US-ASCII";
private static final String ENCODING_BASE64 = "base64";
private static final String ENCODING_URL = "urlencoded";
private static final String META_PART = "data:";
private static final char COMMA_CHAR = ',';
private static final char SEMICOLON_CHAR = ';';
private volatile String contentType;
private volatile String encoding;
DataUrlInputStream(InputStream in) {
super(in);
}
private void readHeaderProcessed() throws IOException {
if (StringUtils.isNotEmpty(contentType)) {
return;
}
final var headerBuffer = new ByteArrayOutputStream(128);
int b;
while ((b = in.read()) != -1) {
if (b == COMMA_CHAR) {
// stop reading header
break;
}
headerBuffer.write(b);
}
var header = headerBuffer.toString(StandardCharsets.UTF_8);
parseHeader(header);
}
private void parseHeader(String header) {
if (!header.startsWith(META_PART)) {
this.contentType = DEFAULT_MEDIA_TYPE;
this.encoding = ENCODING_URL;
return;
}
String metaPart = header.substring(5); // Remove "data:"
if (metaPart.endsWith(SEMICOLON_CHAR + ENCODING_BASE64)) {
this.encoding = ENCODING_BASE64;
// remove ";base64"
metaPart = metaPart.substring(0, metaPart.length() - ENCODING_BASE64.length() - 1);
} else {
this.encoding = ENCODING_URL;
}
if (metaPart.isEmpty()) {
this.contentType = DEFAULT_MEDIA_TYPE;
} else {
this.contentType = metaPart;
}
}
String getContentType() throws IOException {
readHeaderProcessed();
return contentType;
}
String getEncoding() throws IOException {
readHeaderProcessed();
return encoding;
}
@Override
public int read() throws IOException {
readHeaderProcessed();
return in.read();
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
readHeaderProcessed();
return in.read(b, off, len);
}
}
}
@@ -0,0 +1,154 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.processor;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNullElse;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.contentstore.util.ContentPipe;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class DataUrlEncoderContentProcessor implements ContentProcessor {
private static final String DATA_URL_ENCODE_PREFIX = "dataurl.encode.";
public static final String DATA_URL_ENCODE_PARAM_BUFFER_SIZE = DATA_URL_ENCODE_PREFIX + "param.buffer-size";
public static final String DATA_URL_ENCODE_PARAM_CONTENT_TYPE = DATA_URL_ENCODE_PREFIX + "param.content-type";
public static final String DATA_URL_ENCODE_PARAM_ENCODING = DATA_URL_ENCODE_PREFIX + "param.encoding";
private final ContentPipe pipe;
private final FineractProperties properties;
@Override
public ContentProcessorContext process(final ContentProcessorContext ctx) {
final Integer bufferSize = ctx.getParameter(DATA_URL_ENCODE_PARAM_BUFFER_SIZE,
requireNonNullElse(properties.getContent().getDefaultBufferSize(), 8192));
final String contentType = ctx.getParameter(DATA_URL_ENCODE_PARAM_CONTENT_TYPE, "text/plain");
final String encoding = ctx.getParameter(DATA_URL_ENCODE_PARAM_ENCODING, "charset=US-ASCII");
final var pipedInputStream = pipe.pipe(ctx.getInputStream(), (in, out) -> {
pipe.write(new DataUrlEncoderInputStream(in, contentType, encoding), out, new byte[bufferSize]);
});
return ctx.clone(pipedInputStream);
}
private static class DataUrlEncoderInputStream extends FilterInputStream {
private static final String META_PART = "data:";
private static final char COMMA_CHAR = ',';
private static final char SEMICOLON_CHAR = ';';
private final byte[] prefix;
private int prefixIndex = 0;
DataUrlEncoderInputStream(InputStream in, String contentType, String encoding) {
super(in);
this.prefix = (META_PART + contentType + SEMICOLON_CHAR + encoding + COMMA_CHAR).getBytes(UTF_8);
}
private boolean isPrefixConsumed() {
return prefixIndex >= prefix.length;
}
@Override
public int read() throws IOException {
if (!isPrefixConsumed()) {
// return the next byte of the prefix
// we need to cast to int and mask with 0xFF to ensure positive value (0-255)
return prefix[prefixIndex++] & 0xFF;
}
// served the prefix, now the real stream starts
return in.read();
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
// optimization: do not call super.read(b, off, len) as that might call
// the single-byte read() repeatedly, which is slow.
if (isPrefixConsumed()) {
return in.read(b, off, len);
}
// still have prefix data to write
int availableInPrefix = prefix.length - prefixIndex;
// fill the buffer with remaining prefix bytes (or as much as requested)
int bytesToCopyFromPrefix = Math.min(len, availableInPrefix);
System.arraycopy(prefix, prefixIndex, b, off, bytesToCopyFromPrefix);
prefixIndex += bytesToCopyFromPrefix;
// if we filled the request entirely with the prefix, return immediately
if (bytesToCopyFromPrefix == len) {
return len;
}
// if we still have space in the buffer, fill the rest from the underlying stream
int bytesToReadFromStream = len - bytesToCopyFromPrefix;
int bytesReadFromStream = in.read(b, off + bytesToCopyFromPrefix, bytesToReadFromStream);
if (bytesReadFromStream == -1) {
// underlying stream is empty, but we did write the prefix
return bytesToCopyFromPrefix;
}
return bytesToCopyFromPrefix + bytesReadFromStream;
}
@Override
public long skip(long n) throws IOException {
if (isPrefixConsumed()) {
return in.skip(n);
}
long availableInPrefix = Integer.valueOf(prefix.length - prefixIndex).longValue();
long skippableInPrefix = Math.min(n, availableInPrefix);
prefixIndex += Long.valueOf(skippableInPrefix).intValue();
long remainingToSkip = n - skippableInPrefix;
if (remainingToSkip > 0) {
return skippableInPrefix + in.skip(remainingToSkip);
}
return skippableInPrefix;
}
@Override
public int available() throws IOException {
if (isPrefixConsumed()) {
return in.available();
}
// total available is prefix remaining + stream available
return (prefix.length - prefixIndex) + in.available();
}
}
}
@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.processor;
import static java.util.Objects.requireNonNullElse;
import java.util.zip.GZIPInputStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.contentstore.util.ContentPipe;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class GzipDecoderContentProcessor implements ContentProcessor {
private static final String GZIP_DECODE_PREFIX = "gzip.decode.";
public static final String GZIP_DECODE_PARAM_BUFFER_SIZE = GZIP_DECODE_PREFIX + "buffer-size";
private final ContentPipe pipe;
private final FineractProperties properties;
@Override
public ContentProcessorContext process(final ContentProcessorContext ctx) {
final Integer bufferSize = ctx.getParameter(GZIP_DECODE_PARAM_BUFFER_SIZE,
requireNonNullElse(properties.getContent().getDefaultBufferSize(), 8192));
final var pipedInputStream = pipe.pipe(ctx.getInputStream(), (in, out) -> {
pipe.write(new GZIPInputStream(in), out, new byte[bufferSize]);
});
return ctx.clone(pipedInputStream);
}
}
@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.processor;
import static java.util.Objects.requireNonNullElse;
import java.util.zip.GZIPOutputStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.contentstore.util.ContentPipe;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class GzipEncoderContentProcessor implements ContentProcessor {
private static final String GZIP_ENCODE_PREFIX = "gzip.encode.";
public static final String GZIP_ENCODE_PARAM_BUFFER_SIZE = GZIP_ENCODE_PREFIX + "buffer-size";
private final ContentPipe pipe;
private final FineractProperties properties;
@Override
public ContentProcessorContext process(final ContentProcessorContext ctx) {
final Integer bufferSize = ctx.getParameter(GZIP_ENCODE_PARAM_BUFFER_SIZE,
requireNonNullElse(properties.getContent().getDefaultBufferSize(), 8192));
final var pipedInputStream = pipe.pipe(ctx.getInputStream(), (in, out) -> {
pipe.write(in, new GZIPOutputStream(out), new byte[bufferSize]);
});
return ctx.clone(pipedInputStream);
}
}
@@ -0,0 +1,110 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.processor;
import static java.util.Objects.requireNonNull;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.util.regex.Pattern;
import javax.imageio.ImageIO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.contentstore.exception.ContentProcessorException;
import org.apache.fineract.infrastructure.contentstore.util.ContentPipe;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
@Slf4j
@RequiredArgsConstructor
@Component
public class ImageResizeContentProcessor implements ContentProcessor {
private static final String IMAGE_RESIZE_PREFIX = "image.resize.";
public static final String IMAGE_RESIZE_PARAM_MAX_WIDTH = IMAGE_RESIZE_PREFIX + "max-width";
public static final String IMAGE_RESIZE_PARAM_MAX_HEIGHT = IMAGE_RESIZE_PREFIX + "max-height";
public static final String IMAGE_RESIZE_PARAM_FORMAT = IMAGE_RESIZE_PREFIX + "format";
private final ContentPipe pipe;
private static final Pattern VALID_IMAGE_FORMATS = Pattern.compile("^(gif|jpg|jpeg|png)$", Pattern.CASE_INSENSITIVE);
@Override
public ContentProcessorContext process(final ContentProcessorContext ctx) {
final Integer maxWidth = ctx.getParameter(IMAGE_RESIZE_PARAM_MAX_WIDTH);
final Integer maxHeight = ctx.getParameter(IMAGE_RESIZE_PARAM_MAX_HEIGHT);
final String format = ctx.getParameter(IMAGE_RESIZE_PARAM_FORMAT, "jpg");
requireNonNull(maxWidth, "Missing max width parameter");
requireNonNull(maxHeight, "Missing max height parameter");
if (!VALID_IMAGE_FORMATS.matcher(format).matches()) {
throw new ContentProcessorException(String.format("Wrong image format parameter: %s", format));
}
final var pipedInputStream = pipe.pipe(ctx.getInputStream(), (in, out) -> {
final var image = resize(in, maxWidth, maxHeight);
// compresses the image into the specified format (e.g., "gif", "jpeg", "png")
ImageIO.write(image, format, out);
});
return ctx.clone(pipedInputStream);
}
private BufferedImage resize(InputStream is, int targetWidth, int targetHeight) throws IOException {
final var imageIs = ImageIO.createImageInputStream(StreamUtils.nonClosing(is));
final var readers = ImageIO.getImageReaders(imageIs);
if (!readers.hasNext()) {
throw new ContentProcessorException("No image reader found for format");
}
final var reader = readers.next();
try {
reader.setInput(imageIs, true, true);
// get original dimensions to calculate subsampling ratio
int originalWidth = reader.getWidth(0);
int originalHeight = reader.getHeight(0);
// calculate subsampling ratio
// subsampling is an integer ratio of source size to output size
int subsampleX = Math.max(1, originalWidth / targetWidth);
int subsampleY = Math.max(1, originalHeight / targetHeight);
int subsampling = Math.max(subsampleX, subsampleY);
final var param = reader.getDefaultReadParam();
param.setSourceSubsampling(subsampling, subsampling, 0, 0);
// image will be close to the target size, but not exact,
// as subsampling only works with integer ratios. Further quality scaling
// can be done on the smaller BufferedImage if exact dimensions are needed.
// read the image using the parameters
return reader.read(0, param);
} finally {
reader.dispose();
}
}
}
@@ -0,0 +1,80 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.processor;
import java.io.FilterInputStream;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class SizeContentProcessor implements ContentProcessor {
private static final String SIZE_PREFIX = "size.";
public static final String SIZE_RESULT_VALUE = SIZE_PREFIX + "result.value";
@Override
public ContentProcessorContext process(ContentProcessorContext ctx) {
return ctx.clone(new ByteCountingInputStream(ctx));
}
private static final class ByteCountingInputStream extends FilterInputStream {
private final ContentProcessorContext ctx;
private long byteCount = 0;
ByteCountingInputStream(ContentProcessorContext ctx) {
super(ctx.getInputStream());
this.ctx = ctx;
}
@Override
public int read() throws IOException {
int data = in.read();
if (data != -1) {
byteCount++;
}
return data;
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
int count = in.read(b, off, len);
if (count != -1) {
byteCount += count;
}
return count;
}
@Override
public void close() throws IOException {
ctx.setResult(SIZE_RESULT_VALUE, byteCount);
super.close();
}
}
}
@@ -0,0 +1,40 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.service;
import java.io.InputStream;
import org.apache.fineract.infrastructure.contentstore.data.ContentStoreType;
public interface ContentStoreService {
String EXTENSION_REGEX = "^.+\\..+$";
String DELIMITER = "/";
InputStream download(String path);
String upload(String path, InputStream is, String mimeType);
void delete(String path);
ContentStoreType getType();
default String getDelimiter() {
return DELIMITER;
}
}
@@ -0,0 +1,198 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.service;
import static java.util.Objects.requireNonNull;
import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.contentstore.data.ContentStoreType;
import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorContext;
import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorManager;
import org.apache.fineract.infrastructure.contentstore.exception.ContentPolicyException;
import org.apache.fineract.infrastructure.contentstore.exception.ContentStoreException;
import org.apache.fineract.infrastructure.contentstore.policy.ContentPolicyContext;
import org.apache.fineract.infrastructure.contentstore.policy.DefaultDeleteContentPolicy;
import org.apache.fineract.infrastructure.contentstore.policy.DefaultDownloadContentPolicy;
import org.apache.fineract.infrastructure.contentstore.policy.DefaultPostUploadContentPolicy;
import org.apache.fineract.infrastructure.contentstore.policy.DefaultPreUploadContentPolicy;
import org.apache.fineract.infrastructure.contentstore.util.ContentPathRandomizer;
import org.apache.fineract.infrastructure.contentstore.util.ContentPathSanitizer;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
@Slf4j
@RequiredArgsConstructor
@Service
@ConditionalOnProperty(name = "fineract.content.filesystem.enabled", havingValue = "true")
public class FileContentStoreService implements ContentStoreService {
private final ContentPathSanitizer pathSanitizer;
private final ContentPathRandomizer pathRandomizer;
private final DefaultDownloadContentPolicy downloadContentPolicy;
private final DefaultPreUploadContentPolicy preUploadContentPolicy;
private final DefaultPostUploadContentPolicy postUploadContentPolicy;
private final DefaultDeleteContentPolicy deleteContentPolicy;
private final ContentDetectorManager contentDetectorManager;
private final FineractProperties properties;
@Override
public InputStream download(String path) {
downloadContentPolicy.check(ContentPolicyContext.builder().path(path).build());
final var safePath = pathSanitizer.sanitize(path);
try {
final var target = getPath(safePath, false);
return Files.newInputStream(target);
} catch (IOException e) {
throw new ContentStoreException(e);
}
}
@Override
public String upload(String path, InputStream is, String mimeType) {
requireNonNull(path, "Path missing");
requireNonNull(is, "Data missing");
if (StringUtils.isEmpty(mimeType)) {
mimeType = Optional.ofNullable(contentDetectorManager.detect(ContentDetectorContext.builder().fileName(path).build()))
.map(ContentDetectorContext::getMimeType).orElse(APPLICATION_OCTET_STREAM_VALUE);
}
preUploadContentPolicy.check(ContentPolicyContext.builder().path(path).mimeType(mimeType).build());
var safePath = pathSanitizer.sanitize(path);
final var target = Files.exists(getPath(safePath, false)) ? getPath(safePath, false) : getPath(safePath, true);
try (var in = is) {
IOUtils.copy(in, new FileOutputStream(target.toString()));
} catch (Exception e) {
throw new ContentStoreException(e);
}
final var relativePath = getRelativePath(target);
try {
postUploadContentPolicy
.check(ContentPolicyContext.builder().path(path).inputStream(getInputStream(target)).mimeType(mimeType).build());
} catch (ContentPolicyException e) {
log.warn("Delete file because it didn't comply with the post-upload policy check: {} - {}", target, e.getMessage());
delete(relativePath.toString());
throw e;
}
return relativePath.toString();
}
@Override
public void delete(String path) {
deleteContentPolicy.check(ContentPolicyContext.builder().path(path).build());
final var safePath = pathSanitizer.sanitize(path);
try {
final var target = getPath(safePath, false);
final boolean deleted = Files.deleteIfExists(target);
if (!hasFiles(target.getParent())) {
Files.deleteIfExists(target.getParent());
}
if (!deleted) {
// no need to throw an Error, what's a caller going to do about it, so simply log a warning
log.warn("Unable to delete file {}", safePath);
}
} catch (Exception e) {
throw new ContentStoreException(e);
}
}
@Override
public ContentStoreType getType() {
return ContentStoreType.FILE_SYSTEM;
}
private Path getPath(String path, boolean randomize) {
Path p;
if (randomize) {
var tail = Path.of(path);
var file = tail.getFileName();
p = getRootPath().resolve(tail.getParent()).resolve(pathRandomizer.randomize()).resolve(file);
try {
if (Files.notExists(p.getParent())) {
Files.createDirectories(p.getParent());
}
} catch (Exception e) {
throw new ContentStoreException(e);
}
} else {
p = getRootPath().resolve(path);
}
return p;
}
private Path getRelativePath(Path fullPath) {
return getRootPath().relativize(fullPath);
}
private Path getRootPath() {
return Path.of(properties.getContent().getFilesystem().getRootFolder(),
ThreadLocalContextUtil.getTenant().getName().replaceAll(" ", "").trim());
}
private InputStream getInputStream(Path path) {
try {
return Files.newInputStream(path);
} catch (IOException ioe) {
throw new ContentStoreException(ioe);
}
}
boolean hasFiles(Path folder) throws IOException {
if (Files.exists(folder) && Files.isDirectory(folder)) {
try (Stream<Path> entries = Files.list(folder)) {
return entries.anyMatch(Files::isRegularFile);
}
}
return false;
}
}
@@ -0,0 +1,126 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.service;
import static java.util.Objects.requireNonNull;
import java.io.InputStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.contentstore.data.ContentStoreType;
import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorManager;
import org.apache.fineract.infrastructure.contentstore.exception.ContentPolicyException;
import org.apache.fineract.infrastructure.contentstore.exception.ContentStoreException;
import org.apache.fineract.infrastructure.contentstore.policy.ContentPolicyContext;
import org.apache.fineract.infrastructure.contentstore.policy.DefaultDeleteContentPolicy;
import org.apache.fineract.infrastructure.contentstore.policy.DefaultDownloadContentPolicy;
import org.apache.fineract.infrastructure.contentstore.policy.DefaultPostUploadContentPolicy;
import org.apache.fineract.infrastructure.contentstore.policy.DefaultPreUploadContentPolicy;
import org.apache.fineract.infrastructure.contentstore.util.ContentPathSanitizer;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.core.sync.ResponseTransformer;
import software.amazon.awssdk.http.ContentStreamProvider;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
@Slf4j
@RequiredArgsConstructor
@Service
@ConditionalOnProperty(name = "fineract.content.s3.enabled", havingValue = "true")
public class S3ContentStoreService implements ContentStoreService {
private final S3Client s3Client;
private final ContentPathSanitizer pathSanitizer;
private final DefaultDownloadContentPolicy downloadContentPolicy;
private final DefaultPreUploadContentPolicy preUploadContentPolicy;
private final DefaultPostUploadContentPolicy postUploadContentPolicy;
private final DefaultDeleteContentPolicy deleteContentPolicy;
private final ContentDetectorManager contentDetectorManager;
private final FineractProperties properties;
@Override
public InputStream download(String path) {
downloadContentPolicy.check(ContentPolicyContext.builder().path(path).build());
final var safePath = pathSanitizer.sanitize(path);
try {
return s3Client
.getObject(GetObjectRequest.builder().bucket(properties.getContent().getS3().getBucketName()).key(safePath).build(),
ResponseTransformer.toBytes())
.asInputStream();
} catch (Exception e) {
throw new ContentStoreException(e);
}
}
@Override
public String upload(String path, InputStream is, String mimeType) {
requireNonNull(path, "Path missing");
requireNonNull(is, "Data missing");
preUploadContentPolicy.check(ContentPolicyContext.builder().path(path).mimeType(mimeType).build());
var safePath = pathSanitizer.sanitize(path);
try {
final var p = safePath;
s3Client.putObject(builder -> builder.bucket(properties.getContent().getS3().getBucketName()).key(p),
RequestBody.fromContentProvider(ContentStreamProvider.fromInputStream(is), mimeType));
} catch (Exception e) {
throw new ContentStoreException(e);
}
try {
// TODO: verify if this is working; maybe there is a better way (e.g. temporary storage on file system)
postUploadContentPolicy
.check(ContentPolicyContext.builder().path(path).inputStream(download(safePath)).mimeType(mimeType).build());
} catch (ContentPolicyException e) {
log.warn("Delete file because it didn't comply with the post-upload policy check: {} - {}", safePath, e.getMessage());
delete(safePath);
throw e;
}
return safePath;
}
@Override
public void delete(String path) {
deleteContentPolicy.check(ContentPolicyContext.builder().path(path).build());
final var safePath = pathSanitizer.sanitize(path);
try {
s3Client.deleteObject(builder -> builder.bucket(properties.getContent().getS3().getBucketName()).key(safePath));
} catch (Exception e) {
throw new ContentStoreException(e);
}
}
@Override
public ContentStoreType getType() {
return ContentStoreType.S3;
}
}
@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.util;
public interface ContentPathRandomizer {
String randomize();
}
@@ -16,13 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.documentmanagement.contentrepository;
import java.io.BufferedInputStream;
package org.apache.fineract.infrastructure.contentstore.util;
public interface ContentPathSanitizer {
String sanitize(String path);
String sanitize(String path, BufferedInputStream is);
}
@@ -0,0 +1,169 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.util;
import static java.util.Objects.requireNonNullElse;
import static org.apache.fineract.infrastructure.contentstore.processor.ContentProcessor.BEAN_NAME_EXECUTOR;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.contentstore.exception.ContentProcessorException;
import org.apache.fineract.infrastructure.contentstore.processor.ContentProcessor;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public final class ContentPipe {
private final ExecutorService executor;
private final FineractProperties properties;
public ContentPipe(@Qualifier(BEAN_NAME_EXECUTOR) ExecutorService executor, FineractProperties properties) {
this.executor = executor;
this.properties = properties;
}
public InputStream pipe(ContentProcessor.OutputStreamConsumer consumer) {
var pis = new PipedInputStream(requireNonNullElse(properties.getContent().getDefaultBufferSize(), 8192));
var pos = newPipedOutputStream(pis);
var future = executor.submit(() -> {
try (var os = pos) {
consumer.accept(os);
} catch (Throwable e) {
// if an error occurs, the pipe will close
throw new ContentProcessorException(new IOException(e));
}
});
return new FutureInputStream(pis, future);
}
public InputStream pipe(InputStream inputStream, ContentProcessor.InputOutputStreamConsumer consumer) {
// Java default buffer is 1024 bytes
var pis = new PipedInputStream(requireNonNullElse(properties.getContent().getDefaultBufferSize(), 8192));
var pos = newPipedOutputStream(pis);
// submit the work to the executor
var future = executor.submit(() -> {
try (var os = pos; var is = inputStream) {
// run the user logic (e.g., zipping, resizing)
consumer.accept(is, os);
// flushing is handled by try-with-resources close()
} catch (Throwable e) {
// if an error occurs, the pipe will close
throw new ContentProcessorException(new IOException(e));
}
});
// tracks errors
return new FutureInputStream(pis, future);
}
public void write(InputStream is, OutputStream os, byte[] buffer) throws IOException {
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
os.write(buffer, 0, bytesRead);
}
}
private PipedOutputStream newPipedOutputStream(PipedInputStream pis) {
try {
return new PipedOutputStream(pis);
} catch (IOException e) {
throw new ContentProcessorException(e);
}
}
private static class FutureInputStream extends InputStream {
private final PipedInputStream delegate;
private final Future<?> future;
FutureInputStream(PipedInputStream delegate, Future<?> future) {
this.delegate = delegate;
this.future = future;
}
@SuppressWarnings("AvoidHidingCauseException")
private void checkException() throws IOException {
// if the task is done, check for exceptions
if (future.isDone()) {
try {
// throws "ExecutionException" if task failed
future.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Processing interrupted", e);
} catch (ExecutionException e) {
// unwrap cause
Throwable cause = e.getCause();
if (cause instanceof IOException ioe) {
throw ioe;
}
throw new IOException("Processing failed in worker thread", cause);
}
}
}
@Override
public int read() throws IOException {
checkException();
int data = delegate.read();
// check EOF because of error
checkException();
return data;
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
checkException();
int count = delegate.read(b, off, len);
checkException();
return count;
}
@Override
public void close() throws IOException {
delegate.close();
// good practice
future.cancel(true);
}
}
}
@@ -0,0 +1,39 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.util;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class DefaultContentPathRandomizer implements ContentPathRandomizer {
private final FineractProperties properties;
@Override
public String randomize() {
// TODO: make length configurable
return RandomStringUtils.secureStrong().randomAlphabetic(16);
}
}
@@ -0,0 +1,47 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.contentstore.util;
import java.nio.file.Path;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class DefaultContentPathSanitizer implements ContentPathSanitizer {
private final FineractProperties properties;
@Override
public String sanitize(String path) {
final var sanitizedPath = Path.of(path).normalize().toString();
if (log.isDebugEnabled()) {
final var fileName = FilenameUtils.getName(sanitizedPath).toLowerCase();
log.debug("Path: {} -> {} ({})", path, sanitizedPath, fileName);
}
return sanitizedPath;
}
}
@@ -1,61 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.api;
import com.google.common.io.ByteSource;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.ResponseBuilder;
import java.io.IOException;
import java.io.InputStream;
import org.apache.fineract.infrastructure.documentmanagement.data.FileData;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Utilities common to file upload/download resources.
*
* @author Michael Vorburger.ch
*/
final class ContentResources {
private static final Logger LOG = LoggerFactory.getLogger(ContentResources.class);
private ContentResources() {}
static Response fileDataToResponse(FileData fileData, String fileName, String dispositionType) {
ResponseBuilder response;
try {
ByteSource byteSource = fileData.getByteSource();
// TODO Where is this InputStream closed?! It needs to be AFTER it's read by JAX-RS.. how to do that?
InputStream is = byteSource.openBufferedStream();
response = Response.ok(is);
response.header("Content-Disposition", dispositionType + "; filename=\"" + fileName + "\"");
response.header("Content-Length", byteSource.sizeIfKnown().or(-1L));
response.header("Content-Type", fileData.contentType());
} catch (IOException e) {
LOG.error("resizedImage.getByteSource().openBufferedStream() failed", e);
response = Response.serverError();
}
return response.build();
}
static Response fileDataToResponse(FileData fileData, String dispositionType) {
return fileDataToResponse(fileData, fileData.name(), dispositionType);
}
}
@@ -0,0 +1,31 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.api;
public final class DocumentApiConstants {
private DocumentApiConstants() {}
public static final String DOCUMENT_API_PARAM_ENTITY_TYPE = "entityType";
public static final String DOCUMENT_API_PARAM_ENTITY_ID = "entityId";
public static final String DOCUMENT_API_PARAM_DOCUMENT_ID = "documentId";
public static final String DOCUMENT_API_PARAM_FILE = "file";
public static final String DOCUMENT_API_PARAM_NAME = "name";
public static final String DOCUMENT_API_PARAM_DESCRIPTION = "description";
}
@@ -0,0 +1,253 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.api;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_DESCRIPTION;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_DOCUMENT_ID;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_ENTITY_ID;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_ENTITY_TYPE;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_FILE;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_NAME;
import static org.apache.fineract.util.StreamResponseUtil.DISPOSITION_TYPE_ATTACHMENT;
import static org.springframework.http.HttpHeaders.CONTENT_LENGTH;
import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.InputStream;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.CommandPipeline;
import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorContext;
import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorManager;
import org.apache.fineract.infrastructure.documentmanagement.command.DocumentCreateCommand;
import org.apache.fineract.infrastructure.documentmanagement.command.DocumentDeleteCommand;
import org.apache.fineract.infrastructure.documentmanagement.command.DocumentUpdateCommand;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateRequest;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteRequest;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteResponse;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentUpdateRequest;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentUpdateResponse;
import org.apache.fineract.infrastructure.documentmanagement.service.DocumentReadPlatformService;
import org.apache.fineract.util.StreamResponseUtil;
import org.glassfish.jersey.media.multipart.FormDataBodyPart;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataParam;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@Path("/v1/{entityType}/{entityId}/documents")
@Tag(name = "Documents", description = """
Multiple Documents (a combination of a name, description and a file) may be attached to different entities like clients, groups, staff, loans, savings and client identifiers in the system
Note: the currently allowed entities are
- clients: URL pattern as clients
- staff: URL pattern as staff
- loans: URL pattern as loans
- savings: URL pattern as savings
- client identifiers: URL pattern as client_identifiers
- groups: URL pattern as groups
""")
@RequiredArgsConstructor
public class DocumentApiResource {
private final DocumentReadPlatformService documentReadPlatformService;
private final FileUploadValidator fileUploadValidator;
private final ContentDetectorManager contentDetectorManager;
private final CommandPipeline commandPipeline;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List documents", description = """
Example Requests:
- clients/1/documents
- client_identifiers/1/documents
- loans/1/documents?fields=name,description
""")
public List<DocumentData> retrieveAllDocuments(@PathParam(DOCUMENT_API_PARAM_ENTITY_TYPE) final String entityType,
@PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId) {
return documentReadPlatformService.retrieveAllDocuments(entityType, entityId);
}
@GET
@Path("{documentId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a Document", description = """
Example Requests:
- clients/1/documents/1
- loans/1/documents/1
- client_identifiers/1/documents/1?fields=name,description
""")
public DocumentData getDocument(@PathParam(DOCUMENT_API_PARAM_ENTITY_TYPE) final String entityType,
@PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId,
@PathParam(DOCUMENT_API_PARAM_DOCUMENT_ID) final Long documentId) {
return documentReadPlatformService.retrieveDocument(entityType, entityId, documentId);
}
@GET
@Path("{documentId}/attachment")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_OCTET_STREAM })
@Operation(summary = "Retrieve Binary File associated with Document", description = """
Request used to download the file associated with the document
Example Requests:
- clients/1/documents/1/attachment
- loans/1/documents/1/inline
""")
@ApiResponse(responseCode = "200", description = "Not Shown: The corresponding Binary file")
public Response downloadFile(@PathParam(DOCUMENT_API_PARAM_ENTITY_TYPE) final String entityType,
@PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId,
@PathParam(DOCUMENT_API_PARAM_DOCUMENT_ID) final Long documentId) {
final var content = documentReadPlatformService.retrieveDocumentContent(entityType, entityId, documentId);
return StreamResponseUtil
.ok(StreamResponseUtil.StreamResponseData.builder().type(content.getContentType()).fileName(content.getFileName())
.dispositionType(DISPOSITION_TYPE_ATTACHMENT).stream(content.getStream()).size(content.getSize()).build());
}
@POST
@Consumes({ MediaType.MULTIPART_FORM_DATA })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a Document", description = """
Note: A document is created using a Multi-part form upload
Body parts
- name : name or summary of the document
- description : description of the document
- file : the file to be uploaded
Mandatory fields :
- file
- description
""")
@ApiResponse(responseCode = "200", description = "Not Shown (multi-part form data)")
public DocumentCreateResponse createDocument(@PathParam(DOCUMENT_API_PARAM_ENTITY_TYPE) final String entityType,
@PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId, @HeaderParam(CONTENT_LENGTH) final Long fileSize,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final InputStream is,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataContentDisposition fileDetails,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataBodyPart filePart,
@FormDataParam(DOCUMENT_API_PARAM_NAME) final String name,
@FormDataParam(DOCUMENT_API_PARAM_DESCRIPTION) final String description) {
fileUploadValidator.validate(fileSize, is, fileDetails, filePart);
final var command = new DocumentCreateCommand();
var type = Optional.ofNullable(filePart.getMediaType()).map(MediaType::toString)
.or(() -> Optional.of(contentDetectorManager
.detect(ContentDetectorContext.builder().fileName(fileDetails.getFileName()).build()).getMimeType()))
.orElse(APPLICATION_OCTET_STREAM_VALUE);
command.setPayload(DocumentCreateRequest.builder().entityId(entityId).entityType(entityType).name(name).description(description)
.fileName(fileDetails.getFileName()).size(fileSize).type(type).stream(is).build());
final Supplier<DocumentCreateResponse> response = commandPipeline.send(command);
return response.get();
}
@PUT
@Path("{documentId}")
@Consumes({ MediaType.MULTIPART_FORM_DATA })
@Produces({ MediaType.APPLICATION_JSON })
// @RequestBody(description = "Update document", content = { @Content(mediaType = MediaType.MULTIPART_FORM_DATA) })
@Operation(summary = "Update a Document", description = """
Note: A document is updated using a Multi-part form upload
Body Parts
- name: name or summary of the document
- description: description of the document
- file: the file to be uploaded
""")
@ApiResponse(responseCode = "200", description = "Not Shown (multi-part form data)")
public DocumentUpdateResponse updateDocument(@PathParam(DOCUMENT_API_PARAM_ENTITY_TYPE) final String entityType,
@PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId, @PathParam(DOCUMENT_API_PARAM_DOCUMENT_ID) final Long documentId,
@HeaderParam(CONTENT_LENGTH) final Long fileSize, @FormDataParam(DOCUMENT_API_PARAM_FILE) final InputStream is,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataContentDisposition fileDetails,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataBodyPart filePart,
@FormDataParam(DOCUMENT_API_PARAM_NAME) final String name,
@FormDataParam(DOCUMENT_API_PARAM_DESCRIPTION) final String description) {
final var command = new DocumentUpdateCommand();
final var request = DocumentUpdateRequest.builder().id(documentId).entityId(entityId).entityType(entityType).name(name)
.description(description).stream(is);
if (fileDetails != null) {
request.fileName(fileDetails.getFileName()).type(fileDetails.getType()).size(fileSize);
}
command.setPayload(DocumentUpdateRequest.builder().id(documentId).entityId(entityId).entityType(entityType).name(name)
.description(description).stream(is).build());
final Supplier<DocumentUpdateResponse> response = commandPipeline.send(command);
// TODO: does not return list of changes, should be done for consistency with rest of API
return response.get();
}
@DELETE
@Path("{documentId}")
@Operation(summary = "Remove a Document", description = """
""")
@ApiResponse(responseCode = "200", description = "OK")
public DocumentDeleteResponse deleteDocument(@PathParam(DOCUMENT_API_PARAM_ENTITY_TYPE) final String entityType,
@PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId,
@PathParam(DOCUMENT_API_PARAM_DOCUMENT_ID) final Long documentId) {
final var command = new DocumentDeleteCommand();
command.setPayload(DocumentDeleteRequest.builder().id(documentId).entityId(entityId).entityType(entityType).build());
final Supplier<DocumentDeleteResponse> response = commandPipeline.send(command);
return response.get();
}
}
@@ -1,249 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.InputStream;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.documentmanagement.command.DocumentCommand;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.data.FileData;
import org.apache.fineract.infrastructure.documentmanagement.service.DocumentReadPlatformService;
import org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.glassfish.jersey.media.multipart.FormDataBodyPart;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataParam;
import org.springframework.stereotype.Component;
@Component
@Path("/v1/{entityType}/{entityId}/documents")
@Tag(name = "Documents", description = """
Multiple Documents (a combination of a name, description and a file) may be attached to different Entities like Clients, Groups, Staff, Loans, Savings and Client Identifiers in the system
Note: The currently allowed Entities are
Clients: URL Pattern as clients
Staff: URL Pattern as staff
Loans: URL Pattern as loans
Savings: URL Pattern as savings
Client Identifiers: URL Pattern as client_identifiers
Groups: URL Pattern as groups""")
@RequiredArgsConstructor
public class DocumentManagementApiResource {
private static final String SYSTEM_ENTITY_TYPE = "DOCUMENT";
private final PlatformSecurityContext context;
private final DocumentReadPlatformService documentReadPlatformService;
private final DocumentWritePlatformService documentWritePlatformService;
private final FileUploadValidator fileUploadValidator;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List documents", description = """
Example Requests:
clients/1/documents
client_identifiers/1/documents
loans/1/documents?fields=name,description""")
public List<DocumentData> retrieveAllDocuments(@PathParam("entityType") @Parameter(description = "entityType") final String entityType,
@PathParam("entityId") @Parameter(description = "entityId") final Long entityId) {
context.authenticatedUser().validateHasReadPermission(SYSTEM_ENTITY_TYPE);
return documentReadPlatformService.retrieveAllDocuments(entityType, entityId);
}
@POST
@Consumes({ MediaType.MULTIPART_FORM_DATA })
@Produces({ MediaType.APPLICATION_JSON })
@RequestBody(description = "Create document", content = {
@Content(mediaType = MediaType.MULTIPART_FORM_DATA, schema = @Schema(implementation = DocumentManagementApiResourceSwagger.DocumentUploadRequest.class)) })
@Operation(summary = "Create a Document", description = """
Note: A document is created using a Multi-part form upload\s
Body Parts
name :\s
Name or summary of the document
description :\s
Description of the document
file :\s
The file to be uploaded
Mandatory Fields :\s
file and description""")
@ApiResponse(responseCode = "200", description = "Not Shown (multi-part form data)", content = @Content(schema = @Schema(implementation = DocumentManagementApiResourceSwagger.PostEntityTypeEntityIdDocumentsResponse.class)))
public CommandProcessingResult createDocument(@PathParam("entityType") @Parameter(description = "entityType") final String entityType,
@PathParam("entityId") @Parameter(description = "entityId") final Long entityId,
@HeaderParam("Content-Length") @Parameter(description = "Content-Length") final Long fileSize,
@FormDataParam("file") final InputStream inputStream, @FormDataParam("file") final FormDataContentDisposition fileDetails,
@FormDataParam("file") final FormDataBodyPart bodyPart, @FormDataParam("name") final String name,
@FormDataParam("description") final String description) {
// TODO: stop reading from stream after max size is reached to protect against malicious clients
// TODO: need to extract the actual file type and determine if they are permissible
fileUploadValidator.validate(fileSize, inputStream, fileDetails, bodyPart);
final DocumentCommand documentCommand = new DocumentCommand(null, null, entityType, entityId, name, fileDetails.getFileName(),
fileSize, bodyPart.getMediaType().toString(), description, null);
final Long documentId = documentWritePlatformService.createDocument(documentCommand, inputStream);
return CommandProcessingResult.resourceResult(documentId);
}
@PUT
@Path("{documentId}")
@Consumes({ MediaType.MULTIPART_FORM_DATA })
@Produces({ MediaType.APPLICATION_JSON })
@RequestBody(description = "Update document", content = {
@Content(mediaType = MediaType.MULTIPART_FORM_DATA, schema = @Schema(implementation = DocumentManagementApiResourceSwagger.DocumentUploadRequest.class)) })
@Operation(summary = "Update a Document", description = """
Note: A document is updated using a Multi-part form upload\s
Body Parts
name
Name or summary of the document
description
Description of the document
file
The file to be uploaded""")
@ApiResponse(responseCode = "200", description = "Not Shown (multi-part form data)", content = @Content(schema = @Schema(implementation = DocumentManagementApiResourceSwagger.PutEntityTypeEntityIdDocumentsResponse.class)))
public CommandProcessingResult updateDocument(@PathParam("entityType") @Parameter(description = "entityType") final String entityType,
@PathParam("entityId") @Parameter(description = "entityId") final Long entityId,
@PathParam("documentId") @Parameter(description = "documentId") final Long documentId,
@HeaderParam("Content-Length") @Parameter(description = "Content-Length") final Long fileSize,
@FormDataParam("file") final InputStream inputStream, @FormDataParam("file") final FormDataContentDisposition fileDetails,
@FormDataParam("file") final FormDataBodyPart bodyPart, @FormDataParam("name") final String name,
@FormDataParam("description") final String description) {
final Set<String> modifiedParams = new HashSet<>();
modifiedParams.add("name");
modifiedParams.add("description");
/***
* Populate Document command based on whether a file has also been passed in as a part of the update
***/
DocumentCommand documentCommand;
if (inputStream != null && fileDetails.getFileName() != null) {
fileUploadValidator.validate(fileSize, inputStream, fileDetails, bodyPart);
modifiedParams.add("fileName");
modifiedParams.add("size");
modifiedParams.add("type");
modifiedParams.add("location");
documentCommand = new DocumentCommand(modifiedParams, documentId, entityType, entityId, name, fileDetails.getFileName(),
fileSize, bodyPart.getMediaType().toString(), description, null);
} else {
documentCommand = new DocumentCommand(modifiedParams, documentId, entityType, entityId, name, null, null, null, description,
null);
}
/***
* TODO: does not return list of changes, should be done for consistency with rest of API
**/
return documentWritePlatformService.updateDocument(documentCommand, inputStream);
}
@GET
@Path("{documentId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a Document", description = """
Example Requests:
clients/1/documents/1
loans/1/documents/1
client_identifiers/1/documents/1?fields=name,description""")
public DocumentData getDocument(@PathParam("entityType") @Parameter(description = "entityType") final String entityType,
@PathParam("entityId") @Parameter(description = "entityId") final Long entityId,
@PathParam("documentId") @Parameter(description = "documentId") final Long documentId) {
context.authenticatedUser().validateHasReadPermission(SYSTEM_ENTITY_TYPE);
return documentReadPlatformService.retrieveDocument(entityType, entityId, documentId);
}
@GET
@Path("{documentId}/attachment")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_OCTET_STREAM })
@Operation(summary = "Retrieve Binary File associated with Document", description = """
Request used to download the file associated with the document
Example Requests:
clients/1/documents/1/attachment
loans/1/documents/1/attachment""")
@ApiResponse(responseCode = "200", description = "Not Shown: The corresponding Binary file")
public Response downloadFile(@PathParam("entityType") @Parameter(description = "entityType") final String entityType,
@PathParam("entityId") @Parameter(description = "entityId") final Long entityId,
@PathParam("documentId") @Parameter(description = "documentId") final Long documentId) {
context.authenticatedUser().validateHasReadPermission(SYSTEM_ENTITY_TYPE);
final FileData fileData = documentReadPlatformService.retrieveFileData(entityType, entityId, documentId);
return ContentResources.fileDataToResponse(fileData, "attachment");
}
@DELETE
@Path("{documentId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Remove a Document", description = "")
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = DocumentManagementApiResourceSwagger.DeleteEntityTypeEntityIdDocumentsResponse.class)))
public CommandProcessingResult deleteDocument(@PathParam("entityType") @Parameter(description = "entityType") final String entityType,
@PathParam("entityId") @Parameter(description = "entityId") final Long entityId,
@PathParam("documentId") @Parameter(description = "documentId") final Long documentId) {
final DocumentCommand documentCommand = new DocumentCommand(null, documentId, entityType, entityId, null, null, null, null, null,
null);
return documentWritePlatformService.deleteDocument(documentCommand);
}
}
@@ -1,111 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.api;
import io.swagger.v3.oas.annotations.media.Schema;
import org.apache.fineract.infrastructure.core.data.UploadRequest;
import org.glassfish.jersey.media.multipart.FormDataParam;
/**
* Created by sanyam on 7/8/17.
*/
final class DocumentManagementApiResourceSwagger {
private DocumentManagementApiResourceSwagger() {
}
@Schema(description = "PostEntityTypeEntityIdDocumentsResponse")
public static final class PostEntityTypeEntityIdDocumentsResponse {
private PostEntityTypeEntityIdDocumentsResponse() {
}
@Schema(example = "3")
public Long resourceId;
@Schema(example = "3")
public String resourceIdentifier;
}
@Schema(description = "PutEntityTypeEntityIdDocumentsResponse")
public static final class PutEntityTypeEntityIdDocumentsResponse {
private PutEntityTypeEntityIdDocumentsResponse() {
}
public static final class PutEntityTypeEntityIdDocumentsResponseChangesSwagger {
private PutEntityTypeEntityIdDocumentsResponseChangesSwagger() {
}
}
@Schema(example = "3")
public Long resourceId;
public PutEntityTypeEntityIdDocumentsResponse.PutEntityTypeEntityIdDocumentsResponseChangesSwagger changes;
@Schema(example = "3")
public String resourceIdentifier;
}
@Schema(description = "DeleteEntityTypeEntityIdDocumentsResponse")
public static final class DeleteEntityTypeEntityIdDocumentsResponse {
private DeleteEntityTypeEntityIdDocumentsResponse() {
}
@Schema(example = "3")
public Long resourceId;
@Schema(example = "3")
public String resourceIdentifier;
}
@Schema(description = "Document upload request")
public static final class DocumentUploadRequest extends UploadRequest {
@Schema(name = "name", type = "string", accessMode = Schema.AccessMode.READ_WRITE)
@FormDataParam("name")
private String name;
@Schema(name = "description", type = "string", accessMode = Schema.AccessMode.READ_WRITE)
@FormDataParam("description")
private String description;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.api;
public final class ImageApiConstants {
private ImageApiConstants() {}
public static final String IMAGE_API_PARAM_MAX_WIDTH = "maxWidth";
public static final String IMAGE_API_PARAM_MAX_HEIGHT = "maxHeight";
public static final String IMAGE_API_PARAM_OUTPUT = "output";
public static final String IMAGE_API_VALUE_OUTPUT_INLINE_OCTET = "inline_octet";
public static final String IMAGE_API_VALUE_IMAGE_TYPE_JPG = "jpg";
public static final String IMAGE_API_VALUE_ENCODING_BASE64 = "base64";
// public static final String IMAGE_API_VALUE_IMAGE_TYPE_GIF = "gif";
// public static final String IMAGE_API_VALUE_IMAGE_TYPE_PNG = "png";
}
@@ -18,9 +18,30 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.api;
import static java.util.Objects.requireNonNull;
import static org.apache.fineract.infrastructure.contentstore.processor.DataUrlEncoderContentProcessor.DATA_URL_ENCODE_PARAM_CONTENT_TYPE;
import static org.apache.fineract.infrastructure.contentstore.processor.DataUrlEncoderContentProcessor.DATA_URL_ENCODE_PARAM_ENCODING;
import static org.apache.fineract.infrastructure.contentstore.processor.ImageResizeContentProcessor.IMAGE_RESIZE_PARAM_FORMAT;
import static org.apache.fineract.infrastructure.contentstore.processor.ImageResizeContentProcessor.IMAGE_RESIZE_PARAM_MAX_HEIGHT;
import static org.apache.fineract.infrastructure.contentstore.processor.ImageResizeContentProcessor.IMAGE_RESIZE_PARAM_MAX_WIDTH;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_ENTITY_ID;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_ENTITY_TYPE;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_FILE;
import static org.apache.fineract.infrastructure.documentmanagement.api.ImageApiConstants.IMAGE_API_PARAM_MAX_HEIGHT;
import static org.apache.fineract.infrastructure.documentmanagement.api.ImageApiConstants.IMAGE_API_PARAM_MAX_WIDTH;
import static org.apache.fineract.infrastructure.documentmanagement.api.ImageApiConstants.IMAGE_API_PARAM_OUTPUT;
import static org.apache.fineract.infrastructure.documentmanagement.api.ImageApiConstants.IMAGE_API_VALUE_ENCODING_BASE64;
import static org.apache.fineract.infrastructure.documentmanagement.api.ImageApiConstants.IMAGE_API_VALUE_OUTPUT_INLINE_OCTET;
import static org.apache.fineract.util.StreamResponseUtil.DISPOSITION_TYPE_ATTACHMENT;
import static org.apache.fineract.util.StreamResponseUtil.DISPOSITION_TYPE_INLINE;
import static org.springframework.http.HttpHeaders.ACCEPT;
import static org.springframework.http.HttpHeaders.CONTENT_LENGTH;
import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE;
import static org.springframework.http.MediaType.TEXT_PLAIN_VALUE;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
@@ -33,196 +54,175 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.io.InputStream;
import java.util.Base64;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.UploadRequest;
import org.apache.fineract.infrastructure.core.domain.Base64EncodedImage;
import org.apache.fineract.infrastructure.documentmanagement.contentrepository.ContentRepositoryUtils;
import org.apache.fineract.infrastructure.documentmanagement.contentrepository.ContentRepositoryUtils.ImageFileExtension;
import org.apache.fineract.infrastructure.documentmanagement.data.FileData;
import org.apache.fineract.infrastructure.documentmanagement.data.ImageResizer;
import org.apache.fineract.infrastructure.documentmanagement.exception.ContentManagementException;
import org.apache.fineract.infrastructure.documentmanagement.exception.InvalidEntityTypeForImageManagementException;
import org.apache.fineract.command.core.CommandPipeline;
import org.apache.fineract.infrastructure.contentstore.processor.Base64DecoderContentProcessor;
import org.apache.fineract.infrastructure.contentstore.processor.Base64EncoderContentProcessor;
import org.apache.fineract.infrastructure.contentstore.processor.ContentProcessorContext;
import org.apache.fineract.infrastructure.contentstore.processor.DataUrlDecoderContentProcessor;
import org.apache.fineract.infrastructure.contentstore.processor.DataUrlEncoderContentProcessor;
import org.apache.fineract.infrastructure.contentstore.processor.ImageResizeContentProcessor;
import org.apache.fineract.infrastructure.documentmanagement.command.ImageCreateCommand;
import org.apache.fineract.infrastructure.documentmanagement.command.ImageDeleteCommand;
import org.apache.fineract.infrastructure.documentmanagement.data.ImageCreateRequest;
import org.apache.fineract.infrastructure.documentmanagement.data.ImageCreateResponse;
import org.apache.fineract.infrastructure.documentmanagement.data.ImageDeleteRequest;
import org.apache.fineract.infrastructure.documentmanagement.data.ImageDeleteResponse;
import org.apache.fineract.infrastructure.documentmanagement.service.ImageReadPlatformService;
import org.apache.fineract.infrastructure.documentmanagement.service.ImageWritePlatformService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.util.StreamResponseUtil;
import org.glassfish.jersey.media.multipart.FormDataBodyPart;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataParam;
import org.springframework.stereotype.Component;
// NOTE: left for backward compatibility only, could be unified with documents
@Deprecated
@Slf4j
@RequiredArgsConstructor
@Component
@Path("/v1/{entity}/{entityId}/images")
@Path("/v1/{entityType}/{entityId}/images")
public class ImagesApiResource {
private final PlatformSecurityContext context;
private final ImageReadPlatformService imageReadPlatformService;
private final ImageWritePlatformService imageWritePlatformService;
private final CommandPipeline commandPipeline;
private final ImageResizeContentProcessor imageResizeContentProcessor;
private final Base64EncoderContentProcessor base64EncoderContentProcessor;
private final DataUrlEncoderContentProcessor dataUrlEncoderContentProcessor;
private final Base64DecoderContentProcessor base64DecoderContentProcessor;
private final DataUrlDecoderContentProcessor dataUrlDecoderContentProcessor;
private final FileUploadValidator fileUploadValidator;
private final ImageResizer imageResizer;
/**
* Upload images through multi-part form upload
*/
@POST
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
@RequestBody(description = "Upload new client image", content = {
@Content(mediaType = MediaType.MULTIPART_FORM_DATA, schema = @Schema(implementation = UploadRequest.class)) })
public CommandProcessingResult addNewClientImage(@PathParam("entity") final String entityName,
@PathParam("entityId") final Long entityId, @HeaderParam("Content-Length") final Long fileSize,
@FormDataParam("file") final InputStream inputStream, @FormDataParam("file") final FormDataContentDisposition fileDetails,
@FormDataParam("file") final FormDataBodyPart bodyPart) {
validateEntityTypeforImage(entityName);
fileUploadValidator.validate(fileSize, inputStream, fileDetails, bodyPart);
// TODO: vishwas might need more advances validation (like reading magic
// number) for handling malicious clients
// and clients not setting mime type
ContentRepositoryUtils.validateClientImageNotEmpty(fileDetails.getFileName());
ContentRepositoryUtils.validateImageMimeType(bodyPart.getMediaType().toString());
return imageWritePlatformService.saveOrUpdateImage(entityName, entityId, fileDetails.getFileName(), inputStream, fileSize);
}
/**
* Upload image as a Data URL (essentially a base64 encoded stream)
*/
@POST
@Consumes({ MediaType.TEXT_PLAIN, MediaType.TEXT_HTML, MediaType.APPLICATION_JSON })
@Produces(MediaType.APPLICATION_JSON)
public CommandProcessingResult addNewClientImage(@PathParam("entity") final String entityName,
@PathParam("entityId") final Long entityId, final String jsonRequestBody) {
validateEntityTypeforImage(entityName);
final Base64EncodedImage base64EncodedImage = ContentRepositoryUtils.extractImageFromDataURL(jsonRequestBody);
return imageWritePlatformService.saveOrUpdateImage(entityName, entityId, base64EncodedImage);
}
/**
* Returns a images, either as Base64 encoded text/plain or as inline or attachment with image MIME type as
* Content-Type.
*/
@GET
@Consumes(MediaType.APPLICATION_JSON)
// FINERACT-1265: Do NOT specify @Produces(TEXT_PLAIN) here - it may actually not (if it calls the next methods it's
// octet-stream)
public Response retrieveImage(@PathParam("entity") final String entityName, @PathParam("entityId") final Long entityId,
@QueryParam("maxWidth") final Integer maxWidth, @QueryParam("maxHeight") final Integer maxHeight,
@QueryParam("output") final String output, @HeaderParam("Accept") String acceptHeader) {
validateEntityTypeforImage(entityName);
if (EntityTypeForImages.CLIENTS.toString().equalsIgnoreCase(entityName)) {
context.authenticatedUser().validateHasReadPermission("CLIENTIMAGE");
} else if (EntityTypeForImages.STAFF.toString().equalsIgnoreCase(entityName)) {
context.authenticatedUser().validateHasReadPermission("STAFFIMAGE");
public Response retrieveImage(@PathParam(DOCUMENT_API_PARAM_ENTITY_TYPE) final String entityName,
@PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId, @QueryParam(IMAGE_API_PARAM_MAX_WIDTH) final Integer maxWidth,
@QueryParam(IMAGE_API_PARAM_MAX_HEIGHT) final Integer maxHeight, @QueryParam(IMAGE_API_PARAM_OUTPUT) String output,
@HeaderParam(ACCEPT) String acceptHeader) {
// TODO: pass resize information here and do all the processing in the service
final var content = imageReadPlatformService.retrieveImage(entityName, entityId);
String dispositionType = null;
ContentProcessorContext ctx = new ContentProcessorContext(content.getStream());
String type;
// prevent clients sending non-sense
if (!"octet".equalsIgnoreCase(output) && !"inline_octet".equalsIgnoreCase(output)) {
output = null;
}
final FileData imageData = imageReadPlatformService.retrieveImage(entityName, entityId);
final FileData resizedImage = imageResizer.resize(imageData, maxWidth, maxHeight);
// If client wants (Accept header) octet-stream, or output="octet" or "inline_octet", then send that instead of
// text
if (MediaType.APPLICATION_OCTET_STREAM.equalsIgnoreCase(acceptHeader)
|| (output != null && (output.equals("octet") || output.equals("inline_octet")))) {
return ContentResources.fileDataToResponse(resizedImage, resizedImage.name() + ImageFileExtension.JPEG,
"inline_octet".equals(output) ? "inline" : "attachment");
if ((StringUtils.isNotEmpty(output) && output.contains("octet")) || APPLICATION_OCTET_STREAM_VALUE.equalsIgnoreCase(acceptHeader)) {
if (maxWidth != null && maxHeight != null) {
ctx = imageResizeContentProcessor.process(content.getStream(), Map.of(IMAGE_RESIZE_PARAM_MAX_WIDTH, maxWidth,
IMAGE_RESIZE_PARAM_MAX_HEIGHT, maxHeight, IMAGE_RESIZE_PARAM_FORMAT, content.getFormat()));
}
// Else return response with Base64 encoded
// TODO: Need a better way of determining image type
String imageDataURISuffix = ContentRepositoryUtils.ImageDataURIsuffix.JPEG.getValue();
if (StringUtils.endsWith(imageData.name(), ContentRepositoryUtils.ImageFileExtension.GIF.getValue())) {
imageDataURISuffix = ContentRepositoryUtils.ImageDataURIsuffix.GIF.getValue();
} else if (StringUtils.endsWith(imageData.name(), ContentRepositoryUtils.ImageFileExtension.PNG.getValue())) {
imageDataURISuffix = ContentRepositoryUtils.ImageDataURIsuffix.PNG.getValue();
}
try {
byte[] resizedImageBytes = resizedImage.getByteSource().read();
if (resizedImageBytes != null) {
final String clientImageAsBase64Text = imageDataURISuffix + Base64.getMimeEncoder().encodeToString(resizedImageBytes);
return Response.ok(clientImageAsBase64Text, MediaType.TEXT_PLAIN_TYPE).build();
if (IMAGE_API_VALUE_OUTPUT_INLINE_OCTET.equalsIgnoreCase(output)) {
dispositionType = DISPOSITION_TYPE_INLINE;
} else {
log.error("resizedImageBytes is null for entityName={}, entityId={}, maxWidth={}, maxHeight={}", entityName, entityId,
maxWidth, maxHeight);
return Response.serverError().build();
dispositionType = DISPOSITION_TYPE_ATTACHMENT;
}
} catch (IOException e) {
throw new ContentManagementException(imageData.name(), e.getMessage(), e);
type = content.getContentType();
} else {
// else stream base64 encoded original format
if (maxWidth != null && maxHeight != null) {
ctx = imageResizeContentProcessor.then(base64EncoderContentProcessor).then(dataUrlEncoderContentProcessor).process(
content.getStream(),
Map.of(IMAGE_RESIZE_PARAM_MAX_WIDTH, maxWidth, IMAGE_RESIZE_PARAM_MAX_HEIGHT, maxHeight, IMAGE_RESIZE_PARAM_FORMAT,
content.getFormat(), DATA_URL_ENCODE_PARAM_CONTENT_TYPE, content.getContentType(),
DATA_URL_ENCODE_PARAM_ENCODING, IMAGE_API_VALUE_ENCODING_BASE64));
} else {
ctx = base64EncoderContentProcessor.then(dataUrlEncoderContentProcessor).process(content.getStream(),
Map.of(DATA_URL_ENCODE_PARAM_CONTENT_TYPE, content.getContentType(), DATA_URL_ENCODE_PARAM_ENCODING,
IMAGE_API_VALUE_ENCODING_BASE64));
}
type = TEXT_PLAIN_VALUE;
}
// make sure we use the transformed input stream ("ctx.getInputStream()")
return StreamResponseUtil.ok(StreamResponseUtil.StreamResponseData.builder().fileName(content.getDisplayName()).type(type)
.stream(ctx.getInputStream()).dispositionType(dispositionType).build());
}
@POST
@Consumes({ MediaType.MULTIPART_FORM_DATA })
@Produces({ MediaType.APPLICATION_JSON })
@ApiResponse(responseCode = "200", description = "Not Shown (multi-part form data)")
public ImageCreateResponse createImage(@PathParam(DOCUMENT_API_PARAM_ENTITY_TYPE) final String entityType,
@PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId, @HeaderParam(CONTENT_LENGTH) final Long fileSize,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final InputStream is,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataContentDisposition fileDetails,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataBodyPart filePart) {
fileUploadValidator.validate(fileSize, is, fileDetails, filePart);
final var command = new ImageCreateCommand();
command.setPayload(
ImageCreateRequest.builder().entityId(entityId).entityType(entityType).fileName(fileDetails.getFileName()).size(fileSize)
.type(Optional.ofNullable(filePart.getMediaType()).map(MediaType::toString).orElse(null)).stream(is).build());
final Supplier<ImageCreateResponse> response = commandPipeline.send(command);
return response.get();
}
@POST
@Consumes({ MediaType.TEXT_PLAIN, MediaType.TEXT_HTML, MediaType.APPLICATION_JSON })
public ImageCreateResponse createImage(@PathParam(DOCUMENT_API_PARAM_ENTITY_TYPE) final String entityType,
@PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId, final InputStream body) {
requireNonNull(body, "Missing input stream");
final var command = new ImageCreateCommand();
var ctx = dataUrlDecoderContentProcessor.then(base64DecoderContentProcessor).process(new ContentProcessorContext(body));
command.setPayload(ImageCreateRequest.builder().entityId(entityId).entityType(entityType).stream(ctx.getInputStream()).build());
final Supplier<ImageCreateResponse> response = commandPipeline.send(command);
return response.get();
}
/**
* This method is added only for consistency with other URL patterns and for maintaining consistency of usage of the
* HTTP "verb" at the client side
*/
@PUT
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
@RequestBody(description = "Update client image", content = {
@Content(mediaType = MediaType.MULTIPART_FORM_DATA, schema = @Schema(implementation = UploadRequest.class)) })
public CommandProcessingResult updateClientImage(@PathParam("entity") final String entityName,
@PathParam("entityId") final Long entityId, @HeaderParam("Content-Length") final Long fileSize,
@FormDataParam("file") final InputStream inputStream, @FormDataParam("file") final FormDataContentDisposition fileDetails,
@FormDataParam("file") final FormDataBodyPart bodyPart) {
return addNewClientImage(entityName, entityId, fileSize, inputStream, fileDetails, bodyPart);
@RequestBody(description = "Update image", content = { @Content(mediaType = MediaType.MULTIPART_FORM_DATA) })
public ImageCreateResponse updateImage(@PathParam(DOCUMENT_API_PARAM_ENTITY_TYPE) final String entityName,
@PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId, @HeaderParam(CONTENT_LENGTH) final Long fileSize,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final InputStream inputStream,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataContentDisposition fileDetails,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataBodyPart bodyPart) {
return createImage(entityName, entityId, fileSize, inputStream, fileDetails, bodyPart);
}
/**
* This method is added only for consistency with other URL patterns and for maintaining consistency of usage of the
* HTTP "verb" at the client side
*
* Upload image as a Data URL (essentially a base64 encoded stream)
*/
@PUT
@Consumes({ MediaType.TEXT_PLAIN, MediaType.TEXT_HTML, MediaType.APPLICATION_JSON })
@Produces(MediaType.APPLICATION_JSON)
public CommandProcessingResult updateClientImage(@PathParam("entity") final String entityName,
@PathParam("entityId") final Long entityId, final String jsonRequestBody) {
return addNewClientImage(entityName, entityId, jsonRequestBody);
public ImageCreateResponse updateImage(@PathParam(DOCUMENT_API_PARAM_ENTITY_TYPE) final String entityName,
@PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId, final InputStream body) {
return createImage(entityName, entityId, body);
}
@DELETE
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CommandProcessingResult deleteClientImage(@PathParam("entity") final String entityName,
@PathParam("entityId") final Long entityId) {
validateEntityTypeforImage(entityName);
imageWritePlatformService.deleteImage(entityName, entityId);
return CommandProcessingResult.resourceResult(entityId);
}
public ImageDeleteResponse deleteImage(@PathParam(DOCUMENT_API_PARAM_ENTITY_TYPE) final String entityType,
@PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId) {
/*** Entities for document Management **/
public enum EntityTypeForImages {
final var command = new ImageDeleteCommand();
STAFF, //
CLIENTS; //
command.setPayload(ImageDeleteRequest.builder().entityId(entityId).entityType(entityType).build());
@Override
public String toString() {
return name().toLowerCase();
}
}
final Supplier<ImageDeleteResponse> response = commandPipeline.send(command);
private void validateEntityTypeforImage(final String entityName) {
if (!checkValidEntityType(entityName)) {
throw new InvalidEntityTypeForImageManagementException(entityName);
}
}
private static boolean checkValidEntityType(final String entityType) {
for (final EntityTypeForImages entities : EntityTypeForImages.values()) {
if (entities.name().equalsIgnoreCase(entityType)) {
return true;
}
}
return false;
return response.get();
}
}
@@ -20,9 +20,7 @@ package org.apache.fineract.infrastructure.documentmanagement.command;
import java.util.Set;
/**
* Immutable command for creating or updating details of a client identifier.
*/
@Deprecated(forRemoval = true)
public class DocumentCommand {
private final Long id;
@@ -35,13 +33,9 @@ public class DocumentCommand {
private Long size;
private String type;
private String location;
private Integer storageType;
private final Set<String> modifiedParameters;
public DocumentCommand(final Set<String> modifiedParameters, final Long id, final String parentEntityType, final Long parentEntityId,
final String name, final String fileName, final Long size, final String type, final String description, final String location) {
this.modifiedParameters = modifiedParameters;
this.id = id;
this.parentEntityType = parentEntityType;
this.parentEntityId = parentEntityId;
@@ -89,10 +83,6 @@ public class DocumentCommand {
return this.location;
}
public Set<String> getModifiedParameters() {
return this.modifiedParameters;
}
public void setFileName(final String fileName) {
this.fileName = fileName;
}
@@ -108,37 +98,4 @@ public class DocumentCommand {
public void setLocation(final String location) {
this.location = location;
}
public Integer getStorageType() {
return this.storageType;
}
public void setStorageType(final Integer storageType) {
this.storageType = storageType;
}
public boolean isNameChanged() {
return this.modifiedParameters.contains("name");
}
public boolean isFileNameChanged() {
return this.modifiedParameters.contains("fileName");
}
public boolean isSizeChanged() {
return this.modifiedParameters.contains("size");
}
public boolean isFileTypeChanged() {
return this.modifiedParameters.contains("type");
}
public boolean isDescriptionChanged() {
return this.modifiedParameters.contains("description");
}
public boolean isLocationChanged() {
return this.modifiedParameters.contains("location");
}
}
@@ -1,73 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.command;
import java.util.ArrayList;
import java.util.List;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
public class DocumentCommandValidator {
private final DocumentCommand command;
public DocumentCommandValidator(final DocumentCommand command) {
this.command = command;
}
public void validateForUpdate() {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("document");
baseDataValidator.reset().parameter("name").value(this.command.getName()).ignoreIfNull().notBlank();
baseDataValidator.reset().parameter("size").value(this.command.getSize()).ignoreIfNull().integerGreaterThanZero();
baseDataValidator.reset().parameter("fileName").value(this.command.getFileName()).ignoreIfNull().notBlank()
.notExceedingLengthOf(250);
baseDataValidator.reset().parameter("location").value(this.command.getLocation()).ignoreIfNull().notBlank();
baseDataValidator.reset().parameter("description").value(this.command.getName()).ignoreIfNull().notExceedingLengthOf(250);
baseDataValidator.reset().anyOfNotNull(this.command.getName(), this.command.getFileName(), this.command.getDescription(),
this.command.getLocation(), this.command.getSize());
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
public void validateForCreate() {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("document");
baseDataValidator.reset().parameter("parentEntityType").value(this.command.getParentEntityType()).notBlank()
.notExceedingLengthOf(50);
baseDataValidator.reset().parameter("parentEntityId").value(this.command.getParentEntityId()).integerGreaterThanZero();
baseDataValidator.reset().parameter("name").value(this.command.getName()).notBlank().notExceedingLengthOf(250);
baseDataValidator.reset().parameter("size").value(this.command.getSize()).integerGreaterThanZero();
baseDataValidator.reset().parameter("fileName").value(this.command.getFileName()).notBlank().notExceedingLengthOf(250);
baseDataValidator.reset().parameter("description").value(this.command.getName()).notExceedingLengthOf(250);
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.command;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateRequest;
@Data
@EqualsAndHashCode(callSuper = true)
public class DocumentCreateCommand extends Command<DocumentCreateRequest> {}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.command;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteRequest;
@Data
@EqualsAndHashCode(callSuper = true)
public class DocumentDeleteCommand extends Command<DocumentDeleteRequest> {}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.command;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentUpdateRequest;
@Data
@EqualsAndHashCode(callSuper = true)
public class DocumentUpdateCommand extends Command<DocumentUpdateRequest> {}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.command;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.infrastructure.documentmanagement.data.ImageCreateRequest;
@Data
@EqualsAndHashCode(callSuper = true)
public class ImageCreateCommand extends Command<ImageCreateRequest> {}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.command;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.infrastructure.documentmanagement.data.ImageDeleteRequest;
@Data
@EqualsAndHashCode(callSuper = true)
public class ImageDeleteCommand extends Command<ImageDeleteRequest> {}
@@ -1,56 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.contentrepository;
import java.io.InputStream;
import org.apache.fineract.infrastructure.core.domain.Base64EncodedImage;
import org.apache.fineract.infrastructure.documentmanagement.command.DocumentCommand;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.data.FileData;
import org.apache.fineract.infrastructure.documentmanagement.data.ImageData;
import org.apache.fineract.infrastructure.documentmanagement.domain.StorageType;
/**
* Repository which stores Files (AKA Documents) and Images.
*/
public interface ContentRepository {
// TODO:Vishwas Need to move these settings to the Database
Integer MAX_FILE_UPLOAD_SIZE_IN_MB = 5;
// TODO:Vishwas Need to move these settings to the Database
Integer MAX_IMAGE_UPLOAD_SIZE_IN_MB = 1;
String saveFile(InputStream uploadedInputStream, DocumentCommand documentCommand);
void deleteFile(String documentPath);
FileData fetchFile(DocumentData documentData);
String saveImage(InputStream uploadedInputStream, Long resourceId, String imageName, Long fileSize);
String saveImage(Base64EncodedImage base64EncodedImage, Long resourceId, String imageName);
void deleteImage(String location);
FileData fetchImage(ImageData imageData);
StorageType getStorageType();
}
@@ -1,53 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.contentrepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.documentmanagement.domain.StorageType;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class ContentRepositoryFactory {
private final FineractProperties fineractProperties;
private final List<ContentRepository> contentRepositories;
public ContentRepository getRepository() {
if (fineractProperties.getContent() != null) {
if (fineractProperties.getContent().getFilesystem() != null
&& Boolean.TRUE.equals(fineractProperties.getContent().getFilesystem().getEnabled())) {
return getRepository(StorageType.FILE_SYSTEM);
} else if (fineractProperties.getContent().getS3() != null
&& Boolean.TRUE.equals(fineractProperties.getContent().getS3().getEnabled())) {
return getRepository(StorageType.S3);
}
}
throw new RuntimeException(
"No content repository enabled. Please set either 'fineract.content.filesystem.enabled=true' or 'fineract.content.s3.enabled=true' in your application.properties.");
}
public ContentRepository getRepository(StorageType storageType) {
return contentRepositories.stream().filter(cr -> cr.getStorageType().equals(storageType)).findFirst().orElseThrow(
() -> new RuntimeException(String.format("No content repository implementation found for storage type: %s", storageType)));
}
}
@@ -1,210 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.contentrepository;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.domain.Base64EncodedImage;
import org.apache.fineract.infrastructure.core.exception.ImageDataURLNotValidException;
import org.apache.fineract.infrastructure.core.exception.ImageUploadException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.documentmanagement.exception.ContentManagementException;
public final class ContentRepositoryUtils {
private static final SecureRandom random = new SecureRandom();
private ContentRepositoryUtils() {}
public enum ImageMIMEtype {
GIF("image/gif"), //
JPEG("image/jpeg"), //
PNG("image/png"); //
private final String value;
ImageMIMEtype(final String value) {
this.value = value;
}
public String getValue() {
return this.value;
}
@SuppressWarnings("UnnecessaryDefaultInEnumSwitch")
public static ImageMIMEtype fromFileExtension(ImageFileExtension fileExtension) {
switch (fileExtension) {
case GIF:
return ImageMIMEtype.GIF;
case JPG:
case JPEG:
return ImageMIMEtype.JPEG;
case PNG:
return ImageMIMEtype.PNG;
default:
throw new IllegalArgumentException();
}
}
}
public enum ImageFileExtension {
GIF(".gif"), //
JPEG(".jpeg"), //
JPG(".jpg"), //
PNG(".png"); //
private final String value;
ImageFileExtension(final String value) {
this.value = value;
}
public String getValue() {
return this.value;
}
public String getValueWithoutDot() {
return this.value.substring(1);
}
public ImageFileExtension getFileExtension() {
switch (this) {
case GIF:
return ImageFileExtension.GIF;
case JPEG:
return ImageFileExtension.JPEG;
case PNG:
return ImageFileExtension.PNG;
default:
throw new IllegalArgumentException();
}
}
}
public enum ImageDataURIsuffix {
GIF("data:" + ImageMIMEtype.GIF.getValue() + ";base64,"), //
JPEG("data:" + ImageMIMEtype.JPEG.getValue() + ";base64,"), //
PNG("data:" + ImageMIMEtype.PNG.getValue() + ";base64,");
private final String value;
ImageDataURIsuffix(final String value) {
this.value = value;
}
public String getValue() {
return this.value;
}
}
public static ImageFileExtension imageExtensionFromFileName(String fileName) {
if (StringUtils.endsWith(fileName.toLowerCase(), ContentRepositoryUtils.ImageFileExtension.GIF.getValue())) {
return ContentRepositoryUtils.ImageFileExtension.GIF;
} else if (StringUtils.endsWith(fileName, ContentRepositoryUtils.ImageFileExtension.PNG.getValue())) {
return ContentRepositoryUtils.ImageFileExtension.PNG;
} else {
return ContentRepositoryUtils.ImageFileExtension.JPEG;
}
}
/**
* Validates that passed in Mime type maps to known image mime types
*
* @param mimeType
*/
public static void validateImageMimeType(final String mimeType) {
if ((!mimeType.equalsIgnoreCase(ImageMIMEtype.GIF.getValue()) && !mimeType.equalsIgnoreCase(ImageMIMEtype.JPEG.getValue())
&& !mimeType.equalsIgnoreCase(ImageMIMEtype.PNG.getValue()))) {
throw new ImageUploadException(mimeType);
}
}
/**
* Extracts Image from a Data URL
*
* @param dataURL
* mimeType
*/
public static Base64EncodedImage extractImageFromDataURL(final String dataURL) {
String fileExtension = "";
String base64EncodedString = null;
if (StringUtils.startsWith(dataURL, ImageDataURIsuffix.GIF.getValue())) {
base64EncodedString = dataURL.replaceAll(ImageDataURIsuffix.GIF.getValue(), "");
fileExtension = ImageFileExtension.GIF.getValue();
} else if (StringUtils.startsWith(dataURL, ImageDataURIsuffix.PNG.getValue())) {
base64EncodedString = dataURL.replaceAll(ImageDataURIsuffix.PNG.getValue(), "");
fileExtension = ImageFileExtension.PNG.getValue();
} else if (StringUtils.startsWith(dataURL, ImageDataURIsuffix.JPEG.getValue())) {
base64EncodedString = dataURL.replaceAll(ImageDataURIsuffix.JPEG.getValue(), "");
fileExtension = ImageFileExtension.JPEG.getValue();
} else {
throw new ImageDataURLNotValidException();
}
return new Base64EncodedImage(base64EncodedString, fileExtension);
}
public static void validateFileSizeWithinPermissibleRange(final Long fileSize, final String name) {
/**
* Using Content-Length gives me size of the entire request, which is good enough for now for a fast fail as the
* length of the rest of the content i.e name and description while compared to the uploaded file size is
* negligible
**/
if (fileSize != null && ((fileSize / (1024 * 1024)) > ContentRepository.MAX_FILE_UPLOAD_SIZE_IN_MB)) {
throw new ContentManagementException(name, fileSize, ContentRepository.MAX_FILE_UPLOAD_SIZE_IN_MB);
}
}
public static void validateClientImageNotEmpty(final String imageFileName) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
if (imageFileName == null) {
final StringBuilder validationErrorCode = new StringBuilder("validation.msg.clientImage.cannot.be.blank");
final StringBuilder defaultEnglishMessage = new StringBuilder("The parameter image cannot be blank.");
final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode.toString(),
defaultEnglishMessage.toString(), "image");
dataValidationErrors.add(error);
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
/**
* Generate a random String.
*/
@SuppressFBWarnings(value = {
"DMI_RANDOM_USED_ONLY_ONCE" }, justification = "False positive for random object created and used only once")
public static String generateRandomString() {
final String characters = "abcdefghijklmnopqrstuvwxyz123456789";
// length is a random number between 5 to 16
final int length = random.nextInt(11) + 5;
final char[] text = new char[length];
for (int i = 0; i < length; i++) {
text[i] = characters.charAt(random.nextInt(characters.length()));
}
return new String(text);
}
}
@@ -1,138 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.contentrepository;
import jakarta.annotation.PostConstruct;
import java.io.BufferedInputStream;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.documentmanagement.exception.ContentManagementException;
import org.apache.tika.Tika;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.AutoDetectParser;
import org.apache.tika.sax.BodyContentHandler;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class FileSystemContentPathSanitizer implements ContentPathSanitizer {
private final FineractProperties fineractProperties;
private List<Pattern> regexWhitelist;
private static Pattern OVERWRITE_SIBLING_IMAGE = Pattern.compile(".*\\.\\./+[0-9]+/+.*");
@PostConstruct
public void init() {
regexWhitelist = fineractProperties.getContent().getRegexWhitelist().stream().map(Pattern::compile).toList();
}
@Override
public String sanitize(String path) {
return sanitize(path, null);
}
@Override
public String sanitize(String path, BufferedInputStream is) {
try {
if (OVERWRITE_SIBLING_IMAGE.matcher(path).matches()) {
throw new RuntimeException(String.format("Trying to overwrite another resource's image: %s", path));
}
String sanitizedPath = Path.of(path).normalize().toString();
String fileName = FilenameUtils.getName(sanitizedPath).toLowerCase();
if (log.isDebugEnabled()) {
log.debug("Path: {} -> {} ({})", path, sanitizedPath, fileName);
}
if (fineractProperties.getContent().isRegexWhitelistEnabled()) {
boolean matches = regexWhitelist.stream().anyMatch(p -> p.matcher(fileName).matches());
if (!matches) {
throw new RuntimeException(String.format("File name not allowed: %s", fileName));
}
}
if (is != null && fineractProperties.getContent().isMimeWhitelistEnabled()) {
Tika tika = new Tika();
String extensionMimeType = tika.detect(fileName);
if (StringUtils.isEmpty(extensionMimeType)) {
throw new RuntimeException(String.format("Could not detect mime type for filename %s!", fileName));
}
if (!fineractProperties.getContent().getMimeWhitelist().contains(extensionMimeType)) {
throw new RuntimeException(
String.format("Detected mime type %s for filename %s not allowed!", extensionMimeType, fileName));
}
String contentMimeType = detectContentMimeType(is);
if (StringUtils.isEmpty(contentMimeType)) {
throw new RuntimeException(String.format("Could not detect content mime type for %s!", fileName));
}
if (!fineractProperties.getContent().getMimeWhitelist().contains(contentMimeType)) {
throw new RuntimeException(
String.format("Detected content mime type %s for %s not allowed!", contentMimeType, fileName));
}
if (!contentMimeType.equalsIgnoreCase(extensionMimeType)) {
throw new RuntimeException(String.format("Detected filename (%s) and content (%s) mime type do not match!",
extensionMimeType, contentMimeType));
}
}
Path target = Path.of(sanitizedPath);
Path rootFolder = Path.of(fineractProperties.getContent().getFilesystem().getRootFolder(),
ThreadLocalContextUtil.getTenant().getName().replaceAll(" ", "").trim());
if (!target.startsWith(rootFolder)) {
throw new RuntimeException(String.format("Path traversal attempt: %s (%s)", target, rootFolder));
}
return sanitizedPath;
} catch (Exception e) {
throw new ContentManagementException(path, e.getMessage(), e);
}
}
private String detectContentMimeType(BufferedInputStream bis) throws Exception {
TikaInputStream tis = TikaInputStream.get(bis);
AutoDetectParser parser = new AutoDetectParser();
// NOTE: turn off write limit with "-1"
BodyContentHandler handler = new BodyContentHandler(-1);
Metadata metadata = new Metadata();
parser.parse(tis, handler, metadata);
return metadata.get("Content-Type");
}
}
@@ -1,164 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.contentrepository;
import com.google.common.io.Files;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Base64;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.domain.Base64EncodedImage;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.documentmanagement.command.DocumentCommand;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.data.FileData;
import org.apache.fineract.infrastructure.documentmanagement.data.ImageData;
import org.apache.fineract.infrastructure.documentmanagement.domain.StorageType;
import org.apache.fineract.infrastructure.documentmanagement.exception.ContentManagementException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
@ConditionalOnProperty("fineract.content.filesystem.enabled")
public class FileSystemContentRepository implements ContentRepository {
private final FileSystemContentPathSanitizer pathSanitizer;
private final FineractProperties fineractProperties;
@Override
public String saveFile(final InputStream uploadedInputStream, final DocumentCommand documentCommand) {
final String fileName = documentCommand.getFileName();
ContentRepositoryUtils.validateFileSizeWithinPermissibleRange(documentCommand.getSize(), fileName);
final String fileLocation = generateFileParentDirectory(documentCommand.getParentEntityType(), documentCommand.getParentEntityId())
+ File.separator + fileName;
return writeFileToFileSystem(fileName, uploadedInputStream, fileLocation);
}
@Override
public String saveImage(final InputStream uploadedInputStream, final Long resourceId, final String imageName, final Long fileSize) {
ContentRepositoryUtils.validateFileSizeWithinPermissibleRange(fileSize, imageName);
final String fileLocation = generateClientImageParentDirectory(resourceId) + File.separator + imageName;
return writeFileToFileSystem(imageName, uploadedInputStream, fileLocation);
}
@Override
public String saveImage(final Base64EncodedImage base64EncodedImage, final Long resourceId, final String imageName) {
final String fileLocation = generateClientImageParentDirectory(resourceId) + File.separator + imageName
+ base64EncodedImage.getFileExtension();
String base64EncodedImageString = base64EncodedImage.getBase64EncodedString();
try {
final InputStream toUploadInputStream = new ByteArrayInputStream(Base64.getMimeDecoder().decode(base64EncodedImageString));
return writeFileToFileSystem(imageName, toUploadInputStream, fileLocation);
} catch (IllegalArgumentException iae) {
log.error("IllegalArgumentException due to invalid Base64 encoding: {}", base64EncodedImageString, iae);
throw iae;
}
}
@Override
public void deleteImage(final String location) {
deleteFileInternal(location);
}
@Override
public void deleteFile(final String documentPath) {
deleteFileInternal(documentPath);
}
private void deleteFileInternal(final String documentPath) {
String sanitizedPath = pathSanitizer.sanitize(documentPath);
final File fileToBeDeleted = new File(sanitizedPath);
final boolean fileDeleted = fileToBeDeleted.delete();
if (!fileDeleted) {
// no need to throw an Error, what's a caller going to do about it, so simply log a warning
log.warn("Unable to delete file {}", documentPath);
}
}
@Override
public FileData fetchFile(final DocumentData documentData) {
String sanitizedPath = pathSanitizer.sanitize(documentData.getLocation());
final File file = new File(sanitizedPath);
return new FileData(Files.asByteSource(file), documentData.getFileName(), documentData.getType());
}
@Override
public FileData fetchImage(final ImageData imageData) {
String sanitizedPath = pathSanitizer.sanitize(imageData.location());
final File file = new File(sanitizedPath);
return new FileData(Files.asByteSource(file), imageData.getEntityDisplayName(), imageData.contentType().getValue());
}
@Override
public StorageType getStorageType() {
return StorageType.FILE_SYSTEM;
}
/**
* Generate the directory path for storing the new document
*/
private String generateFileParentDirectory(final String entityType, final Long entityId) {
return fineractProperties.getContent().getFilesystem().getRootFolder() + File.separator
+ ThreadLocalContextUtil.getTenant().getName().replace(" ", "").trim() + File.separator + "documents" + File.separator
+ entityType + File.separator + entityId + File.separator + ContentRepositoryUtils.generateRandomString();
}
/**
* Generate ContentRepositoryUtilsfineractProperties.getContentgetWhitelist()getBlacklist() path for storing new
* Image
*/
private String generateClientImageParentDirectory(final Long resourceId) {
return fineractProperties.getContent().getFilesystem().getRootFolder() + File.separator
+ ThreadLocalContextUtil.getTenant().getName().replace(" ", "").trim() + File.separator + "images" + File.separator
+ "clients" + File.separator + resourceId;
}
/**
* Recursively create the directory if it does not exist.
*/
private void makeDirectories(final String uploadDocumentLocation) throws IOException {
String sanitizedPath = pathSanitizer.sanitize(uploadDocumentLocation);
Files.createParentDirs(new File(sanitizedPath));
}
private String writeFileToFileSystem(final String fileName, final InputStream uploadedInputStream, final String fileLocation) {
try (BufferedInputStream bis = new BufferedInputStream(uploadedInputStream)) {
String sanitizedPath = pathSanitizer.sanitize(fileLocation, bis);
makeDirectories(sanitizedPath);
FileUtils.copyInputStreamToFile(bis, new File(sanitizedPath)); // NOSONAR
return sanitizedPath;
} catch (final IOException ioException) {
log.warn("Failed to write file!", ioException);
throw new ContentManagementException(fileName, ioException.getMessage(), ioException);
}
}
}
@@ -1,177 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.contentrepository;
import com.google.common.io.ByteSource;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Base64;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.domain.Base64EncodedImage;
import org.apache.fineract.infrastructure.documentmanagement.command.DocumentCommand;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.data.FileData;
import org.apache.fineract.infrastructure.documentmanagement.data.ImageData;
import org.apache.fineract.infrastructure.documentmanagement.domain.StorageType;
import org.apache.fineract.infrastructure.documentmanagement.exception.ContentManagementException;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentNotFoundException;
import org.apache.fineract.infrastructure.security.utils.LogParameterEscapeUtil;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.awscore.exception.AwsServiceException;
import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.core.sync.ResponseTransformer;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
@Slf4j
@RequiredArgsConstructor
@Component
@ConditionalOnProperty("fineract.content.s3.enabled")
public class S3ContentRepository implements ContentRepository {
private final S3Client s3Client;
private final FineractProperties fineractProperties;
@Override
public String saveFile(final InputStream toUpload, final DocumentCommand documentCommand) {
final String fileName = documentCommand.getFileName();
ContentRepositoryUtils.validateFileSizeWithinPermissibleRange(documentCommand.getSize(), fileName);
final String uploadDocFolder = generateFileParentDirectory(documentCommand.getParentEntityType(),
documentCommand.getParentEntityId());
final String uploadDocFullPath = uploadDocFolder + File.separator + fileName;
putObject(fileName, toUpload, uploadDocFullPath);
return uploadDocFullPath;
}
@Override
public void deleteFile(final String documentPath) {
deleteObject(documentPath);
}
@Override
public String saveImage(final InputStream toUploadInputStream, final Long resourceId, final String imageName, final Long fileSize) {
ContentRepositoryUtils.validateFileSizeWithinPermissibleRange(fileSize, imageName);
final String uploadImageLocation = generateClientImageParentDirectory(resourceId);
final String fileLocation = uploadImageLocation + File.separator + imageName;
putObject(imageName, toUploadInputStream, fileLocation);
return fileLocation;
}
@Override
public String saveImage(final Base64EncodedImage base64EncodedImage, final Long resourceId, final String imageName) {
final String uploadImageLocation = generateClientImageParentDirectory(resourceId);
final String fileLocation = uploadImageLocation + File.separator + imageName + base64EncodedImage.getFileExtension();
final InputStream toUploadInputStream = new ByteArrayInputStream(
Base64.getMimeDecoder().decode(base64EncodedImage.getBase64EncodedString()));
putObject(imageName, toUploadInputStream, fileLocation);
return fileLocation;
}
@Override
public void deleteImage(final String location) {
deleteObject(location);
}
@Override
public FileData fetchFile(final DocumentData documentData) throws DocumentNotFoundException {
return new FileData(new ByteSource() {
@Override
public InputStream openStream() throws IOException {
return s3Client.getObject(GetObjectRequest.builder().bucket(fineractProperties.getContent().getS3().getBucketName())
.key(documentData.getLocation()).build(), ResponseTransformer.toBytes()).asInputStream();
}
}, documentData.getFileName(), documentData.getType());
}
@Override
public FileData fetchImage(final ImageData imageData) {
return new FileData(new ByteSource() {
@Override
public InputStream openStream() throws IOException {
return s3Client.getObject(GetObjectRequest.builder().bucket(fineractProperties.getContent().getS3().getBucketName())
.key(imageData.location()).build(), ResponseTransformer.toBytes()).asInputStream();
}
}, imageData.getEntityDisplayName(), imageData.contentType().getValue());
}
@Override
public StorageType getStorageType() {
return StorageType.S3;
}
private String generateFileParentDirectory(final String entityType, final Long entityId) {
return "documents" + File.separator + entityType + File.separator + entityId + File.separator
+ ContentRepositoryUtils.generateRandomString();
}
private String generateClientImageParentDirectory(final Long resourceId) {
return "images" + File.separator + "clients" + File.separator + resourceId;
}
private void deleteObject(final String location) {
try {
this.s3Client.deleteObject(builder -> builder.bucket(fineractProperties.getContent().getS3().getBucketName()).key(location));
} catch (final AwsServiceException ase) {
throw new ContentManagementException(location,
"message=" + ase.getMessage() + ", Error Code=" + ase.awsErrorDetails().errorCode(), ase);
} catch (final SdkException ace) {
throw new ContentManagementException(location, ace.getMessage(), ace);
}
}
public void putObject(final String filename, final InputStream inputStream, final String s3UploadLocation)
throws ContentManagementException {
try {
if (log.isDebugEnabled()) {
log.debug("Uploading a new object to S3 {}", LogParameterEscapeUtil.escapeLogParameter(s3UploadLocation));
}
this.s3Client.putObject(
builder -> builder.bucket(fineractProperties.getContent().getS3().getBucketName()).key(s3UploadLocation),
RequestBody.fromBytes(IOUtils.toByteArray(inputStream)));
} catch (AwsServiceException | IOException ase) {
throw new ContentManagementException(filename, ase.getMessage(), ase);
}
}
public ResponseBytes<GetObjectResponse> getObject(String key) {
try {
log.debug("Downloading an object from Amazon S3 Bucket: {}, location: {}",
fineractProperties.getContent().getS3().getBucketName(), key);
return this.s3Client.getObject(builder -> builder.bucket(fineractProperties.getContent().getS3().getBucketName()).key(key),
ResponseTransformer.toBytes());
} catch (SdkException ase) {
throw new ContentManagementException(key, ase.getMessage(), ase);
}
}
}
@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.data;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.Hidden;
import java.io.InputStream;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DocumentContent implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String displayName;
private String fileName;
private String contentType;
private String format;
private Long size;
@Hidden
@JsonIgnore
private InputStream stream;
}
@@ -0,0 +1,61 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.data;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.InputStream;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DocumentCreateRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
@Size(max = 50, message = "{org.apache.fineract.document.xxx.not-null}")
private String entityType;
@NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
private Long entityId;
@Size(max = 250, message = "{org.apache.fineract.document.xxx.not-null}")
private String name;
@Size(max = 250, message = "{org.apache.fineract.document.xxx.not-null}")
private String description;
@Size(max = 250, message = "{org.apache.fineract.document.xxx.not-null}")
private String fileName;
@NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
@Min(value = 1, message = "{org.apache.fineract.document.xxx.not-null}")
private Long size;
private String type;
@Hidden
@JsonIgnore
private InputStream stream;
}
@@ -18,29 +18,22 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.data;
import com.google.common.io.ByteSource;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
public class FileData {
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DocumentCreateResponse implements Serializable {
private final String fileName;
private final String contentType;
private final ByteSource byteSource;
@Serial
private static final long serialVersionUID = 1L;
public FileData(final ByteSource byteSource, final String fileName, final String contentType) {
this.fileName = fileName;
this.contentType = contentType;
this.byteSource = byteSource;
}
public String contentType() {
return this.contentType;
}
public String name() {
return this.fileName;
}
public ByteSource getByteSource() {
return this.byteSource;
}
private Long resourceId;
private String resourceIdentifier;
}
@@ -20,32 +20,28 @@ package org.apache.fineract.infrastructure.documentmanagement.data;
import java.io.Serial;
import java.io.Serializable;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.documentmanagement.domain.StorageType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Immutable data object representing a user document being managed on the platform.
*/
@Getter
@RequiredArgsConstructor
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DocumentData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final String parentEntityType;
private final Long parentEntityId;
private final String name;
private final String fileName;
private final Long size;
private final String type;
private final String location;
private final String description;
private final Integer storageType;
public StorageType storageType() {
return StorageType.fromInt(this.storageType);
}
private Long id;
private String parentEntityType;
private Long parentEntityId;
private String name;
private String fileName;
private Long size;
private String type;
private String location;
private String description;
private Integer storageType;
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.data;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DocumentDeleteRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
private Long id;
@NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
@Size(max = 50, message = "{org.apache.fineract.document.xxx.not-null}")
private String entityType;
@NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
private Long entityId;
}
@@ -0,0 +1,39 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.data;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DocumentDeleteResponse implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long resourceId;
private String resourceIdentifier;
}
@@ -0,0 +1,63 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.data;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.InputStream;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DocumentUpdateRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
private Long id;
@NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
@Size(max = 50, message = "{org.apache.fineract.document.xxx.not-null}")
private String entityType;
@NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
private Long entityId;
@Size(max = 250, message = "{org.apache.fineract.document.xxx.not-null}")
private String name;
@Size(max = 250, message = "{org.apache.fineract.document.xxx.not-null}")
private String description;
@Size(max = 250, message = "{org.apache.fineract.document.xxx.not-null}")
private String fileName;
@NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
@Min(value = 1, message = "{org.apache.fineract.document.xxx.not-null}")
private Long size;
private String type;
@Hidden
@JsonIgnore
private InputStream stream;
}
@@ -0,0 +1,39 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.data;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DocumentUpdateResponse implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long resourceId;
private String resourceIdentifier;
}
@@ -0,0 +1,61 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.data;
import static jakarta.validation.constraints.Pattern.Flag.CASE_INSENSITIVE;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.io.InputStream;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ImageCreateRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
@Size(max = 50, message = "{org.apache.fineract.document.xxx.not-null}")
private String entityType;
@NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
private Long entityId;
@Size(max = 250, message = "{org.apache.fineract.document.xxx.not-null}")
private String fileName;
// @NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
@Min(value = 1, message = "{org.apache.fineract.document.xxx.not-null}")
private Long size;
@Pattern(regexp = "^(image\\/gif|image\\/jpeg|image\\/png)$", flags = CASE_INSENSITIVE, message = "{org.apache.fineract.document.xxx.not-null}")
private String type;
@Hidden
@JsonIgnore
private InputStream stream;
}
@@ -0,0 +1,39 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.data;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ImageCreateResponse implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long resourceId;
private String resourceIdentifier;
}
@@ -1,55 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.data;
import org.apache.fineract.infrastructure.documentmanagement.contentrepository.ContentRepositoryUtils;
import org.apache.fineract.infrastructure.documentmanagement.contentrepository.ContentRepositoryUtils.ImageMIMEtype;
import org.apache.fineract.infrastructure.documentmanagement.domain.StorageType;
public class ImageData {
private final String location;
private final StorageType storageType;
private final String entityDisplayName;
private final ContentRepositoryUtils.ImageMIMEtype contentType;
public ImageData(final String location, final StorageType storageType, final String entityDisplayName) {
this.location = location;
this.storageType = storageType;
this.entityDisplayName = entityDisplayName;
this.contentType = ContentRepositoryUtils.ImageMIMEtype
.fromFileExtension(ContentRepositoryUtils.imageExtensionFromFileName(location));
}
public ImageMIMEtype contentType() {
return this.contentType;
}
public StorageType storageType() {
return this.storageType;
}
public String location() {
return this.location;
}
public String getEntityDisplayName() {
return this.entityDisplayName;
}
}
@@ -0,0 +1,44 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.data;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ImageDeleteRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
@Size(max = 50, message = "{org.apache.fineract.document.xxx.not-null}")
private String entityType;
@NotNull(message = "{org.apache.fineract.document.xxx.not-null}")
private Long entityId;
}
@@ -0,0 +1,39 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.data;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ImageDeleteResponse implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long resourceId;
private String resourceIdentifier;
}
@@ -1,93 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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 org.apache.fineract.infrastructure.documentmanagement.data;
import com.google.common.io.ByteSource;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Optional;
import javax.imageio.ImageIO;
import org.apache.fineract.infrastructure.documentmanagement.contentrepository.ContentRepositoryUtils;
import org.apache.fineract.infrastructure.documentmanagement.contentrepository.ContentRepositoryUtils.ImageFileExtension;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class ImageResizer {
private static final Logger LOG = LoggerFactory.getLogger(ImageResizer.class);
public FileData resize(FileData fileData, Integer maxWidth, Integer maxHeight) {
if (maxWidth == null && maxHeight != null) {
return fileData;
}
try (InputStream is = fileData.getByteSource().openBufferedStream()) {
Optional<InputStream> optResizedIS = resizeImage(ContentRepositoryUtils.imageExtensionFromFileName(fileData.name()), is,
maxWidth != null ? maxWidth : Integer.MAX_VALUE, maxHeight != null ? maxHeight : Integer.MAX_VALUE);
if (optResizedIS.isPresent()) {
FileData resizedImage = new FileData(new ByteSource() {
@Override
public InputStream openStream() throws IOException {
return optResizedIS.get(); // NOSONAR
}
}, fileData.name(), fileData.contentType());
return resizedImage;
}
return fileData;
} catch (IOException e) {
LOG.warn("resize() failed, returning original image: {}", e.getMessage(), e);
return fileData;
}
}
private Optional<InputStream> resizeImage(ImageFileExtension fileExtension, InputStream in, int maxWidth, int maxHeight)
throws IOException {
BufferedImage src = ImageIO.read(in);
if (src.getWidth() <= maxWidth && src.getHeight() <= maxHeight) {
return Optional.empty();
}
float widthRatio = (float) src.getWidth() / maxWidth;
float heightRatio = (float) src.getHeight() / maxHeight;
float scaleRatio = widthRatio > heightRatio ? widthRatio : heightRatio;
// TODO(lindahl): Improve compressed image quality (perhaps quality ratio)
int newWidth = (int) (src.getWidth() / scaleRatio);
int newHeight = (int) (src.getHeight() / scaleRatio);
int colorModel = fileExtension == ContentRepositoryUtils.ImageFileExtension.JPEG ? BufferedImage.TYPE_INT_RGB
: BufferedImage.TYPE_INT_ARGB;
BufferedImage target = new BufferedImage(newWidth, newHeight, colorModel);
Graphics2D g = target.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(src, 0, 0, newWidth, newHeight, Color.BLACK, null);
g.dispose();
ByteArrayOutputStream os = new ByteArrayOutputStream();
ImageIO.write(target, fileExtension != null ? fileExtension.getValueWithoutDot() : "jpeg", os);
return Optional.of(new ByteArrayInputStream(os.toByteArray()));
}
}
@@ -18,150 +18,58 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.documentmanagement.command.DocumentCommand;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.Accessors;
import lombok.experimental.FieldNameConstants;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Entity
@Table(name = "m_document")
public class Document extends AbstractPersistableCustom<Long> {
@Table("m_document")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
@FieldNameConstants
public final class Document implements Serializable {
@Column(name = "parent_entity_type", length = 50)
@Serial
private static final long serialVersionUID = 1L;
@Id
@Column("id")
private Long id;
@Column("parent_entity_type")
private String parentEntityType;
@Column(name = "parent_entity_id", length = 1000)
@Column("parent_entity_id")
private Long parentEntityId;
@Column(name = "name", length = 250)
@Column("name")
private String name;
@Column(name = "file_name", length = 250)
@Column("file_name")
private String fileName;
@Column(name = "size")
@Column("size")
private Long size;
@Column(name = "type", length = 50)
@Column("type")
private String type;
@Column(name = "description", length = 1000)
@Column("description")
private String description;
@Column(name = "location", length = 500)
@Column("location")
private String location;
@Column(name = "storage_type_enum")
@Column("storage_type_enum")
private Integer storageType;
public Document() {}
public static Document createNew(final String parentEntityType, final Long parentEntityId, final String name, final String fileName,
final Long size, final String type, final String description, final String location, final StorageType storageType) {
return new Document(parentEntityType, parentEntityId, name, fileName, size, type, description, location, storageType);
}
private Document(final String parentEntityType, final Long parentEntityId, final String name, final String fileName, final Long size,
final String type, final String description, final String location, final StorageType storageType) {
this.parentEntityType = StringUtils.defaultIfEmpty(parentEntityType, null);
this.parentEntityId = parentEntityId;
this.name = StringUtils.defaultIfEmpty(name, null);
this.fileName = StringUtils.defaultIfEmpty(fileName, null);
this.size = size;
this.type = StringUtils.defaultIfEmpty(type, null);
this.description = StringUtils.defaultIfEmpty(description, null);
this.location = StringUtils.defaultIfEmpty(location, null);
this.storageType = storageType.getValue();
}
public void update(final DocumentCommand command) {
if (command.isDescriptionChanged()) {
this.description = command.getDescription();
}
if (command.isFileNameChanged()) {
this.fileName = command.getFileName();
}
if (command.isFileTypeChanged()) {
this.type = command.getType();
}
if (command.isLocationChanged()) {
this.location = command.getLocation();
}
if (command.isNameChanged()) {
this.name = command.getName();
}
if (command.isSizeChanged()) {
this.size = command.getSize();
}
}
public String getParentEntityType() {
return this.parentEntityType;
}
public void setParentEntityType(final String parentEntityType) {
this.parentEntityType = parentEntityType;
}
public Long getParentEntityId() {
return this.parentEntityId;
}
public void setParentEntityId(final Long parentEntityId) {
this.parentEntityId = parentEntityId;
}
public String getName() {
return this.name;
}
public void setName(final String name) {
this.name = name;
}
public String getFileName() {
return this.fileName;
}
public void setFileName(final String fileName) {
this.fileName = fileName;
}
public Long getSize() {
return this.size;
}
public void setSize(final Long size) {
this.size = size;
}
public String getType() {
return this.type;
}
public void setType(final String type) {
this.type = type;
}
public String getDescription() {
return this.description;
}
public void setDescription(final String description) {
this.description = description;
}
public String getLocation() {
return this.location;
}
public void setLocation(final String location) {
this.location = location;
}
public StorageType storageType() {
return StorageType.fromInt(this.storageType);
}
}

Some files were not shown because too many files have changed in this diff Show More