-
Notifications
You must be signed in to change notification settings - Fork 0
S3AttachmentContentStore #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
msladek
wants to merge
8
commits into
major-7
Choose a base branch
from
s3
base: major-7
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
130d854
S3AttachmentContentStore
msladek 9a69362
address PR comments
msladek da81d07
move s3 store package
msladek 4f76949
add logging
msladek eb95426
deleteContent for all attachment contents
msladek a11b50d
improve s3 name
msladek 4414d3f
S3AttachmentContentMigrationService
msladek 293557b
S3AttachmentContentMigrationService improve logging
msladek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| <project xmlns="http://maven.apache.org/POM/4.0.0" | ||
| xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
| xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd" | ||
| > | ||
| <parent> | ||
| <groupId>com.celements</groupId> | ||
| <artifactId>celements</artifactId> | ||
| <version>7.0-SNAPSHOT</version> | ||
| </parent> | ||
| <modelVersion>4.0.0</modelVersion> | ||
| <artifactId>celements-s3</artifactId> | ||
| <version>7.0-SNAPSHOT</version> | ||
| <description>Celements S3 Integration</description> | ||
| <dependencies> | ||
| <dependency> | ||
| <groupId>com.celements</groupId> | ||
| <artifactId>celements-config-source</artifactId> | ||
| <version>7.0-SNAPSHOT</version> | ||
| <scope>provided</scope> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>com.celements</groupId> | ||
| <artifactId>celements-servlet</artifactId> | ||
| <version>7.0-SNAPSHOT</version> | ||
| <scope>provided</scope> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>com.celements</groupId> | ||
| <artifactId>celements-model</artifactId> | ||
| <version>7.0-SNAPSHOT</version> | ||
| <scope>provided</scope> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>software.amazon.awssdk</groupId> | ||
| <artifactId>s3</artifactId> | ||
| <version>2.41.5</version> | ||
| </dependency> | ||
| </dependencies> | ||
| <scm> | ||
| <connection>scm:git:git@github.com:celements/celements-base.git</connection> | ||
| <developerConnection>scm:git:git@github.com:celements/celements-base.git</developerConnection> | ||
| <url>https://github.com/celements/celements-base/tree/dev/celements-s3</url> | ||
| <tag>HEAD</tag> | ||
| </scm> | ||
| </project> |
98 changes: 98 additions & 0 deletions
98
celements-s3/src/main/java/com/celements/store/s3/S3Config.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| package com.celements.store.s3; | ||
|
|
||
| import java.net.URI; | ||
| import java.util.Optional; | ||
|
|
||
| import javax.annotation.Nullable; | ||
| import javax.inject.Inject; | ||
|
|
||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.xwiki.configuration.ConfigurationSource; | ||
|
|
||
| import com.celements.configuration.CelementsAllPropertiesConfigurationSource; | ||
|
|
||
| import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; | ||
| import software.amazon.awssdk.auth.credentials.AwsCredentials; | ||
| import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; | ||
| import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; | ||
| import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; | ||
| import software.amazon.awssdk.regions.Region; | ||
| import software.amazon.awssdk.services.s3.S3Client; | ||
|
|
||
| @Configuration | ||
| public class S3Config { | ||
|
|
||
| private static final Logger LOGGER = LoggerFactory.getLogger(S3Config.class); | ||
|
|
||
| private final ConfigurationSource cfgSrc; | ||
|
|
||
| @Inject | ||
| public S3Config(CelementsAllPropertiesConfigurationSource cfgSrc) { | ||
| this.cfgSrc = cfgSrc; | ||
| } | ||
|
|
||
| @Bean(destroyMethod = "close") | ||
| @Nullable | ||
| public S3Client s3Client() { | ||
| var endpoint = cfgSrc.getProperty("celements.s3.endpoint", "").trim(); | ||
| var region = cfgSrc.getProperty("celements.s3.region", "eu-central").trim(); | ||
| if (endpoint.isEmpty()) { | ||
| LOGGER.info("S3 endpoint not configured"); | ||
| return null; | ||
| } | ||
| var client = S3Client.builder() | ||
| .endpointOverride(URI.create(endpoint)) | ||
| .region(Region.of(region)) | ||
| .credentialsProvider(StaticCredentialsProvider.create(buildCredentials())) | ||
| .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED) | ||
| .responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED) | ||
| .build(); | ||
| testClient(client); | ||
| LOGGER.info("S3 configured: {}", endpoint); | ||
msladek marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return client; | ||
| } | ||
|
|
||
| private void testClient(S3Client client) { | ||
| try { | ||
| client.listBuckets(); | ||
| } catch (Exception exc) { | ||
| client.close(); | ||
| throw exc; | ||
| } | ||
| } | ||
msladek marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| private AwsCredentials buildCredentials() { | ||
| var accessKey = cfgSrc.getProperty("celements.s3.accessKey", "").trim(); | ||
| var secretKey = cfgSrc.getProperty("celements.s3.secretKey", "").trim(); | ||
| if (accessKey.isEmpty() || secretKey.isEmpty()) { | ||
| throw new IllegalArgumentException("celements.s3.accessKey/secretKey missing"); | ||
| } | ||
| return AwsBasicCredentials.builder() | ||
| .accessKeyId(accessKey) | ||
| .secretAccessKey(secretKey) | ||
| .build(); | ||
| } | ||
|
|
||
| @Bean(name = "s3BucketFilebase") | ||
| @Nullable | ||
| public String s3BucketFilebase(Optional<S3Client> s3Client) { | ||
| var bucket = cfgSrc.getProperty("celements.s3.bucket.filebase", "").trim(); | ||
| if (bucket.isEmpty()) { | ||
| LOGGER.info("S3 filebase bucket not configured"); | ||
| return null; | ||
| } | ||
| testBucket(s3Client, bucket); | ||
| LOGGER.info("S3 filebase bucket configured: {}", bucket); | ||
| return bucket; | ||
| } | ||
|
|
||
| private void testBucket(Optional<S3Client> s3Client, String bucket) { | ||
| s3Client | ||
| .orElseThrow(() -> new IllegalStateException("S3 client not configured")) | ||
| .headBucket(builder -> builder.bucket(bucket)); | ||
| } | ||
|
|
||
| } | ||
181 changes: 181 additions & 0 deletions
181
celements-s3/src/main/java/com/celements/store/s3/att/S3AttachmentContentStore.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| package com.celements.store.s3.att; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| import javax.inject.Inject; | ||
| import javax.inject.Named; | ||
|
|
||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
| import org.springframework.context.annotation.Lazy; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import com.celements.servlet.NodeConfig.NodeIdentity; | ||
| import com.xpn.xwiki.doc.XWikiAttachment; | ||
| import com.xpn.xwiki.doc.XWikiAttachmentContent; | ||
| import com.xpn.xwiki.store.AttachmentContentStore; | ||
|
|
||
| import software.amazon.awssdk.core.sync.RequestBody; | ||
| import software.amazon.awssdk.services.s3.S3Client; | ||
| import software.amazon.awssdk.services.s3.model.NoSuchKeyException; | ||
| import software.amazon.awssdk.services.s3.model.ObjectIdentifier; | ||
| import software.amazon.awssdk.services.s3.model.S3Exception; | ||
|
|
||
| @Component | ||
| @Named(S3AttachmentContentStore.STORE_NAME) | ||
| @Lazy | ||
| public class S3AttachmentContentStore implements AttachmentContentStore { | ||
|
|
||
| private static final Logger LOGGER = LoggerFactory | ||
| .getLogger(S3AttachmentContentStore.class); | ||
|
|
||
| public static final String STORE_NAME = "store.attachment.content.s3"; | ||
|
|
||
| private final NodeIdentity nodeIdentity; | ||
| private final S3Client s3Client; | ||
| private final String s3BucketFilebase; | ||
|
|
||
| @Inject | ||
| public S3AttachmentContentStore( | ||
| NodeIdentity nodeIdentity, | ||
| Optional<S3Client> s3Client, | ||
| @Named("s3BucketFilebase") Optional<String> s3BucketFilebase) { | ||
| this.nodeIdentity = nodeIdentity; | ||
| this.s3Client = s3Client | ||
| .orElseThrow(() -> new IllegalStateException("S3Client missing")); | ||
| this.s3BucketFilebase = s3BucketFilebase | ||
| .orElseThrow(() -> new IllegalStateException("s3BucketFilebase missing")); | ||
| } | ||
|
|
||
| @Override | ||
| public String getStoreName() { | ||
| return STORE_NAME; | ||
| } | ||
|
|
||
| /** | ||
| * Builds the S3 key for the given attachment. The key structure is as follows: | ||
| * attachment/{appName}/{wikiName}/{docId}/{attachmentId} | ||
| */ | ||
| public String buildS3AttachmentKey(XWikiAttachment attachment) { | ||
| var doc = attachment.getDoc(); | ||
| var wiki = doc.getDocumentReference().getWikiReference(); | ||
| return String.join("/", | ||
| nodeIdentity.clusterName(), // allow bucket multi-tenancy by cluster name | ||
| "attachments", // subbucket for attachments | ||
| wiki.getName(), // identify wiki | ||
| Long.toString(doc.getId()), // identify document | ||
| Long.toString(attachment.getId())); // identify attachment | ||
| } | ||
|
|
||
| /** | ||
| * Builds the S3 key for the given attachment. The key structure is as follows: | ||
| * attachment/{appName}/{wikiName}/{docId}/{attachmentId}/{version} | ||
| */ | ||
| public String buildS3AttachmentVersionKey(XWikiAttachment attachment) { | ||
| return String.join("/", | ||
| buildS3AttachmentKey(attachment), | ||
| attachment.getVersion()); // identify attachment version | ||
| } | ||
|
|
||
| public boolean hasContent(XWikiAttachment attachment) throws AttachmentContentStoreException { | ||
msladek marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| var s3Key = buildS3AttachmentVersionKey(attachment); | ||
| LOGGER.info("hasContent - {} in {}", attachment, s3Key); | ||
| try { | ||
| s3Client.headObject(builder -> builder | ||
| .bucket(s3BucketFilebase) | ||
| .key(s3Key)); | ||
| return true; | ||
| } catch (NoSuchKeyException e) { | ||
| return false; | ||
| } catch (S3Exception e) { | ||
| throw new AttachmentContentStoreException(buildS3ErrorMessage(s3Key, e), e); | ||
| } catch (Exception e) { | ||
| throw new AttachmentContentStoreException("Failed checking attachment", e); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public void saveContent(XWikiAttachmentContent content) throws AttachmentContentStoreException { | ||
| var s3Key = buildS3AttachmentVersionKey(content.getAttachment()); | ||
| LOGGER.info("saveContent - {} to {}", content.getAttachment(), s3Key); | ||
| try { | ||
| try (var data = content.getContentInputStream()) { | ||
| s3Client.putObject(builder -> builder | ||
| .bucket(s3BucketFilebase) | ||
| .key(s3Key) | ||
| .contentLength((long) content.getSize()) | ||
| .contentType(content.getAttachment().getMimeType()), | ||
| RequestBody.fromInputStream(data, content.getSize())); | ||
| } | ||
| } catch (S3Exception e) { | ||
| throw new AttachmentContentStoreException(buildS3ErrorMessage(s3Key, e), e); | ||
| } catch (Exception e) { | ||
| throw new AttachmentContentStoreException("Failed saving attachment", e); | ||
| } | ||
msladek marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| @Override | ||
| public void loadContent(XWikiAttachmentContent content) throws AttachmentContentStoreException { | ||
| var s3Key = buildS3AttachmentVersionKey(content.getAttachment()); | ||
| LOGGER.info("loadContent - {} from {}", content.getAttachment(), s3Key); | ||
| try { | ||
| try (var data = s3Client.getObject(builder -> builder | ||
| .bucket(s3BucketFilebase) | ||
| .key(s3Key))) { | ||
| content.setContent(data); | ||
| } | ||
| } catch (NoSuchKeyException e) { | ||
| throw new AttachmentContentStoreException("Attachment content not found in S3: " + s3Key, e); | ||
| } catch (S3Exception e) { | ||
| throw new AttachmentContentStoreException(buildS3ErrorMessage(s3Key, e), e); | ||
| } catch (Exception e) { | ||
| throw new AttachmentContentStoreException("Failed loading attachment", e); | ||
| } | ||
msladek marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| @Override | ||
| public void deleteContent(XWikiAttachment attachment) throws AttachmentContentStoreException { | ||
| var s3Prefix = buildS3AttachmentKey(attachment) + "/"; | ||
| LOGGER.info("deleteContent - {} from {}", attachment, s3Prefix); | ||
| List<ObjectIdentifier> batch = s3Client.listObjectsV2(builder -> builder | ||
| .bucket(s3BucketFilebase) | ||
| .prefix(s3Prefix)) | ||
| .contents() | ||
| .stream() | ||
| .map(s3Object -> ObjectIdentifier.builder().key(s3Object.key()).build()) | ||
| .toList(); | ||
| if (batch.isEmpty()) { | ||
| return; | ||
| } else if (batch.size() >= 1000) { | ||
| throw new AttachmentContentStoreException( | ||
| "Too many objects to delete in S3 for attachment: " + attachment, null); | ||
| } | ||
| s3Client.deleteObjects(builder -> builder | ||
| .bucket(s3BucketFilebase) | ||
| .delete(deleteBuilder -> deleteBuilder.objects(batch))); | ||
| } | ||
|
|
||
| @Override | ||
| public void deleteContent(XWikiAttachmentContent content) throws AttachmentContentStoreException { | ||
| var s3Key = buildS3AttachmentVersionKey(content.getAttachment()); | ||
| LOGGER.info("deleteContent - {} from {}", content.getAttachment(), s3Key); | ||
| try { | ||
| s3Client.deleteObject(builder -> builder | ||
| .bucket(s3BucketFilebase) | ||
| .key(s3Key)); | ||
| } catch (S3Exception e) { | ||
| throw new AttachmentContentStoreException(buildS3ErrorMessage(s3Key, e), e); | ||
| } catch (Exception e) { | ||
| throw new AttachmentContentStoreException("Failed deleting attachment", e); | ||
| } | ||
msladek marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| private static String buildS3ErrorMessage(String s3Key, S3Exception e) { | ||
| return String.format("S3 error for attachment (key=%s, status=%d, code=%s)", | ||
| s3Key, | ||
| e.statusCode(), | ||
| e.awsErrorDetails() != null ? e.awsErrorDetails().errorCode() : "n/a"); | ||
| } | ||
|
|
||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.