Skip to content

Commit

Permalink
Impl. file upload endpoint
Browse files Browse the repository at this point in the history
Currently only implemented for S3 storage type.
  • Loading branch information
pvannierop committed Jun 18, 2024
1 parent 00ad614 commit 1e60613
Show file tree
Hide file tree
Showing 12 changed files with 503 additions and 1 deletion.
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ ext {
radarSpringAuthVersion = '1.2.1'
springSecurityVersion = '6.0.2'
hibernateValidatorVersion = '8.0.0.Final'
minioVersion = '8.5.10'
}

sourceSets {
Expand All @@ -64,6 +65,7 @@ dependencies {
implementation('org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:' + springBootVersion)
implementation('org.springframework.security.oauth:spring-security-oauth2:' + springOauth2Version)
runtimeOnly("org.hibernate.validator:hibernate-validator:$hibernateValidatorVersion")
implementation("io.minio:minio:$minioVersion")

// Open API spec
implementation(group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: springDocVersion)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

@Configuration
@EnableJpaAuditing
@EnableConfigurationProperties({FcmServerConfig.class})
@EnableConfigurationProperties({FcmServerConfig.class, S3StorageProperties.class})
@EnableTransactionManagement
@EnableAsync
@EnableScheduling
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
*
* * Copyright 2024 The Hyve
* *
* * Licensed 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.radarbase.appserver.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Data
@ConfigurationProperties("storage.s3")
public class S3StorageProperties {
private String url;
private String accessKey;
private String secretKey;
private String bucketName;
private String subPath;
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ public class PathsUtil {

public static final String USER_PATH = "users";
public static final String PROJECT_PATH = "projects";
public static final String TOPIC_PATH = "topics";
public static final String MESSAGING_NOTIFICATION_PATH = "messaging/notifications";
public static final String MESSAGING_DATA_PATH = "messaging/data";
public static final String PROTOCOL_PATH = "protocols";
public static final String PROJECT_ID_CONSTANT = "{projectId}";
public static final String SUBJECT_ID_CONSTANT = "{subjectId}";
public static final String TOPIC_ID_CONSTANT = "{topicId}";
public static final String NOTIFICATION_ID_CONSTANT = "{notificationId}";
public static final String NOTIFICATION_STATE_EVENTS_PATH = "state_events";
public static final String QUESTIONNAIRE_SCHEDULE_PATH = "questionnaire/schedule";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
*
* * Copyright 2024 The Hyve
* *
* * Licensed 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.radarbase.appserver.controller;

import lombok.extern.slf4j.Slf4j;
import org.radarbase.appserver.config.AuthConfig.AuthEntities;
import org.radarbase.appserver.config.AuthConfig.AuthPermissions;
import org.radarbase.appserver.service.storage.StorageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import radar.spring.auth.common.Authorized;
import radar.spring.auth.common.PermissionOn;

import java.net.URI;
import java.net.URISyntaxException;

/**
* Resource Endpoint for uploading assets to a data store.
*
* @author Pim van Nierop
*/
@CrossOrigin
@RestController
@ConditionalOnProperty(value = "file-upload.enabled", havingValue = "true")
@Slf4j
public class UploadController {

@Autowired
private StorageService storageService;

@Authorized(
permission = AuthPermissions.UPDATE,
entity = AuthEntities.SUBJECT,
permissionOn = PermissionOn.SUBJECT
)
@PostMapping(
"/" + PathsUtil.PROJECT_PATH + "/" + PathsUtil.PROJECT_ID_CONSTANT +
"/" + PathsUtil.USER_PATH + "/" + PathsUtil.SUBJECT_ID_CONSTANT +
"/" + PathsUtil.TOPIC_PATH + "/" + PathsUtil.TOPIC_ID_CONSTANT +
"/upload")
public ResponseEntity<?> subjectFileUpload(
@RequestParam("file") MultipartFile file,
@PathVariable String projectId,
@PathVariable String subjectId,
@PathVariable String topicId) throws URISyntaxException {

log.info("Storing file for project: {}, subject: {}, topic: {}", projectId, subjectId, topicId);

String filePath = storageService.store(file, projectId, subjectId, topicId);
return ResponseEntity.created(new URI(filePath)).build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
*
* * Copyright 2024 The Hyve
* *
* * Licensed 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.radarbase.appserver.service.storage;

import io.minio.BucketExistsArgs;
import io.minio.MinioClient;
import org.radarbase.appserver.config.S3StorageProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;

@Component
@ConditionalOnExpression("${file-upload.enabled:false} and 's3' == '${storage.type:}'")
public class MinioClientInitializer {

private MinioClient minioClient;
private String bucketName;

@Autowired
private S3StorageProperties s3StorageProperties;

@PostConstruct
public void init() {
try {
minioClient =
MinioClient.builder()
.endpoint(s3StorageProperties.getUrl())
.credentials(s3StorageProperties.getAccessKey(), s3StorageProperties.getSecretKey())
.build();
bucketName = s3StorageProperties.getBucketName();
boolean found =
minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
if (!found) {
throw new RuntimeException(String.format("S3 bucket '%s' does not exist", bucketName));
}
} catch (Exception e) {
throw new RuntimeException("Could not connect to S3", e);
}
}

public MinioClient getClient() {
return minioClient;
}

public String getBucketName() {
return bucketName;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
*
* * Copyright 2024 The Hyve
* *
* * Licensed 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.radarbase.appserver.service.storage;

import java.util.UUID;

public abstract class RandomUuidFilenameStorageService {

// Storing files under their original filename is a security risk, as it can be used to
// overwrite existing files. This method generates a random filename to mitigate this risk.
// See https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload
String generateRandomFilename(String originalFilename) {
return UUID.randomUUID() + getFileExtension(originalFilename);
}

private String getFileExtension(String originalFilename) {
int lastDot = originalFilename.lastIndexOf('.');
if (lastDot < 0) {
return "";
} else {
return originalFilename.substring(lastDot);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
*
* * Copyright 2024 The Hyve
* *
* * Licensed 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.radarbase.appserver.service.storage;

import io.minio.PutObjectArgs;
import lombok.extern.slf4j.Slf4j;
import org.radarbase.appserver.config.S3StorageProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.PostConstruct;


@Slf4j
@Service
@ConditionalOnExpression("${file-upload.enabled:false} and 's3' == '${storage.type:}'")
public class S3StorageService extends RandomUuidFilenameStorageService implements StorageService {

@Autowired
private S3StorageProperties s3StorageProperties;

@Autowired
private MinioClientInitializer bucketClient;
private String subPath = "";

@PostConstruct
public void init() {
if (s3StorageProperties.getSubPath() != null) {
subPath = s3StorageProperties.getSubPath().replaceAll("^/|/$", "");
if (!subPath.isEmpty()) {
subPath = subPath + "/";
}
}
}

public String store(MultipartFile file, String projectId, String subjectId, String topicId) {
Assert.notNull(file, "File must not be null");
Assert.notEmpty(new String[]{projectId, subjectId, topicId}, "Project, subject and topic IDs must not be empty");
String filePath = String.format("%s%s/%s/%s/%s", subPath, projectId, subjectId, topicId, generateRandomFilename(file.getOriginalFilename()));
log.debug("Storing file at path: {}", filePath);
try {
bucketClient.getClient().putObject(PutObjectArgs
.builder()
.bucket(bucketClient.getBucketName())
.object(filePath)
.stream(file.getInputStream(), file.getSize(), -1)
.build());
} catch (Exception e) {
throw new RuntimeException("Could not store file", e);
}
return filePath;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.radarbase.appserver.service.storage;

import org.springframework.web.multipart.MultipartFile;

public interface StorageService {
String store(MultipartFile file, String projectId, String subjectId, String topicId);
}
8 changes: 8 additions & 0 deletions src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,11 @@ security.github.client.maxContentLength=1000000
security.github.cache.size=10000
security.github.cache.duration=3600
security.github.cache.retryDuration=60

# DATA UPLOAD
file-upload.enabled=true
storage.type=s3 # can be 's3'
storage.s3.url=http://localhost:9000
storage.s3.access-key=access-key
storage.s3.secret-key=secret-key
storage.s3.bucket-name=radar
Loading

0 comments on commit 1e60613

Please sign in to comment.