Skip to content

Commit 37a929d

Browse files
authored
Merge pull request #1059 from amvanbaren/s3-storage
Add support for AWS S3 external file storage
2 parents 072bf3e + 53e29d4 commit 37a929d

File tree

14 files changed

+429
-31
lines changed

14 files changed

+429
-31
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,42 @@ If you also would like to test download count via Azure Blob, follow these steps
121121
* `AZURE_LOGS_SAS_TOKEN` with the shared access token for the `insights-logs-storageread` container.
122122
* If you change the variables in a running workspace, run `scripts/generate-properties.sh` in the `server` directory to update the application properties.
123123

124+
### Amazon S3 Setup
125+
126+
If you would like to test file storage via Amazon S3, follow these steps:
127+
128+
* Login to the AWS Console and create an [S3 storage bucket](https://s3.console.aws.amazon.com/s3/home?refid=ft_card)
129+
* Go to the bucket's `Permissions` tab.
130+
* Disable the `Block all public access` setting.
131+
* Add a `Cross-origin resource sharing (CORS)` configuration:
132+
```json
133+
[
134+
{
135+
"AllowedHeaders": [
136+
"*"
137+
],
138+
"AllowedMethods": [
139+
"GET",
140+
"HEAD"
141+
],
142+
"AllowedOrigins": [
143+
"*"
144+
],
145+
"ExposeHeaders": []
146+
}
147+
]
148+
```
149+
* Follow the steps for [programmatic access](https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#access-keys-and-secret-access-keys) to create your access key id and secret access key
150+
* Configure the following environment variables on your server environment
151+
* `AWS_ACCESS_KEY_ID` with your access key id
152+
* `AWS_SECRET_ACCESS_KEY` with your secret access key
153+
* `AWS_REGION` with your bucket region name
154+
* `AWS_SERVICE_ENDPOINT` with the url of your S3 provider if not using AWS (for AWS do not set)
155+
* `AWS_BUCKET` with your bucket name
156+
* `AWS_PATH_STYLE_ACCESS` whether or not to use path style access, (defaults to `false`)
157+
* Path-style access: `https://s3.<region>.amazonaws.com/<bucket-name>/<resource-key>`
158+
* Virtual-style access: `https://<bucket-name>.s3.<region>.amazonaws.com/<resource-key>`
159+
124160
## License
125161

126162
[Eclipse Public License 2.0](https://www.eclipse.org/legal/epl-2.0/)

server/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def versions = [
2424
springdoc: '2.1.0',
2525
gcloud: '2.36.1',
2626
azure: '12.23.0',
27+
aws: '2.29.29',
2728
junit: '5.9.2',
2829
testcontainers: '1.15.2',
2930
jackson: '2.15.2',
@@ -92,6 +93,7 @@ dependencies {
9293
implementation "org.flywaydb:flyway-core:${versions.flyway}"
9394
implementation "com.google.cloud:google-cloud-storage:${versions.gcloud}"
9495
implementation "com.azure:azure-storage-blob:${versions.azure}"
96+
implementation "software.amazon.awssdk:s3:${versions.aws}"
9597
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${versions.springdoc}"
9698
implementation "com.fasterxml.jackson.core:jackson-core:${versions.jackson}"
9799
implementation "com.fasterxml.jackson.core:jackson-annotations:${versions.jackson}"

server/scripts/generate-properties.sh

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,23 @@ then
7777
echo "ovsx.logs.azure.sas-token=$AZURE_LOGS_SAS_TOKEN" >> $OVSX_APP_PROFILE
7878
echo "Using Azure Logs Storage: $AZURE_LOGS_SERVICE_ENDPOINT"
7979
fi
80+
81+
# Set the AWS Storage service access key id, secret access key, region and endpoint
82+
if [ -n "$AWS_ACCESS_KEY_ID" ] && [ -n "$AWS_SECRET_ACCESS_KEY" ] && [ -n "$AWS_REGION" ] && [ -n "$AWS_BUCKET" ]
83+
then
84+
echo "ovsx.storage.aws.access-key-id=$AWS_ACCESS_KEY_ID" >> $OVSX_APP_PROFILE
85+
echo "ovsx.storage.aws.secret-access-key=$AWS_SECRET_ACCESS_KEY" >> $OVSX_APP_PROFILE
86+
echo "ovsx.storage.aws.region=$AWS_REGION" >> $OVSX_APP_PROFILE
87+
echo "ovsx.storage.aws.bucket=$AWS_BUCKET" >> $OVSX_APP_PROFILE
88+
if [ -n "$AWS_PATH_STYLE_ACCESS" ]
89+
then
90+
echo "ovsx.storage.aws.path-style-access=$AWS_PATH_STYLE_ACCESS" >> $OVSX_APP_PROFILE
91+
fi
92+
if [ -n "$AWS_SERVICE_ENDPOINT" ]
93+
then
94+
echo "ovsx.storage.aws.service-endpoint=$AWS_SERVICE_ENDPOINT" >> $OVSX_APP_PROFILE
95+
echo "Using AWS S3 Storage: $AWS_SERVICE_ENDPOINT"
96+
else
97+
echo "Using AWS S3 Storage."
98+
fi
99+
fi

server/src/main/java/org/eclipse/openvsx/entities/FileResource.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public class FileResource implements Serializable {
3737
public static final String STORAGE_LOCAL = "local";
3838
public static final String STORAGE_GOOGLE = "google-cloud";
3939
public static final String STORAGE_AZURE = "azure-blob";
40+
public static final String STORAGE_AWS = "aws";
4041

4142
@Id
4243
@GeneratedValue(generator = "fileResourceSeq")
@@ -105,4 +106,4 @@ public String getStorageType() {
105106
public void setStorageType(String storageType) {
106107
this.storageType = storageType;
107108
}
108-
}
109+
}
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
/********************************************************************************
2+
* Copyright (c) 2022 Marshall Walker and others
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v. 2.0 which is available at
6+
* http://www.eclipse.org/legal/epl-2.0.
7+
*
8+
* SPDX-License-Identifier: EPL-2.0
9+
********************************************************************************/
10+
11+
package org.eclipse.openvsx.storage;
12+
13+
import java.io.IOException;
14+
import java.net.URI;
15+
import java.net.URISyntaxException;
16+
import java.nio.file.Files;
17+
import java.nio.file.Path;
18+
import java.nio.file.StandardCopyOption;
19+
import java.util.HashMap;
20+
import java.util.List;
21+
22+
import org.apache.commons.lang3.ArrayUtils;
23+
import org.apache.commons.lang3.StringUtils;
24+
import org.eclipse.openvsx.entities.FileResource;
25+
import org.eclipse.openvsx.entities.Namespace;
26+
import org.eclipse.openvsx.util.TempFile;
27+
import org.eclipse.openvsx.util.UrlUtil;
28+
import org.springframework.beans.factory.annotation.Value;
29+
import org.springframework.cache.annotation.Cacheable;
30+
import org.springframework.data.util.Pair;
31+
import org.springframework.stereotype.Component;
32+
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
33+
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
34+
import software.amazon.awssdk.awscore.defaultsmode.DefaultsMode;
35+
import software.amazon.awssdk.regions.Region;
36+
import software.amazon.awssdk.services.s3.S3Client;
37+
import software.amazon.awssdk.services.s3.endpoints.S3EndpointParams;
38+
import software.amazon.awssdk.services.s3.endpoints.S3EndpointProvider;
39+
import software.amazon.awssdk.services.s3.model.*;
40+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
41+
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
42+
43+
import static org.eclipse.openvsx.cache.CacheService.CACHE_EXTENSION_FILES;
44+
import static org.eclipse.openvsx.cache.CacheService.GENERATOR_FILES;
45+
46+
@Component
47+
public class AwsStorageService implements IStorageService {
48+
49+
private final FileCacheDurationConfig fileCacheDurationConfig;
50+
51+
@Value("${ovsx.storage.aws.access-key-id:}")
52+
String accessKeyId;
53+
54+
@Value("${ovsx.storage.aws.secret-access-key:}")
55+
String secretAccessKey;
56+
57+
@Value("${ovsx.storage.aws.region:}")
58+
String region;
59+
60+
@Value("${ovsx.storage.aws.service-endpoint:}")
61+
String serviceEndpoint;
62+
63+
@Value("${ovsx.storage.aws.bucket:}")
64+
String bucket;
65+
66+
@Value("${ovsx.storage.aws.path-style-access:false}")
67+
boolean pathStyleAccess;
68+
69+
private S3Client s3Client;
70+
71+
public AwsStorageService(FileCacheDurationConfig fileCacheDurationConfig) {
72+
this.fileCacheDurationConfig = fileCacheDurationConfig;
73+
}
74+
75+
protected S3Client getS3Client() {
76+
if (s3Client == null) {
77+
var credentials = AwsBasicCredentials.create(accessKeyId, secretAccessKey);
78+
var s3ClientBuilder = S3Client.builder()
79+
.defaultsMode(DefaultsMode.STANDARD)
80+
.forcePathStyle(pathStyleAccess)
81+
.credentialsProvider(StaticCredentialsProvider.create(credentials))
82+
.region(Region.of(region));
83+
84+
if(StringUtils.isNotEmpty(serviceEndpoint)) {
85+
var endpointParams = S3EndpointParams.builder()
86+
.endpoint(serviceEndpoint)
87+
.region(Region.of(region))
88+
.build();
89+
90+
var endpoint = S3EndpointProvider
91+
.defaultProvider()
92+
.resolveEndpoint(endpointParams).join();
93+
94+
s3ClientBuilder = s3ClientBuilder.endpointOverride(endpoint.url());
95+
}
96+
97+
s3Client = s3ClientBuilder.build();
98+
}
99+
return s3Client;
100+
}
101+
102+
private S3Presigner getS3Presigner() {
103+
var credentials = AwsBasicCredentials.create(accessKeyId, secretAccessKey);
104+
var builder = S3Presigner.builder()
105+
.credentialsProvider(StaticCredentialsProvider.create(credentials))
106+
.region(Region.of(region));
107+
108+
if(StringUtils.isNotEmpty(serviceEndpoint)) {
109+
var endpointParams = S3EndpointParams.builder()
110+
.endpoint(serviceEndpoint)
111+
.region(Region.of(region))
112+
.build();
113+
114+
var endpoint = S3EndpointProvider
115+
.defaultProvider()
116+
.resolveEndpoint(endpointParams).join();
117+
118+
builder = builder.endpointOverride(endpoint.url());
119+
}
120+
121+
return builder.build();
122+
}
123+
124+
@Override
125+
public boolean isEnabled() {
126+
return !StringUtils.isEmpty(accessKeyId);
127+
}
128+
129+
@Override
130+
public void uploadFile(TempFile tempFile) {
131+
var resource = tempFile.getResource();
132+
uploadFile(tempFile, resource.getName(), getObjectKey(resource));
133+
}
134+
135+
@Override
136+
public void uploadNamespaceLogo(TempFile logoFile) {
137+
var namespace = logoFile.getNamespace();
138+
uploadFile(logoFile, namespace.getLogoName(), getObjectKey(namespace));
139+
}
140+
141+
protected void uploadFile(TempFile file, String fileName, String objectKey) {
142+
var metadata = new HashMap<String, String>();
143+
metadata.put("Content-Type", StorageUtil.getFileType(fileName).toString());
144+
if (fileName.endsWith(".vsix")) {
145+
metadata.put("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
146+
} else {
147+
metadata.put("Cache-Control", StorageUtil.getCacheControl(fileName).getHeaderValue());
148+
}
149+
150+
var request = PutObjectRequest.builder()
151+
.bucket(bucket)
152+
.key(objectKey)
153+
.metadata(metadata)
154+
.build();
155+
156+
getS3Client().putObject(request, file.getPath());
157+
}
158+
159+
@Override
160+
public void removeFile(FileResource resource) {
161+
removeFile(getObjectKey(resource));
162+
}
163+
164+
@Override
165+
public void removeNamespaceLogo(Namespace namespace) {
166+
removeFile(getObjectKey(namespace));
167+
}
168+
169+
private void removeFile(String objectKey) {
170+
var request = DeleteObjectRequest.builder()
171+
.bucket(bucket)
172+
.key(objectKey)
173+
.build();
174+
175+
getS3Client().deleteObject(request);
176+
}
177+
178+
@Override
179+
public URI getLocation(FileResource resource) {
180+
return getLocation(getObjectKey(resource));
181+
}
182+
183+
@Override
184+
public URI getNamespaceLogoLocation(Namespace namespace) {
185+
return getLocation(getObjectKey(namespace));
186+
}
187+
188+
private URI getLocation(String objectKey) {
189+
var objectRequest = GetObjectRequest.builder()
190+
.bucket(bucket)
191+
.key(objectKey)
192+
.build();
193+
194+
var presignRequest = GetObjectPresignRequest.builder()
195+
.signatureDuration(fileCacheDurationConfig.getCacheDuration())
196+
.getObjectRequest(objectRequest)
197+
.build();
198+
199+
try (var presigner = getS3Presigner()) {
200+
var presignedRequest = presigner.presignGetObject(presignRequest);
201+
return presignedRequest.url().toURI();
202+
} catch (URISyntaxException e) {
203+
throw new RuntimeException(e);
204+
}
205+
}
206+
207+
@Override
208+
public TempFile downloadFile(FileResource resource) throws IOException {
209+
var objectKey = getObjectKey(resource);
210+
var request = GetObjectRequest.builder()
211+
.bucket(bucket)
212+
.key(objectKey)
213+
.build();
214+
215+
var tempFile = new TempFile("temp_file_", "");
216+
try (var stream = getS3Client().getObject(request)) {
217+
Files.copy(stream, tempFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
218+
}
219+
tempFile.setResource(resource);
220+
return tempFile;
221+
}
222+
223+
@Override
224+
public void copyFiles(List<Pair<FileResource, FileResource>> pairs) {
225+
for(var pair : pairs) {
226+
var oldObjectKey = getObjectKey(pair.getFirst());
227+
var newObjectKey = getObjectKey(pair.getSecond());
228+
var request = CopyObjectRequest.builder()
229+
.sourceBucket(bucket)
230+
.sourceKey(oldObjectKey)
231+
.destinationBucket(bucket)
232+
.destinationKey(newObjectKey)
233+
.build();
234+
235+
getS3Client().copyObject(request);
236+
}
237+
}
238+
239+
@Override
240+
@Cacheable(value = CACHE_EXTENSION_FILES, keyGenerator = GENERATOR_FILES)
241+
public Path getCachedFile(FileResource resource) throws IOException {
242+
var objectKey = getObjectKey(resource);
243+
var request = GetObjectRequest.builder()
244+
.bucket(bucket)
245+
.key(objectKey)
246+
.build();
247+
248+
var path = Files.createTempFile("cached_file", null);
249+
try (var stream = getS3Client().getObject(request)) {
250+
Files.copy(stream, path, StandardCopyOption.REPLACE_EXISTING);
251+
}
252+
return path;
253+
}
254+
255+
protected String getObjectKey(FileResource resource) {
256+
var extVersion = resource.getExtension();
257+
var extension = extVersion.getExtension();
258+
var namespace = extension.getNamespace();
259+
var segments = new String[] {namespace.getName(), extension.getName()};
260+
if (!extVersion.isUniversalTargetPlatform()) {
261+
segments = ArrayUtils.add(segments, extVersion.getTargetPlatform());
262+
}
263+
264+
segments = ArrayUtils.add(segments, extVersion.getVersion());
265+
segments = ArrayUtils.addAll(segments, resource.getName().split("/"));
266+
return UrlUtil.createApiUrl("", segments).substring(1); // remove first '/'
267+
}
268+
269+
protected String getObjectKey(Namespace namespace) {
270+
return UrlUtil.createApiUrl("", namespace.getName(), "logo", namespace.getLogoName()).substring(1);
271+
}
272+
}

server/src/main/java/org/eclipse/openvsx/storage/AzureBlobStorageService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ private void removeFile(String blobName) {
141141
}
142142
}
143143

144-
@Override
144+
@Override
145145
public URI getLocation(FileResource resource) {
146146
var blobName = getBlobName(resource);
147147
if (StringUtils.isEmpty(serviceEndpoint)) {

0 commit comments

Comments
 (0)