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
@@ -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();
}
}
@@ -0,0 +1,35 @@
/**
* 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.data;
public enum ContentStoreType {
FILE_SYSTEM(1), //
S3(2); //
private final Integer value;
ContentStoreType(final Integer value) {
this.value = value;
}
public Integer getValue() {
return this.value;
}
}
@@ -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");
}
// 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();
} else {
log.error("resizedImageBytes is null for entityName={}, entityId={}, maxWidth={}, maxHeight={}", entityName, entityId,
maxWidth, maxHeight);
return Response.serverError().build();
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()));
}
} catch (IOException e) {
throw new ContentManagementException(imageData.name(), e.getMessage(), e);
if (IMAGE_API_VALUE_OUTPUT_INLINE_OCTET.equalsIgnoreCase(output)) {
dispositionType = DISPOSITION_TYPE_INLINE;
} else {
dispositionType = DISPOSITION_TYPE_ATTACHMENT;
}
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;
}
@@ -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.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 DocumentData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
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);
}
}
@@ -18,9 +18,14 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.domain;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
import java.util.Optional;
import org.springframework.data.repository.ListCrudRepository;
import org.springframework.data.repository.query.QueryByExampleExecutor;
public interface DocumentRepository extends JpaRepository<Document, Long>, JpaSpecificationExecutor<Document> {
// no added behaviour
public interface DocumentRepository extends ListCrudRepository<Document, Long>, QueryByExampleExecutor<Document> {
List<Document> findAllByParentEntityTypeAndParentEntityId(String parentEntityType, Long parentEntityId);
Optional<Document> findByIdAndParentEntityTypeAndParentEntityId(Long id, String entityType, Long entityId);
}
@@ -0,0 +1,54 @@
/**
* 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.domain;
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;
@Table("m_image")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
@FieldNameConstants
public final class Image implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Id
@Column("id")
private Long id;
@Column("location")
private String location;
@Column("storage_type_enum")
private Integer storageType;
}
@@ -18,8 +18,7 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.domain;
import org.apache.fineract.portfolio.client.domain.Client;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.repository.ListCrudRepository;
import org.springframework.data.repository.query.QueryByExampleExecutor;
public interface ImageRepository extends JpaRepository<Image, Long>, JpaSpecificationExecutor<Client> {}
public interface ImageRepository extends ListCrudRepository<Image, Long>, QueryByExampleExecutor<Image> {}
@@ -1,39 +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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class ContentManagementException extends AbstractPlatformDomainRuleException {
public ContentManagementException(final String filename, final String message) {
super("error.msg.document.save", "Error while manipulating file " + filename + " due to a ContentRepository issue " + message,
filename, message);
}
public ContentManagementException(final String name, final Long fileSize, final int maxFileSize) {
super("error.msg.document.file.too.big", "Unable to save the document with name" + name + " since its file Size of "
+ fileSize / (1024 * 1024) + " MB exceeds the max permissable file size of " + maxFileSize + " MB", name, fileSize);
}
public ContentManagementException(String filename, String message, Exception exception) {
super("error.msg.document.save", "Error while manipulating file " + filename + " due to a ContentRepository issue " + message,
filename, message, exception);
}
}
@@ -18,14 +18,11 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Runtime exception for invalid image types
*/
public class InvalidEntityTypeForImageManagementException extends AbstractPlatformResourceNotFoundException {
public class DocumentInvalidRequestException extends AbstractPlatformDomainRuleException {
public InvalidEntityTypeForImageManagementException(String imageType) {
super("error.imagemanagement.entitytype.invalid", "Image Management is not support for the Entity Type: " + imageType);
public DocumentInvalidRequestException(Exception e) {
super("error.msg.document.request.invalid", e.getMessage(), e);
}
}
@@ -18,24 +18,16 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
import software.amazon.awssdk.core.exception.SdkException;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class DocumentNotFoundException extends AbstractPlatformResourceNotFoundException {
public class DocumentNotFoundException extends AbstractPlatformDomainRuleException {
public DocumentNotFoundException(final Long id) {
super("error.msg.document.id.invalid", "Document with identifier " + id + " does not exist", id);
}
public DocumentNotFoundException(final String entityType, final Long entityId, final Long id) {
super("error.msg.document.id.invalid",
"Document with identifier " + id + " does not exist for the " + entityType + " with Identifier " + entityId, id);
}
public DocumentNotFoundException(String entityType, Long entityId, Long id, EmptyResultDataAccessException e) {
super("error.msg.document.id.invalid",
"Document with identifier " + id + " does not exist for the " + entityType + " with Identifier " + entityId, id, e);
}
public DocumentNotFoundException(String entityType, Long entityId, Long id, SdkException ace) {
super("error.msg.document.id.invalid",
"Document with identifier " + id + " does not exist for the " + entityType + " with Identifier " + entityId, id, ace);
}
}
@@ -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.handler;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateRequest;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse;
import org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class DocumentCreateCommandHandler implements CommandHandler<DocumentCreateRequest, DocumentCreateResponse> {
private final DocumentWritePlatformService writePlatformService;
@Retry(name = "commandDocumentCreate", fallbackMethod = "fallback")
@Transactional
@Override
public DocumentCreateResponse handle(final Command<DocumentCreateRequest> command) {
return writePlatformService.createDocument(command.getPayload());
}
@Override
public DocumentCreateResponse fallback(Command<DocumentCreateRequest> command, Throwable t) {
// NOTE: fallback method needs to be in the same class
return CommandHandler.super.fallback(command, t);
}
}
@@ -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.handler;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteRequest;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteResponse;
import org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class DocumentDeleteCommandHandler implements CommandHandler<DocumentDeleteRequest, DocumentDeleteResponse> {
private final DocumentWritePlatformService writePlatformService;
@Retry(name = "commandDocumentDelete", fallbackMethod = "fallback")
@Transactional
@Override
public DocumentDeleteResponse handle(final Command<DocumentDeleteRequest> command) {
return writePlatformService.deleteDocument(command.getPayload());
}
@Override
public DocumentDeleteResponse fallback(Command<DocumentDeleteRequest> command, Throwable t) {
// NOTE: fallback method needs to be in the same class
return CommandHandler.super.fallback(command, t);
}
}
@@ -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.handler;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentUpdateRequest;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentUpdateResponse;
import org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class DocumentUpdateCommandHandler implements CommandHandler<DocumentUpdateRequest, DocumentUpdateResponse> {
private final DocumentWritePlatformService writePlatformService;
@Retry(name = "commandDocumentUpdate", fallbackMethod = "fallback")
@Transactional
@Override
public DocumentUpdateResponse handle(final Command<DocumentUpdateRequest> command) {
return writePlatformService.updateDocument(command.getPayload());
}
@Override
public DocumentUpdateResponse fallback(Command<DocumentUpdateRequest> command, Throwable t) {
// NOTE: fallback method needs to be in the same class
return CommandHandler.super.fallback(command, t);
}
}
@@ -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.handler;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.infrastructure.documentmanagement.data.ImageCreateRequest;
import org.apache.fineract.infrastructure.documentmanagement.data.ImageCreateResponse;
import org.apache.fineract.infrastructure.documentmanagement.service.ImageWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class ImageCreateCommandHandler implements CommandHandler<ImageCreateRequest, ImageCreateResponse> {
private final ImageWritePlatformService writePlatformService;
@Retry(name = "commandImageCreate", fallbackMethod = "fallback")
@Transactional
@Override
public ImageCreateResponse handle(final Command<ImageCreateRequest> command) {
return writePlatformService.createImage(command.getPayload());
}
@Override
public ImageCreateResponse fallback(Command<ImageCreateRequest> command, Throwable t) {
// NOTE: fallback method needs to be in the same class
return CommandHandler.super.fallback(command, t);
}
}
@@ -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.handler;
import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.command.core.Command;
import org.apache.fineract.command.core.CommandHandler;
import org.apache.fineract.infrastructure.documentmanagement.data.ImageDeleteRequest;
import org.apache.fineract.infrastructure.documentmanagement.data.ImageDeleteResponse;
import org.apache.fineract.infrastructure.documentmanagement.service.ImageWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class ImageDeleteCommandHandler implements CommandHandler<ImageDeleteRequest, ImageDeleteResponse> {
private final ImageWritePlatformService writePlatformService;
@Retry(name = "commandImageDelete", fallbackMethod = "fallback")
@Transactional
@Override
public ImageDeleteResponse handle(final Command<ImageDeleteRequest> command) {
return writePlatformService.deleteImage(command.getPayload());
}
@Override
public ImageDeleteResponse fallback(Command<ImageDeleteRequest> command, Throwable t) {
// NOTE: fallback method needs to be in the same class
return CommandHandler.super.fallback(command, t);
}
}
@@ -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.documentmanagement.mapping;
import static org.mapstruct.InjectionStrategy.CONSTRUCTOR;
import static org.mapstruct.MappingConstants.ComponentModel.SPRING;
import java.io.InputStream;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentContent;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.domain.Document;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = SPRING, injectionStrategy = CONSTRUCTOR)
public interface DocumentMapper {
DocumentData map(Document source);
@Mapping(source = "source.type", target = "contentType")
@Mapping(source = "source.name", target = "displayName")
@Mapping(source = "source.fileName", target = "fileName")
@Mapping(source = "source.size", target = "size")
@Mapping(ignore = true, target = "format")
@Mapping(source = "stream", target = "stream")
DocumentContent map(Document source, InputStream stream);
}
@@ -19,14 +19,16 @@
package org.apache.fineract.infrastructure.documentmanagement.service;
import java.util.List;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentContent;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.data.FileData;
public interface DocumentReadPlatformService {
List<DocumentData> retrieveAllDocuments(String entityType, Long entityId);
FileData retrieveFileData(String entityType, Long entityId, Long documentId);
DocumentData retrieveDocument(Long id);
DocumentContent retrieveDocumentContent(String entityType, Long entityId, Long documentId);
DocumentData retrieveDocument(String entityType, Long entityId, Long documentId);
@@ -18,108 +18,50 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.infrastructure.documentmanagement.contentrepository.ContentRepository;
import org.apache.fineract.infrastructure.documentmanagement.contentrepository.ContentRepositoryFactory;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentContent;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.data.FileData;
import org.apache.fineract.infrastructure.documentmanagement.domain.DocumentRepository;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentNotFoundException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.apache.fineract.infrastructure.documentmanagement.mapping.DocumentMapper;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final ContentRepositoryFactory contentRepositoryFactory;
private final ContentStoreService storeService;
private final DocumentRepository documentRepository;
private final DocumentMapper documentMapper;
@Override
public List<DocumentData> retrieveAllDocuments(final String entityType, final Long entityId) {
final var docs = documentRepository.findAllByParentEntityTypeAndParentEntityId(entityType, entityId);
this.context.authenticatedUser();
// TODO verify if the entities are valid and a user
// has data
// scope for the particular entities
final DocumentMapper mapper = new DocumentMapper(true, true);
final String sql = "select " + mapper.schema() + " order by d.id";
return this.jdbcTemplate.query(sql, mapper, new Object[] { entityType, entityId }); // NOSONAR
return docs.stream().map(documentMapper::map).toList();
}
@Override
public FileData retrieveFileData(final String entityType, final Long entityId, final Long documentId) {
try {
final DocumentMapper mapper = new DocumentMapper(false, false);
final DocumentData documentData = fetchDocumentDetails(entityType, entityId, documentId, mapper);
final ContentRepository contentRepository = this.contentRepositoryFactory.getRepository(documentData.storageType());
return contentRepository.fetchFile(documentData);
} catch (final EmptyResultDataAccessException e) {
throw new DocumentNotFoundException(entityType, entityId, documentId, e);
}
public DocumentData retrieveDocument(final Long id) {
return documentRepository.findById(id).map(documentMapper::map).orElseThrow(() -> new DocumentNotFoundException(id));
}
@Override
public DocumentContent retrieveDocumentContent(final String entityType, final Long entityId, final Long documentId) {
final var doc = documentRepository.findByIdAndParentEntityTypeAndParentEntityId(documentId, entityType, entityId)
.orElseThrow(() -> new DocumentNotFoundException(entityType, entityId, documentId));
final var is = storeService.download(doc.getLocation());
return documentMapper.map(doc, is);
}
@Override
public DocumentData retrieveDocument(final String entityType, final Long entityId, final Long documentId) {
try {
final DocumentMapper mapper = new DocumentMapper(true, true);
return fetchDocumentDetails(entityType, entityId, documentId, mapper);
} catch (final EmptyResultDataAccessException e) {
throw new DocumentNotFoundException(entityType, entityId, documentId, e);
}
}
final var doc = documentRepository.findByIdAndParentEntityTypeAndParentEntityId(documentId, entityType, entityId)
.orElseThrow(() -> new DocumentNotFoundException(entityType, entityId, documentId));
private DocumentData fetchDocumentDetails(final String entityType, final Long entityId, final Long documentId,
final DocumentMapper mapper) {
final String sql = "select " + mapper.schema() + " and d.id=? ";
return this.jdbcTemplate.queryForObject(sql, mapper, new Object[] { entityType, entityId, documentId }); // NOSONAR
}
private static final class DocumentMapper implements RowMapper<DocumentData> {
private final boolean hideLocation;
private final boolean hideStorageType;
DocumentMapper(final boolean hideLocation, final boolean hideStorageType) {
this.hideLocation = hideLocation;
this.hideStorageType = hideStorageType;
}
public String schema() {
return "d.id as id, d.parent_entity_type as parentEntityType, d.parent_entity_id as parentEntityId, d.name as name, "
+ " d.file_name as fileName, d.size as fileSize, d.type as fileType, "
+ " d.description as description, d.location as location," + " d.storage_type_enum as storageType"
+ " from m_document d where d.parent_entity_type=? and d.parent_entity_id=?";
}
@Override
public DocumentData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException {
final Long id = JdbcSupport.getLong(rs, "id");
final Long parentEntityId = JdbcSupport.getLong(rs, "parentEntityId");
final Long fileSize = JdbcSupport.getLong(rs, "fileSize");
final String parentEntityType = rs.getString("parentEntityType");
final String name = rs.getString("name");
final String fileName = rs.getString("fileName");
final String fileType = rs.getString("fileType");
final String description = rs.getString("description");
String location = null;
Integer storageType = null;
if (!this.hideLocation) {
location = rs.getString("location");
}
if (!this.hideStorageType) {
storageType = rs.getInt("storageType");
}
return new DocumentData(id, parentEntityType, parentEntityId, name, fileName, fileSize, fileType, location, description,
storageType);
}
return documentMapper.map(doc);
}
}
@@ -18,24 +18,18 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.service;
import java.io.InputStream;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.documentmanagement.command.DocumentCommand;
import org.springframework.security.access.prepost.PreAuthorize;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateRequest;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse;
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;
public interface DocumentWritePlatformService {
@PreAuthorize(value = "hasAnyAuthority('ALL_FUNCTIONS', 'CREATE_DOCUMENT')")
Long createDocument(DocumentCommand documentCommand, InputStream inputStream);
DocumentCreateResponse createDocument(DocumentCreateRequest request);
@PreAuthorize(value = "hasAnyAuthority('ALL_FUNCTIONS', 'UPDATE_DOCUMENT')")
CommandProcessingResult updateDocument(DocumentCommand documentCommand, InputStream inputStream);
@PreAuthorize(value = "hasAnyAuthority('ALL_FUNCTIONS', 'DELETE_DOCUMENT')")
CommandProcessingResult deleteDocument(DocumentCommand documentCommand);
@PreAuthorize(value = "hasAnyAuthority('ALL_FUNCTIONS', 'CREATE_DOCUMENT')")
Long createInternalDocument(String entityType, Long entityId, Long fileSize, InputStream inputStream, String mimeType, String name,
String description, String fileName);
DocumentUpdateResponse updateDocument(DocumentUpdateRequest request);
DocumentDeleteResponse deleteDocument(DocumentDeleteRequest request);
}
@@ -0,0 +1,175 @@
/**
* 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.service;
import static java.util.Objects.requireNonNull;
import jakarta.validation.Valid;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateRequest;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse;
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.domain.Document;
import org.apache.fineract.infrastructure.documentmanagement.domain.DocumentRepository;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentNotFoundException;
import org.apache.fineract.infrastructure.documentmanagement.mapping.DocumentMapper;
import org.apache.fineract.infrastructure.event.business.domain.document.DocumentCreatedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.document.DocumentDeletedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
@Service
public class DocumentWritePlatformServiceImpl implements DocumentWritePlatformService {
private static final String STORE_PREFIX = "documents";
private final DocumentRepository documentRepository;
private final DocumentMapper documentMapper;
private final ContentStoreService storeService;
private final BusinessEventNotifierService businessEventNotifierService;
private final FineractProperties properties;
@Transactional
@Override
public DocumentCreateResponse createDocument(@Valid final DocumentCreateRequest request) {
try {
requireNonNull(request, "Request parameter required");
requireNonNull(request.getFileName(), "File name parameter required");
requireNonNull(request.getStream(), "Document content required");
// TODO: make "prefix" configurable
var path = getPath(STORE_PREFIX, request.getEntityType(), request.getEntityId(), request.getFileName(),
storeService.getDelimiter());
path = storeService.upload(path, request.getStream(), request.getType());
final var doc = new Document().setParentEntityType(request.getEntityType()).setParentEntityId(request.getEntityId())
.setName(Optional.ofNullable(request.getName()).orElse(request.getFileName())).setFileName(request.getFileName())
.setSize(request.getSize()).setType(request.getType()).setDescription(request.getDescription()).setLocation(path)
.setStorageType(storeService.getType().getValue());
documentRepository.save(doc);
businessEventNotifierService.notifyPostBusinessEvent(new DocumentCreatedBusinessEvent(documentMapper.map(doc)));
return DocumentCreateResponse.builder().resourceId(doc.getId()).resourceIdentifier(request.getEntityType()).build();
} catch (final Exception e) {
throw ErrorHandler.getMappable(e, "error.msg.document.unknown.data.integrity.issue",
"Unknown data integrity issue with resource.");
}
}
@Transactional
@Override
public DocumentUpdateResponse updateDocument(@Valid final DocumentUpdateRequest request) {
try {
requireNonNull(request, "Request parameter required");
String path = null;
if (StringUtils.isNotEmpty(request.getFileName())) {
// TODO: make "prefix" configurable
path = getPath(STORE_PREFIX, request.getEntityType(), request.getEntityId(), request.getFileName(),
storeService.getDelimiter());
}
final var doc = this.documentRepository.findById(request.getId())
.orElseThrow(() -> new DocumentNotFoundException(request.getEntityType(), request.getEntityId(), request.getId()));
doc.setStorageType(storeService.getType().getValue());
if (StringUtils.isNotEmpty(request.getFileName())) {
// these two only make sense if we have an actual file
if (request.getStream() != null && StringUtils.isNotEmpty(path) && !Strings.CI.equals(doc.getLocation(), path)) {
storeService.delete(doc.getLocation());
storeService.upload(path, request.getStream(), request.getType());
doc.setLocation(path);
doc.setFileName(FilenameUtils.getName(path));
}
} else if (request.getStream() != null) {
storeService.upload(doc.getLocation(), request.getStream(), request.getType());
}
if (StringUtils.isNotEmpty(request.getDescription()) && !Strings.CI.equals(doc.getDescription(), request.getDescription())) {
doc.setDescription(request.getDescription());
}
if (StringUtils.isNotEmpty(request.getName()) && !Strings.CI.equals(doc.getName(), request.getName())) {
doc.setName(request.getName());
}
if (Objects.requireNonNullElse(request.getSize(), 0L) > 1L && !Objects.equals(doc.getSize(), request.getSize())) {
doc.setSize(request.getSize());
}
documentRepository.save(doc);
// TODO: shouldn't we send a business event?
// businessEventNotifierService.notifyPostBusinessEvent(new DocumentUpdateBusinessEvent(doc));
return DocumentUpdateResponse.builder().resourceId(doc.getId()).resourceIdentifier(request.getEntityType()).build();
} catch (final Exception e) {
throw ErrorHandler.getMappable(e, "error.msg.document.unknown.data.integrity.issue",
"Unknown data integrity issue with resource.");
}
}
@Transactional
@Override
public DocumentDeleteResponse deleteDocument(@Valid final DocumentDeleteRequest request) {
try {
final var doc = this.documentRepository.findById(request.getId())
.orElseThrow(() -> new DocumentNotFoundException(request.getEntityType(), request.getEntityId(), request.getId()));
storeService.delete(doc.getLocation());
documentRepository.deleteById(request.getId());
businessEventNotifierService.notifyPostBusinessEvent(new DocumentDeletedBusinessEvent(documentMapper.map(doc)));
return DocumentDeleteResponse.builder().resourceId(doc.getId()).resourceIdentifier(request.getEntityType()).build();
} catch (final Exception e) {
throw ErrorHandler.getMappable(e, "error.msg.document.unknown.data.integrity.issue",
"Unknown data integrity issue with resource.");
}
}
private String getPath(final String prefix, final String entityType, final Long entityId, final String fileName, String delimiter) {
requireNonNull(prefix);
requireNonNull(entityType);
requireNonNull(entityId);
requireNonNull(fileName);
requireNonNull(delimiter);
return String.join(delimiter, prefix, entityType, String.valueOf(entityId), fileName);
}
}
@@ -1,200 +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.service;
import java.io.InputStream;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.documentmanagement.command.DocumentCommand;
import org.apache.fineract.infrastructure.documentmanagement.command.DocumentCommandValidator;
import org.apache.fineract.infrastructure.documentmanagement.contentrepository.ContentRepository;
import org.apache.fineract.infrastructure.documentmanagement.contentrepository.ContentRepositoryFactory;
import org.apache.fineract.infrastructure.documentmanagement.domain.Document;
import org.apache.fineract.infrastructure.documentmanagement.domain.DocumentRepository;
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.documentmanagement.exception.InvalidEntityTypeForDocumentManagementException;
import org.apache.fineract.infrastructure.event.business.domain.document.DocumentCreatedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.document.DocumentDeletedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class DocumentWritePlatformServiceJpaRepositoryImpl implements DocumentWritePlatformService {
private static final Logger LOG = LoggerFactory.getLogger(DocumentWritePlatformServiceJpaRepositoryImpl.class);
private final PlatformSecurityContext context;
private final DocumentRepository documentRepository;
private final ContentRepositoryFactory contentRepositoryFactory;
private final BusinessEventNotifierService businessEventNotifierService;
@Autowired
public DocumentWritePlatformServiceJpaRepositoryImpl(final PlatformSecurityContext context, final DocumentRepository documentRepository,
final ContentRepositoryFactory documentStoreFactory, BusinessEventNotifierService businessEventNotifierService) {
this.context = context;
this.documentRepository = documentRepository;
this.contentRepositoryFactory = documentStoreFactory;
this.businessEventNotifierService = businessEventNotifierService;
}
@Transactional
@Override
public Long createDocument(final DocumentCommand documentCommand, final InputStream inputStream) {
try {
this.context.authenticatedUser();
final DocumentCommandValidator validator = new DocumentCommandValidator(documentCommand);
validateParentEntityType(documentCommand);
validator.validateForCreate();
final ContentRepository contentRepository = this.contentRepositoryFactory.getRepository();
final String fileLocation = contentRepository.saveFile(inputStream, documentCommand);
final Document document = Document.createNew(documentCommand.getParentEntityType(), documentCommand.getParentEntityId(),
documentCommand.getName(), documentCommand.getFileName(), documentCommand.getSize(), documentCommand.getType(),
documentCommand.getDescription(), fileLocation, contentRepository.getStorageType());
this.documentRepository.saveAndFlush(document);
businessEventNotifierService.notifyPostBusinessEvent(new DocumentCreatedBusinessEvent(document));
return document.getId();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
LOG.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.document.unknown.data.integrity.issue",
"Unknown data integrity issue with resource.");
}
}
@Transactional
@Override
public Long createInternalDocument(final String entityType, final Long entityId, final Long fileSize, final InputStream inputStream,
final String mimeType, final String name, final String description, final String fileName) {
final DocumentCommand documentCommand = new DocumentCommand(null, null, entityType, entityId, name, fileName, fileSize, mimeType,
description, null);
return createDocument(documentCommand, inputStream);
}
@Transactional
@Override
public CommandProcessingResult updateDocument(final DocumentCommand documentCommand, final InputStream inputStream) {
try {
this.context.authenticatedUser();
String oldLocation = null;
final DocumentCommandValidator validator = new DocumentCommandValidator(documentCommand);
validator.validateForUpdate();
// TODO check if entity id is valid and within data scope for the
// user
final Document documentForUpdate = this.documentRepository.findById(documentCommand.getId())
.orElseThrow(() -> new DocumentNotFoundException(documentCommand.getParentEntityType(),
documentCommand.getParentEntityId(), documentCommand.getId()));
final StorageType documentStoreType = documentForUpdate.storageType();
oldLocation = documentForUpdate.getLocation();
if (inputStream != null && documentCommand.isFileNameChanged()) {
final ContentRepository contentRepository = this.contentRepositoryFactory.getRepository();
documentCommand.setLocation(contentRepository.saveFile(inputStream, documentCommand));
documentCommand.setStorageType(contentRepository.getStorageType().getValue());
}
documentForUpdate.update(documentCommand);
if (inputStream != null && documentCommand.isFileNameChanged()) {
final ContentRepository contentRepository = this.contentRepositoryFactory.getRepository(documentStoreType);
contentRepository.deleteFile(oldLocation);
}
this.documentRepository.saveAndFlush(documentForUpdate);
return CommandProcessingResult.resourceResult(documentForUpdate.getId());
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
LOG.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.document.unknown.data.integrity.issue",
"Unknown data integrity issue with resource.");
} catch (final ContentManagementException cme) {
LOG.error("Error occured.", cme);
throw new ContentManagementException(documentCommand.getName(), cme.getMessage(), cme);
}
}
@Transactional
@Override
public CommandProcessingResult deleteDocument(final DocumentCommand documentCommand) {
this.context.authenticatedUser();
validateParentEntityType(documentCommand);
// TODO: Check document is present under this entity Id
final Document document = this.documentRepository.findById(documentCommand.getId())
.orElseThrow(() -> new DocumentNotFoundException(documentCommand.getParentEntityType(), documentCommand.getParentEntityId(),
documentCommand.getId()));
this.documentRepository.delete(document);
final ContentRepository contentRepository = this.contentRepositoryFactory.getRepository(document.storageType());
contentRepository.deleteFile(document.getLocation());
businessEventNotifierService.notifyPostBusinessEvent(new DocumentDeletedBusinessEvent(document));
return CommandProcessingResult.resourceResult(document.getId());
}
private void validateParentEntityType(final DocumentCommand documentCommand) {
if (!checkValidEntityType(documentCommand.getParentEntityType())) {
throw new InvalidEntityTypeForDocumentManagementException(documentCommand.getParentEntityType());
}
}
private static boolean checkValidEntityType(final String entityType) {
for (final DocumentManagementEntity entities : DocumentManagementEntity.values()) {
if (entities.name().equalsIgnoreCase(entityType)) {
return true;
}
}
return false;
}
/*** Entities for document Management **/
public enum DocumentManagementEntity {
CLIENTS, //
CLIENT_IDENTIFIERS, //
STAFF, //
LOANS, //
SAVINGS, //
GROUPS, //
IMPORT; //
@Override
public String toString() {
return name().toLowerCase();
}
}
}
@@ -18,10 +18,10 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.service;
import org.apache.fineract.infrastructure.documentmanagement.data.FileData;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentContent;
public interface ImageReadPlatformService {
FileData retrieveImage(String entityType, Long entityId);
DocumentContent retrieveImage(String entityType, Long entityId);
}
@@ -0,0 +1,69 @@
/**
* 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.service;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorContext;
import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorManager;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.infrastructure.documentmanagement.adapter.EntityImageIdAdapter;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentContent;
import org.apache.fineract.infrastructure.documentmanagement.domain.ImageRepository;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentInvalidRequestException;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentNotFoundException;
import org.springframework.stereotype.Service;
@Slf4j
@RequiredArgsConstructor
@Service
public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
@Deprecated
private final List<EntityImageIdAdapter> imageIdAdapters;
private final ContentStoreService storeService;
private final ImageRepository imageRepository;
private final ContentDetectorManager contentDetectorManager;
@Override
public DocumentContent retrieveImage(final String entityType, final Long entityId) {
try {
return imageIdAdapters.stream().filter(imageIdAdapter -> imageIdAdapter.accept(entityType)).findFirst()
.flatMap(imageIdAdapter -> imageIdAdapter.get(entityId))
.flatMap(
imageIdResult -> imageRepository.findById(imageIdResult.getId())
.map(image -> DocumentContent
.builder().fileName(
FilenameUtils.getName(image.getLocation()))
.format(FilenameUtils.getExtension(image.getLocation()))
.displayName(imageIdResult.getDisplayName())
.contentType(
contentDetectorManager
.detect(ContentDetectorContext.builder()
.fileName(FilenameUtils.getName(image.getLocation())).build())
.getMimeType())
.stream(storeService.download(image.getLocation())).build()))
.orElseThrow(() -> new DocumentNotFoundException(entityType, entityId, -1L));
} catch (final Exception e) {
throw new DocumentInvalidRequestException(e);
}
}
}
@@ -18,19 +18,14 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.service;
import java.io.InputStream;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.domain.Base64EncodedImage;
import org.springframework.security.access.prepost.PreAuthorize;
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;
public interface ImageWritePlatformService {
@PreAuthorize(value = "hasAnyAuthority('ALL_FUNCTIONS', 'CREATE_CLIENTIMAGE','CREATE_STAFFIMAGE')")
CommandProcessingResult saveOrUpdateImage(String entityName, Long entityId, String imageName, InputStream inputStream, Long fileSize);
ImageCreateResponse createImage(ImageCreateRequest request);
@PreAuthorize(value = "hasAnyAuthority('ALL_FUNCTIONS', 'CREATE_CLIENTIMAGE','CREATE_STAFFIMAGE')")
CommandProcessingResult saveOrUpdateImage(String entityName, Long entityId, Base64EncodedImage encodedImage);
@PreAuthorize(value = "hasAnyAuthority('ALL_FUNCTIONS', 'DELETE_CLIENTIMAGE','DELETE_STAFFIMAGE')")
CommandProcessingResult deleteImage(String entityName, Long entityId);
ImageDeleteResponse deleteImage(ImageDeleteRequest request);
}
@@ -0,0 +1,147 @@
/**
* 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.service;
import static java.util.Objects.requireNonNull;
import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorContext;
import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorManager;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.infrastructure.documentmanagement.adapter.EntityImageIdAdapter;
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.domain.Image;
import org.apache.fineract.infrastructure.documentmanagement.domain.ImageRepository;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentInvalidRequestException;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
@Service
public class ImageWritePlatformServiceImpl implements ImageWritePlatformService {
private static final String STORE_PREFIX = "images";
private static final String DEFAULT_ENTITY_TYPE = "clients";
private static final String DEFAULT_EXTENSION = ".jpg";
@Deprecated
private final List<EntityImageIdAdapter> imageIdAdapters;
private final ContentStoreService storeService;
private final ImageRepository imageRepository;
private final ContentDetectorManager contentDetectorManager;
@Transactional
@Override
public ImageCreateResponse createImage(final ImageCreateRequest request) {
try {
if (StringUtils.isEmpty(request.getEntityType())) {
// TODO: keeping the path segment always "clients" not consistent how this works with documents
request.setEntityType(DEFAULT_ENTITY_TYPE);
}
if (StringUtils.isEmpty(request.getFileName())) {
// NOTE: defacto limiting the uploads to JPEG files, same behavior as before
request.setFileName(UUID.randomUUID() + DEFAULT_EXTENSION);
}
if (StringUtils.isEmpty(request.getType())) {
final var type = Optional
.of(contentDetectorManager.detect(ContentDetectorContext.builder().fileName(request.getFileName()).build()))
.filter(contentDetectorContext -> StringUtils.isNotEmpty(contentDetectorContext.getMimeType()))
.filter(contentDetectorContext -> !APPLICATION_OCTET_STREAM_VALUE
.equalsIgnoreCase(contentDetectorContext.getMimeType()))
.map(ContentDetectorContext::getMimeType).orElse(APPLICATION_OCTET_STREAM_VALUE);
request.setType(type);
}
// TODO: make "prefix" configurable?
var path = getPath(STORE_PREFIX, request.getEntityType(), request.getEntityId(), request.getFileName(),
storeService.getDelimiter());
final var imagePath = storeService.upload(path, request.getStream(), request.getType());
final var result = imageIdAdapters.stream().filter(imageIdAdapter -> imageIdAdapter.accept(request.getEntityType())).findFirst()
.flatMap(imageIdAdapter -> imageIdAdapter.get(request.getEntityId()))
.flatMap(imageIdResult -> imageRepository.findById(imageIdResult.getId())).map(image -> {
// delete old image
storeService.delete(image.getLocation());
return image;
}).or(() -> Optional.of(new Image()))
.map(image -> image.setLocation(imagePath).setStorageType(storeService.getType().getValue())).map(imageRepository::save)
.map(image -> ImageCreateResponse.builder().resourceId(image.getId()).build());
imageIdAdapters.stream().filter(imageIdAdapter -> imageIdAdapter.accept(request.getEntityType())).findFirst()
.ifPresent(imageIdAdapter -> result.ifPresent(
imageCreateResponse -> imageIdAdapter.set(request.getEntityId(), imageCreateResponse.getResourceId())));
return result.orElseThrow(() -> new DocumentNotFoundException(request.getEntityType(), request.getEntityId(), -1L));
} catch (final Exception e) {
throw new DocumentInvalidRequestException(e);
}
}
@Transactional
@Override
public ImageDeleteResponse deleteImage(final ImageDeleteRequest request) {
try {
return delete(request.getEntityType(), request.getEntityId())
.orElseThrow(() -> new DocumentNotFoundException(request.getEntityType(), request.getEntityId(), -1L));
} catch (final Exception e) {
throw new DocumentInvalidRequestException(e);
}
}
private Optional<ImageDeleteResponse> delete(final String entityType, final Long entityId) {
return imageIdAdapters.stream().filter(imageIdAdapter -> imageIdAdapter.accept(entityType)).findFirst()
.flatMap(imageIdAdapter -> imageIdAdapter.set(entityId, null))
.flatMap(imageIdResult -> imageRepository.findById(imageIdResult.getId())).map(image -> {
requireNonNull(image.getId(), "Image ID required");
requireNonNull(image.getLocation(), "Image location required");
storeService.delete(image.getLocation());
imageRepository.deleteById(image.getId());
return ImageDeleteResponse.builder().resourceId(entityId).resourceIdentifier(entityType).build();
});
}
private String getPath(final String prefix, final String entityType, final Long entityId, final String fileName, String delimiter) {
requireNonNull(prefix);
requireNonNull(entityType);
requireNonNull(entityId);
requireNonNull(fileName);
requireNonNull(delimiter);
return String.join(delimiter, prefix, entityType, String.valueOf(entityId), fileName);
}
}
@@ -18,14 +18,14 @@
*/
package org.apache.fineract.infrastructure.event.business.domain.document;
import org.apache.fineract.infrastructure.documentmanagement.domain.Document;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.event.business.domain.AbstractBusinessEvent;
public abstract class DocumentBusinessEvent extends AbstractBusinessEvent<Document> {
public abstract class DocumentBusinessEvent extends AbstractBusinessEvent<DocumentData> {
private static final String CATEGORY = "Document";
protected DocumentBusinessEvent(Document value) {
protected DocumentBusinessEvent(DocumentData value) {
super(value);
}
@@ -18,13 +18,13 @@
*/
package org.apache.fineract.infrastructure.event.business.domain.document;
import org.apache.fineract.infrastructure.documentmanagement.domain.Document;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
public class DocumentCreatedBusinessEvent extends DocumentBusinessEvent {
private static final String TYPE = "DocumentCreatedBusinessEvent";
public DocumentCreatedBusinessEvent(Document value) {
public DocumentCreatedBusinessEvent(DocumentData value) {
super(value);
}
@@ -18,13 +18,13 @@
*/
package org.apache.fineract.infrastructure.event.business.domain.document;
import org.apache.fineract.infrastructure.documentmanagement.domain.Document;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
public class DocumentDeletedBusinessEvent extends DocumentBusinessEvent {
private static final String TYPE = "DocumentDeletedBusinessEvent";
public DocumentDeletedBusinessEvent(Document value) {
public DocumentDeletedBusinessEvent(DocumentData value) {
super(value);
}
@@ -1,78 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<persistence version="2.0"
xmlns="http://java.sun.com/xml/ns/persistence"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">
<!-- This file is only used for static weaving, nothing more. -->
<!-- You can find the runtime configuration in the JPAConfig class -->
<persistence-unit name="jpa-pu" transaction-type="RESOURCE_LOCAL">
<provider>org.eclipse.persistence.jpa.PersistenceProvider</provider>
<!-- 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>
<class>org.apache.fineract.commands.domain.CommandSource</class>
<class>org.apache.fineract.useradministration.domain.AppUser</class>
<class>org.apache.fineract.accounting.glaccount.domain.GLAccount</class>
<class>org.apache.fineract.organisation.monetary.domain.OrganisationCurrency</class>
<class>org.apache.fineract.organisation.staff.domain.Staff</class>
<class>org.apache.fineract.portfolio.rate.domain.Rate</class>
<class>org.apache.fineract.organisation.monetary.domain.ApplicationCurrency</class>
<class>org.apache.fineract.portfolio.calendar.domain.CalendarInstance</class>
<class>org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail</class>
<class>org.apache.fineract.portfolio.calendar.domain.Calendar</class>
<class>org.apache.fineract.portfolio.calendar.domain.CalendarHistory</class>
<class>org.apache.fineract.portfolio.client.domain.ClientIdentifier</class>
<class>org.apache.fineract.portfolio.delinquency.domain.DelinquencyBucket</class>
<class>org.apache.fineract.portfolio.delinquency.domain.DelinquencyRange</class>
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
<class>org.apache.fineract.portfolio.group.domain.Group</class>
<class>org.apache.fineract.portfolio.client.domain.Client</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
<class>org.apache.fineract.portfolio.group.domain.GroupLevel</class>
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEvent</class>
<class>org.apache.fineract.organisation.office.domain.Office</class>
<class>org.apache.fineract.organisation.holiday.domain.Holiday</class>
<class>org.apache.fineract.infrastructure.cache.domain.PlatformCache</class>
<class>org.apache.fineract.infrastructure.codes.domain.Code</class>
<class>org.apache.fineract.infrastructure.businessdate.domain.BusinessDate</class>
<class>org.apache.fineract.infrastructure.codes.domain.CodeValue</class>
<!-- Document Module Entities -->
<class>org.apache.fineract.infrastructure.documentmanagement.domain.Document</class>
<exclude-unlisted-classes>false</exclude-unlisted-classes>
<properties>
<property name="eclipselink.weaving" value="static" />
</properties>
</persistence-unit>
</persistence>

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