Adding more features.
- Implementing support for custom domains - Blocking common link previewers to "unwind" the links - Setting a log retention policy for the lambda functions - Updating to latest CDK version (1.44)
This commit is contained in:
25
README.md
25
README.md
@@ -21,13 +21,26 @@ With CDK I could create the following resources:
|
|||||||
|
|
||||||
I will use API Gateway to expose the lambda functions as an HTTP API.
|
I will use API Gateway to expose the lambda functions as an HTTP API.
|
||||||
|
|
||||||
|
|
||||||
HERE BE DIAGRAM!
|
HERE BE DIAGRAM!
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
mkdir once
|
[+] Publish it to a custom domain name: DONE
|
||||||
cd once
|
[+] Set logs retention policy
|
||||||
cdk init app
|
[+] Deploy custom domain as a nested stack
|
||||||
|
[+] Mask link preview depending on the user agent
|
||||||
|
|
||||||
Then it should be easy to start organizing the project layout.
|
- Add a robust authentication method
|
||||||
One single stack, one folder for each lambda function.
|
- Add progressbar to client
|
||||||
|
|
||||||
|
- Package application as a click app
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- Write a proper README with instructions
|
||||||
|
- Record a demo
|
||||||
|
- write tests with pytest
|
||||||
|
|
||||||
|
- publish the source code
|
||||||
|
- write a blog post
|
||||||
|
- add a link to the blog post in the README
|
||||||
17
app.py
17
app.py
@@ -1,11 +1,22 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
from aws_cdk import core
|
import os
|
||||||
|
from aws_cdk import (
|
||||||
|
core,
|
||||||
|
aws_route53 as route53)
|
||||||
|
|
||||||
from once.once_stack import OnceStack
|
from once.once_stack import OnceStack, CustomDomainStack
|
||||||
|
|
||||||
|
USE_CUSTOM_DOMAIN = True
|
||||||
|
DOMAIN_NAME = os.getenv('DOMAIN_NAME')
|
||||||
|
HOSTED_ZONE_NAME = os.getenv('HOSTED_ZONE_NAME')
|
||||||
|
HOSTED_ZONE_ID = os.getenv('HOSTED_ZONE_ID')
|
||||||
|
|
||||||
|
|
||||||
app = core.App()
|
app = core.App()
|
||||||
OnceStack(app, "once")
|
once = OnceStack(app, 'once',
|
||||||
|
custom_domain=DOMAIN_NAME,
|
||||||
|
hosted_zone_id=HOSTED_ZONE_ID,
|
||||||
|
hosted_zone_name=HOSTED_ZONE_NAME)
|
||||||
|
|
||||||
app.synth()
|
app.synth()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import os
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
|
|
||||||
@@ -18,6 +19,20 @@ DEBUG = is_debug_enabled()
|
|||||||
FILES_BUCKET = os.getenv('FILES_BUCKET')
|
FILES_BUCKET = os.getenv('FILES_BUCKET')
|
||||||
FILES_TABLE_NAME = os.getenv('FILES_TABLE_NAME')
|
FILES_TABLE_NAME = os.getenv('FILES_TABLE_NAME')
|
||||||
PRESIGNED_URL_EXPIRES_IN = int(os.getenv('PRESIGNED_URL_EXPIRES_IN', 20))
|
PRESIGNED_URL_EXPIRES_IN = int(os.getenv('PRESIGNED_URL_EXPIRES_IN', 20))
|
||||||
|
MASKED_USER_AGENTS = os.getenv('MASKED_USER_AGENTS', ','.join([
|
||||||
|
'^Facebook.*',
|
||||||
|
'^Google.*',
|
||||||
|
'^Instagram.*',
|
||||||
|
'^LinkedIn.*',
|
||||||
|
'^Outlook.*',
|
||||||
|
'^Reddit.*',
|
||||||
|
'^Slack.*',
|
||||||
|
'^Skype.*',
|
||||||
|
'^SnapChat.*',
|
||||||
|
'^Telegram.*',
|
||||||
|
'^Twitter.*',
|
||||||
|
'^WhatsApp.*'
|
||||||
|
])).split(',')
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger()
|
log = logging.getLogger()
|
||||||
@@ -49,6 +64,17 @@ def on_event(event, context):
|
|||||||
log.info(error_message)
|
log.info(error_message)
|
||||||
return {'statusCode': 404, 'body': error_message}
|
return {'statusCode': 404, 'body': error_message}
|
||||||
|
|
||||||
|
# Some rich clients try to get a preview of any link pasted
|
||||||
|
# into text controls.
|
||||||
|
user_agent = event['headers'].get('user-agent', '')
|
||||||
|
is_masked_agent = any([re.match(agent, user_agent) for agent in MASKED_USER_AGENTS])
|
||||||
|
if is_masked_agent:
|
||||||
|
log.info('Serving possible link preview. Download prevented.')
|
||||||
|
return {
|
||||||
|
'statusCode': 200,
|
||||||
|
'headers': {}
|
||||||
|
}
|
||||||
|
|
||||||
s3 = boto3.client('s3')
|
s3 = boto3.client('s3')
|
||||||
download_url = s3.generate_presigned_url(
|
download_url = s3.generate_presigned_url(
|
||||||
'get_object',
|
'get_object',
|
||||||
@@ -69,4 +95,3 @@ def on_event(event, context):
|
|||||||
'Location': download_url
|
'Location': download_url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,9 +66,7 @@ def on_event(event, context):
|
|||||||
response = {}
|
response = {}
|
||||||
try:
|
try:
|
||||||
if filename is None:
|
if filename is None:
|
||||||
raise BadRequestError(
|
raise BadRequestError('Provide a valid value for the `f` query parameter')
|
||||||
'Please provide a valid value for the `filename_prefix` '
|
|
||||||
'query parameter')
|
|
||||||
|
|
||||||
domain = string.ascii_uppercase + string.ascii_lowercase + string.digits
|
domain = string.ascii_uppercase + string.ascii_lowercase + string.digits
|
||||||
entry_id = ''.join(random.choice(domain) for _ in range(6))
|
entry_id = ''.join(random.choice(domain) for _ in range(6))
|
||||||
|
|||||||
@@ -1,22 +1,85 @@
|
|||||||
import os
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import jsii
|
||||||
from aws_cdk import(
|
from aws_cdk import(
|
||||||
core,
|
core,
|
||||||
aws_apigatewayv2 as apigw,
|
aws_apigatewayv2 as apigw,
|
||||||
|
aws_certificatemanager as certmgr,
|
||||||
|
aws_cloudformation as cfn,
|
||||||
aws_dynamodb as dynamodb,
|
aws_dynamodb as dynamodb,
|
||||||
aws_events as events,
|
aws_events as events,
|
||||||
aws_events_targets as targets,
|
aws_events_targets as targets,
|
||||||
aws_lambda as lambda_,
|
aws_lambda as lambda_,
|
||||||
|
aws_logs as logs,
|
||||||
|
aws_route53 as route53,
|
||||||
|
aws_route53_targets as route53_targets,
|
||||||
aws_s3 as s3)
|
aws_s3 as s3)
|
||||||
|
|
||||||
from .utils import make_python_zip_bundle
|
from .utils import make_python_zip_bundle
|
||||||
|
|
||||||
|
|
||||||
BASE_PATH = os.path.dirname(os.path.abspath(__file__))
|
BASE_PATH = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
LOG_RETENTION = getattr(logs.RetentionDays, os.getenv('LOG_RETENTION', 'TWO_WEEKS'))
|
||||||
|
|
||||||
|
|
||||||
|
@jsii.implements(route53.IAliasRecordTarget)
|
||||||
|
class ApiGatewayV2Domain(object):
|
||||||
|
def __init__(self, domain_name: apigw.CfnDomainName):
|
||||||
|
self.domain_name = domain_name
|
||||||
|
|
||||||
|
@jsii.member(jsii_name='bind')
|
||||||
|
def bind(self, _record: route53.IRecordSet) -> route53.AliasRecordTargetConfig:
|
||||||
|
return {
|
||||||
|
'dnsName': self.domain_name.get_att('RegionalDomainName').to_string(),
|
||||||
|
'hostedZoneId': self.domain_name.get_att('RegionalHostedZoneId').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomDomainStack(cfn.NestedStack):
|
||||||
|
def __init__(self, scope: core.Construct, id: str,
|
||||||
|
hosted_zone_id: str,
|
||||||
|
hosted_zone_name: str,
|
||||||
|
domain_name: str,
|
||||||
|
api: apigw.HttpApi):
|
||||||
|
super().__init__(scope, id)
|
||||||
|
|
||||||
|
hosted_zone = route53.HostedZone.from_hosted_zone_attributes(self, id='dns-hosted-zone',
|
||||||
|
hosted_zone_id=hosted_zone_id,
|
||||||
|
zone_name=hosted_zone_name)
|
||||||
|
|
||||||
|
certificate = certmgr.DnsValidatedCertificate(self, 'tls-certificate',
|
||||||
|
domain_name=domain_name,
|
||||||
|
hosted_zone=hosted_zone,
|
||||||
|
validation_method=certmgr.ValidationMethod.DNS)
|
||||||
|
|
||||||
|
custom_domain = apigw.CfnDomainName(self, 'custom-domain',
|
||||||
|
domain_name=domain_name,
|
||||||
|
domain_name_configurations=[
|
||||||
|
apigw.CfnDomainName.DomainNameConfigurationProperty(
|
||||||
|
certificate_arn=certificate.certificate_arn)])
|
||||||
|
|
||||||
|
custom_domain.node.add_dependency(api)
|
||||||
|
custom_domain.node.add_dependency(certificate)
|
||||||
|
|
||||||
|
api_mapping = apigw.CfnApiMapping(self, 'custom-domain-mapping',
|
||||||
|
api_id=api.http_api_id,
|
||||||
|
domain_name=domain_name,
|
||||||
|
stage='$default')
|
||||||
|
|
||||||
|
api_mapping.node.add_dependency(custom_domain)
|
||||||
|
|
||||||
|
route53.ARecord(self, 'custom-domain-record',
|
||||||
|
target=route53.RecordTarget.from_alias(ApiGatewayV2Domain(custom_domain)),
|
||||||
|
zone=hosted_zone,
|
||||||
|
record_name=domain_name)
|
||||||
|
|
||||||
|
|
||||||
class OnceStack(core.Stack):
|
class OnceStack(core.Stack):
|
||||||
def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
|
def __init__(self, scope: core.Construct, id: str,
|
||||||
|
custom_domain: Optional[str] = None,
|
||||||
|
hosted_zone_id: Optional[str] = None,
|
||||||
|
hosted_zone_name: Optional[str] = None,
|
||||||
|
**kwargs) -> None:
|
||||||
super().__init__(scope, id, **kwargs)
|
super().__init__(scope, id, **kwargs)
|
||||||
|
|
||||||
self.files_bucket = s3.Bucket(self, 'files-bucket',
|
self.files_bucket = s3.Bucket(self, 'files-bucket',
|
||||||
@@ -33,16 +96,21 @@ class OnceStack(core.Stack):
|
|||||||
|
|
||||||
self.api = apigw.HttpApi(self, 'once-api', api_name='once-api')
|
self.api = apigw.HttpApi(self, 'once-api', api_name='once-api')
|
||||||
|
|
||||||
core.CfnOutput(self, 'api-url', value=self.api.url)
|
api_url = self.api.url
|
||||||
|
if custom_domain is not None:
|
||||||
|
api_url = f'https://{custom_domain}/'
|
||||||
|
|
||||||
|
core.CfnOutput(self, 'api-url', value=api_url)
|
||||||
|
|
||||||
self.get_upload_ticket_function = lambda_.Function(self, 'get-upload-ticket-function',
|
self.get_upload_ticket_function = lambda_.Function(self, 'get-upload-ticket-function',
|
||||||
function_name='once-get-upload-ticket',
|
function_name='once-get-upload-ticket',
|
||||||
|
description='Returns a pre-signed request to share a file',
|
||||||
runtime=lambda_.Runtime.PYTHON_3_7,
|
runtime=lambda_.Runtime.PYTHON_3_7,
|
||||||
code=make_python_zip_bundle(os.path.join(BASE_PATH, 'get-upload-ticket')),
|
code=make_python_zip_bundle(os.path.join(BASE_PATH, 'get-upload-ticket')),
|
||||||
handler='handler.on_event',
|
handler='handler.on_event',
|
||||||
description='Returns a pre-signed request to share a file',
|
log_retention=LOG_RETENTION,
|
||||||
environment={
|
environment={
|
||||||
'APP_URL': self.api.url,
|
'APP_URL': api_url,
|
||||||
'FILES_TABLE_NAME': self.files_table.table_name,
|
'FILES_TABLE_NAME': self.files_table.table_name,
|
||||||
'FILES_BUCKET': self.files_bucket.bucket_name
|
'FILES_BUCKET': self.files_bucket.bucket_name
|
||||||
})
|
})
|
||||||
@@ -52,10 +120,11 @@ class OnceStack(core.Stack):
|
|||||||
|
|
||||||
self.download_and_delete_function = lambda_.Function(self, 'download-and-delete-function',
|
self.download_and_delete_function = lambda_.Function(self, 'download-and-delete-function',
|
||||||
function_name='once-download-and-delete',
|
function_name='once-download-and-delete',
|
||||||
|
description='Serves a file from S3 and deletes it as soon as it has been successfully transferred',
|
||||||
runtime=lambda_.Runtime.PYTHON_3_7,
|
runtime=lambda_.Runtime.PYTHON_3_7,
|
||||||
code=lambda_.Code.from_asset(os.path.join(BASE_PATH, 'download-and-delete')),
|
code=lambda_.Code.from_asset(os.path.join(BASE_PATH, 'download-and-delete')),
|
||||||
handler='handler.on_event',
|
handler='handler.on_event',
|
||||||
description='Serves a file from S3 and deletes it as soon as it has been successfully transferred.',
|
log_retention=LOG_RETENTION,
|
||||||
environment={
|
environment={
|
||||||
'FILES_BUCKET': self.files_bucket.bucket_name,
|
'FILES_BUCKET': self.files_bucket.bucket_name,
|
||||||
'FILES_TABLE_NAME': self.files_table.table_name
|
'FILES_TABLE_NAME': self.files_table.table_name
|
||||||
@@ -79,10 +148,11 @@ class OnceStack(core.Stack):
|
|||||||
|
|
||||||
self.cleanup_function = lambda_.Function(self, 'delete-served-files-function',
|
self.cleanup_function = lambda_.Function(self, 'delete-served-files-function',
|
||||||
function_name='once-delete-served-files',
|
function_name='once-delete-served-files',
|
||||||
|
description='Deletes files from S3 once they have been marked as deleted in DynamoDB',
|
||||||
runtime=lambda_.Runtime.PYTHON_3_7,
|
runtime=lambda_.Runtime.PYTHON_3_7,
|
||||||
code=lambda_.Code.from_asset(os.path.join(BASE_PATH, 'delete-served-files')),
|
code=lambda_.Code.from_asset(os.path.join(BASE_PATH, 'delete-served-files')),
|
||||||
handler='handler.on_event',
|
handler='handler.on_event',
|
||||||
description='Deletes files from S3 once they have been marked as deleted in DynamoDB',
|
log_retention=LOG_RETENTION,
|
||||||
environment={
|
environment={
|
||||||
'FILES_BUCKET': self.files_bucket.bucket_name,
|
'FILES_BUCKET': self.files_bucket.bucket_name,
|
||||||
'FILES_TABLE_NAME': self.files_table.table_name
|
'FILES_TABLE_NAME': self.files_table.table_name
|
||||||
@@ -91,6 +161,13 @@ class OnceStack(core.Stack):
|
|||||||
self.files_bucket.grant_delete(self.cleanup_function)
|
self.files_bucket.grant_delete(self.cleanup_function)
|
||||||
self.files_table.grant_read_write_data(self.cleanup_function)
|
self.files_table.grant_read_write_data(self.cleanup_function)
|
||||||
|
|
||||||
events.Rule(self, "once-delete-served-files-rule",
|
events.Rule(self, 'once-delete-served-files-rule',
|
||||||
schedule=events.Schedule.rate(core.Duration.hours(24)),
|
schedule=events.Schedule.rate(core.Duration.hours(24)),
|
||||||
targets=[targets.LambdaFunction(self.cleanup_function)])
|
targets=[targets.LambdaFunction(self.cleanup_function)])
|
||||||
|
|
||||||
|
if custom_domain is not None:
|
||||||
|
self.custom_domain_stack = CustomDomainStack(self, 'custom-domain',
|
||||||
|
api=self.api,
|
||||||
|
domain_name=custom_domain,
|
||||||
|
hosted_zone_id=hosted_zone_id,
|
||||||
|
hosted_zone_name=hosted_zone_name)
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
-e .
|
-e .
|
||||||
aws_cdk.core
|
aws_cdk.core
|
||||||
aws_cdk.aws_apigatewayv2
|
aws_cdk.aws_apigatewayv2
|
||||||
|
aws_cdk.aws_certificatemanager
|
||||||
|
aws_cdk.aws_cloudformation
|
||||||
aws_cdk.aws_dynamodb
|
aws_cdk.aws_dynamodb
|
||||||
aws_cdk.aws_events
|
aws_cdk.aws_events
|
||||||
aws_cdk.aws_events_targets
|
aws_cdk.aws_events_targets
|
||||||
aws_cdk.aws_lambda
|
aws_cdk.aws_lambda
|
||||||
|
aws_cdk.aws_logs
|
||||||
|
aws_cdk.aws_route53
|
||||||
aws_cdk.aws_s3
|
aws_cdk.aws_s3
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -13,7 +13,7 @@ setuptools.setup(
|
|||||||
long_description=long_description,
|
long_description=long_description,
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
|
|
||||||
author="author",
|
author="Domenico Testa",
|
||||||
|
|
||||||
package_dir={"": "once"},
|
package_dir={"": "once"},
|
||||||
packages=setuptools.find_packages(where="once"),
|
packages=setuptools.find_packages(where="once"),
|
||||||
|
|||||||
Reference in New Issue
Block a user