Skip to content

Commit e07b2ea

Browse files
committed
fix app and add a lambda to list uploaded images
1 parent f6598ca commit e07b2ea

File tree

10 files changed

+363
-66
lines changed

10 files changed

+363
-66
lines changed

README.md

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ pip install -r requirements-dev.txt
5656
Start LocalStack Pro with the appropriate CORS configuration for the S3 Website:
5757

5858
```bash
59-
export EXTRA_CORS_ALLOWED_ORIGINS=webapp.s3-website.localhost.localstack.cloud:4566
6059
LOCALSTACK_API_KEY=... localstack start
6160
```
6261

@@ -96,13 +95,12 @@ awslocal sns subscribe \
9695
--notification-endpoint my-email@example.com
9796
```
9897

99-
`awslocal sns list-topics | jq -r '.Topics[] | select(.TopicArn|test("failed-resize-topic")).TopicArn'`
100-
101-
10298
### Create the lambdas
10399

104100
#### S3 pre-signed POST URL generator
105101

102+
This Lambda is responsible for generating pre-signed POST URLs to upload files to an S3 bucket.
103+
106104
```bash
107105
(cd lambdas/presign; rm -f lambda.zip; zip lambda.zip handler.py)
108106
awslocal lambda create-function \
@@ -111,7 +109,8 @@ awslocal lambda create-function \
111109
--timeout 10 \
112110
--zip-file fileb://lambdas/presign/lambda.zip \
113111
--handler handler.handler \
114-
--role arn:aws:iam::000000000000:role/lambda-role
112+
--role arn:aws:iam::000000000000:role/lambda-role \
113+
--environment Variables="{STAGE=local}"
115114
```
116115

117116
Create the function URL:
@@ -124,6 +123,29 @@ awslocal lambda create-function-url-config \
124123

125124
Copy the `FunctionUrl` from the response, you will need it later to make the app work.
126125

126+
### Image lister lambda
127+
128+
The `list` Lambda is very similar:
129+
130+
```bash
131+
(cd lambdas/list; rm -f lambda.zip; zip lambda.zip handler.py)
132+
awslocal lambda create-function \
133+
--function-name list \
134+
--handler handler.handler \
135+
--zip-file fileb://lambdas/list/lambda.zip \
136+
--runtime python3.9 \
137+
--role arn:aws:iam::000000000000:role/lambda-role \
138+
--environment Variables="{STAGE=local}"
139+
```
140+
141+
Create the function URL:
142+
143+
```bash
144+
awslocal lambda create-function-url-config \
145+
--function-name list \
146+
--auth-type NONE
147+
```
148+
127149
### Resizer Lambda
128150

129151
```bash
@@ -143,7 +165,8 @@ awslocal lambda create-function \
143165
--zip-file fileb://lambdas/resize/lambda.zip \
144166
--handler handler.handler \
145167
--dead-letter-config TargetArn=arn:aws:sns:us-east-1:000000000000:failed-resize-topic \
146-
--role arn:aws:iam::000000000000:role/lambda-role
168+
--role arn:aws:iam::000000000000:role/lambda-role \
169+
--environment Variables="{STAGE=local}"
147170
```
148171

149172
### Connect the S3 bucket to the resizer lambda
@@ -167,13 +190,24 @@ awslocal s3 website s3://webapp --index-document index.html
167190
Once deployed, visit http://webapp.s3-website.localhost.localstack.cloud:4566
168191

169192
Paste the Function URL of the presign Lambda you created earlier into the form field.
170-
If you use LocalStack's v2 Lambda provider then you can also get the URL by running:
171193
```bash
172194
awslocal lambda list-function-url-configs --function-name presign
195+
awslocal lambda list-function-url-configs --function-name list
173196
```
174197

175198
After uploading a file, you can download the resized file from the `localstack-thumbnails-app-resized` bucket.
176199

200+
### Testing failures
201+
202+
If the `resize` Lambda fails, an SNS message is sent to a topic that an SES subscription listens to.
203+
An email is then sent with the raw failure message.
204+
In a real scenario you'd probably have another lambda sitting here, but it's just for demo purposes.
205+
Since there's no real email server involved, you can use the [SES developer endpoint](https://docs.localstack.cloud/user-guide/aws/ses/) to list messages that were sent via SES:
206+
207+
curl -s http://localhost.localstack.cloud:4566/_aws/ses
208+
209+
An alternative is to use a service like MailHog or smtp4dev, and start LocalStack using `SMTP_HOST=host.docker.internal:1025` pointing to the mock SMTP server.
210+
177211
## Run integration tests
178212

179213
Once all resource are created on LocalStack, you can run the automated integration tests.

bin/deploy.sh

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,27 @@ awslocal lambda create-function \
1919
--timeout 10 \
2020
--zip-file fileb://lambdas/presign/lambda.zip \
2121
--handler handler.handler \
22-
--role arn:aws:iam::000000000000:role/lambda-role
22+
--role arn:aws:iam::000000000000:role/lambda-role \
23+
--environment Variables="{STAGE=local}"
2324

2425
awslocal lambda create-function-url-config \
2526
--function-name presign \
2627
--auth-type NONE
2728

29+
(cd lambdas/list; rm -f lambda.zip; zip lambda.zip handler.py)
30+
awslocal lambda create-function \
31+
--function-name list \
32+
--runtime python3.9 \
33+
--timeout 10 \
34+
--zip-file fileb://lambdas/list/lambda.zip \
35+
--handler handler.handler \
36+
--role arn:aws:iam::000000000000:role/lambda-role \
37+
--environment Variables="{STAGE=local}"
38+
39+
awslocal lambda create-function-url-config \
40+
--function-name list \
41+
--auth-type NONE
42+
2843
(
2944
cd lambdas/resize
3045
rm -rf package lambda.zip
@@ -41,7 +56,8 @@ awslocal lambda create-function \
4156
--zip-file fileb://lambdas/resize/lambda.zip \
4257
--handler handler.handler \
4358
--dead-letter-config TargetArn=arn:aws:sns:us-east-1:000000000000:failed-resize-topic \
44-
--role arn:aws:iam::000000000000:role/lambda-role
59+
--role arn:aws:iam::000000000000:role/lambda-role \
60+
--environment Variables="{STAGE=local}"
4561

4662
awslocal s3api put-bucket-notification-configuration \
4763
--bucket localstack-thumbnails-app-images \

lambdas/list/handler.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import os
2+
import typing
3+
4+
import boto3
5+
6+
if typing.TYPE_CHECKING:
7+
from mypy_boto3_s3 import S3Client
8+
from mypy_boto3_ssm import SSMClient
9+
10+
# used to make sure that S3 generates pre-signed URLs that have the localstack URL in them
11+
endpoint_url = None
12+
if os.getenv("STAGE") == "local":
13+
endpoint_url = "https://localhost.localstack.cloud:4566"
14+
15+
s3: "S3Client" = boto3.client("s3", endpoint_url=endpoint_url)
16+
ssm: "SSMClient" = boto3.client("ssm", endpoint_url=endpoint_url)
17+
18+
19+
def get_bucket_name_images() -> str:
20+
parameter = ssm.get_parameter(Name="/localstack-thumbnail-app/buckets/images")
21+
return parameter["Parameter"]["Value"]
22+
23+
24+
def get_bucket_name_resized() -> str:
25+
parameter = ssm.get_parameter(Name="/localstack-thumbnail-app/buckets/resized")
26+
return parameter["Parameter"]["Value"]
27+
28+
29+
def handler(event, context):
30+
images_bucket = get_bucket_name_images()
31+
images = s3.list_objects(Bucket=images_bucket)
32+
33+
result = {}
34+
# collect the original images
35+
for obj in images["Contents"]:
36+
result[obj["Key"]] = {
37+
"Name": obj["Key"],
38+
"Timestamp": obj["LastModified"].isoformat(),
39+
"Original": {
40+
"Size": obj["Size"],
41+
"URL": s3.generate_presigned_url(
42+
ClientMethod="get_object",
43+
Params={"Bucket": images_bucket, "Key": obj["Key"]},
44+
ExpiresIn=3600,
45+
),
46+
},
47+
}
48+
49+
# collect the associated resized images
50+
resized_bucket = get_bucket_name_resized()
51+
images = s3.list_objects(Bucket=resized_bucket)
52+
for obj in images["Contents"]:
53+
if obj["Key"] not in result:
54+
continue
55+
result[obj["Key"]]["Resized"] = {
56+
"Size": obj["Size"],
57+
"URL": s3.generate_presigned_url(
58+
ClientMethod="get_object",
59+
Params={"Bucket": resized_bucket, "Key": obj["Key"]},
60+
ExpiresIn=3600,
61+
),
62+
}
63+
64+
return list(sorted(result.values(), key=lambda k: k["Timestamp"], reverse=True))
65+
66+
67+
if __name__ == "__main__":
68+
print(handler(None, None))

lambdas/presign/handler.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import os
23
import typing
34

45
import boto3
@@ -8,8 +9,13 @@
89
from mypy_boto3_s3 import S3Client
910
from mypy_boto3_ssm import SSMClient
1011

11-
s3: "S3Client" = boto3.client("s3")
12-
ssm: "SSMClient" = boto3.client("ssm")
12+
# used to make sure that S3 generates pre-signed URLs that have the localstack URL in them
13+
endpoint_url = None
14+
if os.getenv("STAGE") == "local":
15+
endpoint_url = "https://localhost.localstack.cloud:4566"
16+
17+
s3: "S3Client" = boto3.client("s3", endpoint_url=endpoint_url)
18+
ssm: "SSMClient" = boto3.client("ssm", endpoint_url=endpoint_url)
1319

1420

1521
def get_bucket_name() -> str:

lambdas/resize/handler.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# adapted from https://docs.aws.amazon.com/lambda/latest/dg/with-s3-tutorial.html
2+
import os
23
import typing
34
import uuid
45
from urllib.parse import unquote_plus
@@ -10,8 +11,15 @@
1011
from mypy_boto3_s3 import S3Client
1112
from mypy_boto3_ssm import SSMClient
1213

13-
s3: "S3Client" = boto3.client("s3")
14-
ssm: "SSMClient" = boto3.client("ssm")
14+
MAX_DIMENSIONS = 400, 400
15+
"""The max width and height to scale the image to."""
16+
17+
endpoint_url = None
18+
if os.getenv("STAGE") == "local":
19+
endpoint_url = "https://localhost.localstack.cloud:4566"
20+
21+
s3: "S3Client" = boto3.client("s3", endpoint_url=endpoint_url)
22+
ssm: "SSMClient" = boto3.client("ssm", endpoint_url=endpoint_url)
1523

1624

1725
def get_bucket_name() -> str:
@@ -21,7 +29,16 @@ def get_bucket_name() -> str:
2129

2230
def resize_image(image_path, resized_path):
2331
with Image.open(image_path) as image:
24-
image.thumbnail(tuple(int(x / 2) for x in image.size))
32+
# Calculate the thumbnail size
33+
width, height = image.size
34+
max_width, max_height = MAX_DIMENSIONS
35+
if width > max_width or height > max_height:
36+
ratio = max(width / max_width, height / max_height)
37+
width = int(width / ratio)
38+
height = int(height / ratio)
39+
size = width, height
40+
# Generate the resized image
41+
image.thumbnail(size)
2542
image.save(resized_path)
2643

2744

tests/nyan-cat.png

2.71 KB
Loading

tests/test_integration.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
from mypy_boto3_s3 import S3Client
1111
from mypy_boto3_ssm import SSMClient
1212

13+
os.environ["AWS_DEFAULT_REGION"] = "us-east-1"
14+
os.environ["AWS_ACCESS_KEY_ID"] = "test"
15+
os.environ["AWS_SECRET_ACCESS_KEY"] = "test"
16+
1317
s3: "S3Client" = boto3.client(
1418
"s3", endpoint_url="http://localhost.localstack.cloud:4566"
1519
)

website/app.js

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,43 @@
11
(function ($) {
2+
let functionUrlPresign = localStorage.getItem("functionUrlPresign");
3+
if (functionUrlPresign) {
4+
$("#functionUrlPresign").val(functionUrlPresign);
5+
}
6+
7+
let functionUrlList = localStorage.getItem("functionUrlList");
8+
if (functionUrlList) {
9+
console.log("function url list is", functionUrlList);
10+
$("#functionUrlList").val(functionUrlList);
11+
}
12+
13+
let imageItemTemplate = Handlebars.compile($("#image-item-template").html());
14+
15+
$("#configForm").submit(function (event) {
16+
if (event.preventDefault)
17+
event.preventDefault();
18+
else
19+
event.returnValue = false;
20+
21+
event.preventDefault();
22+
23+
let action = $(this).find("button[type=submit]:focus").attr('name');
24+
25+
if (action == "save") {
26+
localStorage.setItem("functionUrlPresign", $("#functionUrlPresign").val());
27+
localStorage.setItem("functionUrlList", $("#functionUrlList").val());
28+
alert("Configuration saved");
29+
} else if (action == "clear") {
30+
localStorage.removeItem("functionUrlPresign");
31+
localStorage.removeItem("functionUrlList");
32+
$("#functionUrlPresign").val("")
33+
$("#functionUrlList").val("")
34+
alert("Configuration cleared");
35+
} else {
36+
alert("Unknown action");
37+
}
38+
39+
});
40+
241
$("#uploadForm").submit(function (event) {
342
$("#uploadForm button").addClass('disabled');
443

@@ -10,12 +49,12 @@
1049
event.preventDefault();
1150

1251
let fileName = $("#customFile").val().replace(/C:\\fakepath\\/i, '');
13-
let presignerUrl = $("#presignerUrl").val();
52+
let functionUrlPresign = $("#functionUrlPresign").val();
1453

1554
// modify the original form
16-
console.log(fileName, presignerUrl);
55+
console.log(fileName, functionUrlPresign);
1756

18-
let urlToCall = presignerUrl + "/" + fileName
57+
let urlToCall = functionUrlPresign + "/" + fileName
1958
console.log(urlToCall);
2059

2160
let form = this;
@@ -43,8 +82,9 @@
4382
contentType: false,
4483
success: function () {
4584
alert("success!");
85+
updateImageList();
4686
},
47-
error: function() {
87+
error: function () {
4888
alert("error! check the logs");
4989
},
5090
complete: function (event) {
@@ -60,4 +100,37 @@
60100
}
61101
});
62102
});
103+
104+
function updateImageList() {
105+
let listUrl = $("#functionUrlList").val();
106+
if (!listUrl) {
107+
alert("Please set the function URL of the list Lambda");
108+
return
109+
}
110+
111+
$.ajax({
112+
url: listUrl,
113+
success: function (response) {
114+
$('#imagesContainer').empty(); // Empty imagesContainer
115+
response.forEach(function (item) {
116+
console.log(item);
117+
let cardHtml = imageItemTemplate(item);
118+
$("#imagesContainer").append(cardHtml);
119+
});
120+
},
121+
error: function (jqXHR, textStatus, errorThrown) {
122+
console.log("Error:", textStatus, errorThrown);
123+
alert("error! check the logs");
124+
}
125+
});
126+
}
127+
128+
$("#updateImageListButton").click(function (event) {
129+
updateImageList();
130+
});
131+
132+
if (functionUrlList) {
133+
updateImageList();
134+
}
135+
63136
})(jQuery);

website/favicon.ico

14.7 KB
Binary file not shown.

0 commit comments

Comments
 (0)