diff --git a/avatar-realm-resource/src/main/java/org/gcube/keycloak/avatar/storage/s3/MinioAvatarStorageProvider.java b/avatar-realm-resource/src/main/java/org/gcube/keycloak/avatar/storage/s3/MinioAvatarStorageProvider.java new file mode 100644 index 0000000..e0965d5 --- /dev/null +++ b/avatar-realm-resource/src/main/java/org/gcube/keycloak/avatar/storage/s3/MinioAvatarStorageProvider.java @@ -0,0 +1,126 @@ +package org.gcube.keycloak.avatar.storage.s3; + +import java.io.InputStream; + +import org.gcube.keycloak.avatar.storage.AvatarStorageProvider; +import org.jboss.logging.Logger; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +import io.minio.GetObjectArgs; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import io.minio.RemoveObjectArgs; +import io.minio.errors.ErrorResponseException; + +public class MinioAvatarStorageProvider implements AvatarStorageProvider { + + private static final Logger logger = Logger.getLogger(MinioAvatarStorageProvider.class); + + private static final String AVATAR_FOLDER = "avatar"; + private static final int CHUNK_SIZE = 5242880; + + private final Configuration configuration; + + public MinioAvatarStorageProvider(Configuration configuration) { + this.configuration = configuration; + } + + @Override + public void saveAvatarImage(RealmModel realmModel, UserModel userModel, InputStream input) { + execute(minioClient -> minioClient + .putObject(PutObjectArgs.builder().bucket(configuration.rootBucket).object(getAvatarFilePath( + realmModel, userModel)).stream(input, -1, CHUNK_SIZE).build())); + } + + @Override + public InputStream loadAvatarImage(RealmModel realmModel, UserModel userModel) { + logger.debug("Loading avatar from S3"); + return execute(new Executor() { + public InputStream execute(MinioClient minioClient) throws Exception { + try { + return minioClient + .getObject(GetObjectArgs.builder().bucket(configuration.rootBucket).object(getAvatarFilePath( + realmModel, userModel)).build()); + } catch (ErrorResponseException e) { + if (e.response().code() == 404) { + logger.debugf("Avatar file not found for user '%s' in realm '%s'", userModel.getUsername(), realmModel.getName()); + return null; + } else { + throw e; + } + } + }; + }); + } + + @Override + public void deleteAvatarImage(RealmModel realmModel, UserModel userModel) { + logger.debug("Deeleting avatar from S3"); + execute(minioClient -> { + minioClient.removeObject(RemoveObjectArgs.builder().bucket(configuration.rootBucket) + .object(getAvatarFilePath(realmModel, userModel)).build()); + return true; + }); + + } + + @Override + public void close() { + // NOOP + } + + public String getAvatarFilePath(RealmModel realmModel, UserModel userModel) { + return AVATAR_FOLDER + "/" + realmModel.getName() + "/" + userModel.getUsername(); + } + + public T execute(Executor executor) { + try { + MinioClient minioClient = MinioClient.builder() + .endpoint(configuration.getServerUrl()) + .credentials(configuration.getAccessKey(), configuration.getSecretKey()) + .build(); + + return executor.execute(minioClient); + } catch (Exception e) { + throw new RuntimeException("Executing operation on S3 persistnce", e); + } + } + + public static class Configuration { + + private final String serverUrl; + private final String accessKey; + private final String secretKey; + private final String rootBucket; + + public Configuration(String serverUrl, String accessKey, String secretKey, String rootBucket) { + this.serverUrl = serverUrl; + this.accessKey = accessKey; + this.secretKey = secretKey; + this.rootBucket = rootBucket; + } + + public String getServerUrl() { + return serverUrl; + } + + public String getAccessKey() { + return accessKey; + } + + public String getSecretKey() { + return secretKey; + } + + public String getRootBucket() { + return rootBucket; + } + } + + public interface Executor { + + T execute(MinioClient minioClient) throws Exception; + + } +} \ No newline at end of file diff --git a/avatar-realm-resource/src/main/java/org/gcube/keycloak/avatar/storage/s3/MinioAvatarStorageProviderFactory.java b/avatar-realm-resource/src/main/java/org/gcube/keycloak/avatar/storage/s3/MinioAvatarStorageProviderFactory.java new file mode 100644 index 0000000..e14bb27 --- /dev/null +++ b/avatar-realm-resource/src/main/java/org/gcube/keycloak/avatar/storage/s3/MinioAvatarStorageProviderFactory.java @@ -0,0 +1,45 @@ +package org.gcube.keycloak.avatar.storage.s3; + +import org.gcube.keycloak.avatar.storage.AvatarStorageProvider; +import org.gcube.keycloak.avatar.storage.AvatarStorageProviderFactory; +import org.gcube.keycloak.avatar.storage.s3.MinioAvatarStorageProvider.Configuration; +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class MinioAvatarStorageProviderFactory implements AvatarStorageProviderFactory { + + private Configuration minioConfig; + + @Override + public AvatarStorageProvider create(KeycloakSession session) { + return new MinioAvatarStorageProvider(minioConfig); + } + + @Override + public void init(Config.Scope config) { + + String serverUrl = config.get("server-url"); + String accessKey = config.get("access-key"); + String secretKey = config.get("secret-key"); + String rootBucket = config.get("root-bucket"); + + this.minioConfig = new Configuration(serverUrl, accessKey, secretKey, rootBucket); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + // NOOP + } + + @Override + public void close() { + // NOOP + } + + @Override + public String getId() { + return "avatar-storage-s3"; + } + +} \ No newline at end of file diff --git a/avatar-realm-resource/src/test/java/org/gcube/keycloak/avatar/storage/s3/MinioAvatarStorageTest.java b/avatar-realm-resource/src/test/java/org/gcube/keycloak/avatar/storage/s3/MinioAvatarStorageTest.java new file mode 100644 index 0000000..2d5ce99 --- /dev/null +++ b/avatar-realm-resource/src/test/java/org/gcube/keycloak/avatar/storage/s3/MinioAvatarStorageTest.java @@ -0,0 +1,49 @@ +package org.gcube.keycloak.avatar.storage.s3; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.when; + +import java.io.FileNotFoundException; + +import org.gcube.keycloak.avatar.storage.s3.MinioAvatarStorageProvider.Configuration; +import org.jboss.logging.Logger; +import org.junit.Test; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.mockito.Mockito; + +public class MinioAvatarStorageTest { + + private static final Logger logger = Logger.getLogger(MinioAvatarStorageTest.class); + + private static final String ENDPOINT_URL = "https://isti-cloud.isti.cnr.it:13808"; + private static final String ACCESS_KEY_ENV = "ACCESS_KEY"; + private static final String SECRET_KEY_ENV = "SECRET_KEY"; + private static final String BUCKET = "d4s-container-registry-dev"; + + @Test + public void test() throws FileNotFoundException { + String accessKey = System.getenv(ACCESS_KEY_ENV); + String secretKey = System.getenv(SECRET_KEY_ENV); + if (accessKey == null || secretKey == null) { + logger.error("Cannot proceed with tests without access and secret keys"); + } + Configuration minioConfig = new Configuration(ENDPOINT_URL, accessKey, secretKey, BUCKET); + + MinioAvatarStorageProvider minioAvatarStorageProvider = new MinioAvatarStorageProvider(minioConfig); + RealmModel realmModel = Mockito.mock(RealmModel.class); + when(realmModel.getName()).thenReturn("testRealm"); + UserModel userModel = Mockito.mock(UserModel.class); + when(userModel.getUsername()).thenReturn("test.user"); + minioAvatarStorageProvider.saveAvatarImage(realmModel, userModel, + this.getClass().getClassLoader().getResourceAsStream("TM_Emoji.jpg")); + + assertNotNull(minioAvatarStorageProvider.loadAvatarImage(realmModel, userModel)); + minioAvatarStorageProvider.deleteAvatarImage(realmModel, userModel); + assertNull(minioAvatarStorageProvider.loadAvatarImage(realmModel, userModel)); + // Delete of a non existing resource must not raise an exception, assuring it with a proper test + minioAvatarStorageProvider.deleteAvatarImage(realmModel, userModel); + } + +} diff --git a/avatar-realm-resource/src/test/resources/log4j.xml b/avatar-realm-resource/src/test/resources/log4j.xml new file mode 100644 index 0000000..9ff69e5 --- /dev/null +++ b/avatar-realm-resource/src/test/resources/log4j.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/avatar-realm-resource/src/test/resources/tm-avatar.png b/avatar-realm-resource/src/test/resources/tm-avatar.png new file mode 100644 index 0000000..7f59788 Binary files /dev/null and b/avatar-realm-resource/src/test/resources/tm-avatar.png differ