1
0
mirror of https://github.com/quay/quay.git synced 2025-04-19 21:42:17 +03:00
quay/storage/cloudflarestorage.py
Marcus Kok 4bd036b6c5
storage: add namespace filter to direct download responses (PROJQUAY-8147) (#3363)
* add namespace filter to direct download responses
2024-10-28 13:09:54 -04:00

123 lines
4.4 KiB
Python

import base64
import logging
import urllib.parse
from datetime import datetime, timedelta
from functools import lru_cache
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
logger = logging.getLogger(__name__)
from storage.cloud import S3Storage, is_in_network_request
class CloudFlareS3Storage(S3Storage):
"""
CloudFlare CDN backed by S3 storage
"""
def __init__(
self,
context,
cloudflare_domain,
cloudflare_privatekey_filename,
storage_path,
s3_bucket,
s3_region,
*args,
**kwargs,
):
super(CloudFlareS3Storage, self).__init__(
context, storage_path, s3_bucket, s3_region=s3_region, *args, **kwargs
)
self.context = context
self.cloudflare_domain = cloudflare_domain
self.cloudflare_privatekey = self._load_private_key(cloudflare_privatekey_filename)
self.region = s3_region
def get_direct_download_url(
self, path, request_ip=None, expires_in=60, requires_cors=False, head=False, **kwargs
):
# If CloudFlare could not be loaded, fall back to normal S3.
s3_presigned_url = super(CloudFlareS3Storage, self).get_direct_download_url(
path, request_ip, expires_in, requires_cors, head
)
logger.debug(f"s3 presigned_url: {s3_presigned_url}")
if self.cloudflare_privatekey is None or request_ip is None:
return s3_presigned_url
if is_in_network_request(self._context, request_ip, self.region):
if kwargs.get("cdn_specific", False):
logger.debug(
"Request came from within network but namespace is protected: %s", path
)
else:
logger.debug("Request is from within the network, returning S3 URL")
return s3_presigned_url
s3_url_parsed = urllib.parse.urlparse(s3_presigned_url)
cf_url_parsed = s3_url_parsed._replace(netloc=self.cloudflare_domain)
expire_date = datetime.now() + timedelta(seconds=expires_in)
signed_url = self._cf_sign_url(cf_url_parsed, date_less_than=expire_date, **kwargs)
logger.debug(
'Returning CloudFlare URL for path "%s" with IP "%s": %s',
path,
request_ip,
signed_url,
)
return signed_url
def _cf_sign_url(self, cf_url_parsed, date_less_than, **kwargs):
expiry_ts = date_less_than.timestamp()
sign_data = "%s@%d" % (cf_url_parsed.path, expiry_ts)
signature = self.cloudflare_privatekey.sign(
sign_data.encode("utf8"), padding.PKCS1v15(), hashes.SHA256()
)
signature_b64 = base64.b64encode(signature)
return self._build_signed_url(cf_url_parsed, signature_b64, date_less_than, **kwargs)
def _build_signed_url(self, cf_url_parsed, signature, expiry_date, **kwargs):
query = cf_url_parsed.query
url_dict = dict(urllib.parse.parse_qsl(query))
params = {
"cf_sign": signature,
"cf_expiry": "%d" % expiry_date.timestamp(),
"region": self.region,
}
# Additional params for usage calculation. They are removed
# from the URL before sending to S3 by the CloudFlare worker
if kwargs.get("namespace"):
params["namespace"] = kwargs.get("namespace")
if kwargs.get("username"):
params["username"] = kwargs.get("username")
if kwargs.get("repo_name"):
params["repo_name"] = kwargs.get("repo_name")
url_dict.update(params)
url_new_query = urllib.parse.urlencode(url_dict)
url_parse = cf_url_parsed._replace(query=url_new_query)
return urllib.parse.urlunparse(url_parse)
@lru_cache(maxsize=1)
def _load_private_key(self, cloudfront_privatekey_filename):
"""
Returns the private key, loaded from the config provider, used to sign direct download URLs
to CloudFront.
"""
if self._context.config_provider is None:
return None
with self._context.config_provider.get_volume_file(
cloudfront_privatekey_filename,
mode="rb",
) as key_file:
return serialization.load_pem_private_key(
key_file.read(), password=None, backend=default_backend()
)