Files

195 lines
5.8 KiB
Python

import asyncio
import datetime
import hmac
import logging
from os.path import join as path_join
from base64 import urlsafe_b64encode, urlsafe_b64decode
from hashlib import sha224, sha256
from aiofiles.os import path
from blacksheep import Application, Request, Response, bad_request
from blacksheep.server import responses
from downloader import ImgproxyDownloader, DownloadInProgress, BadResponse
logger = logging.getLogger('uvicorn.application')
IMGPROXY_URL = 'http://127.0.0.1:8090/'
SIG_SECRET = b'1234'
SIG_LENGTH_BYTES = 12
ALLOWED_SIZES = {b'150', b'250', b'500'}
DOWNLOAD_TIMEOUT_SEC = 30
app = Application()
class BadOptions(Exception):
pass
class SignatureValidator:
SIG_LENGTH_BASE64 = int(SIG_LENGTH_BYTES * 1.34)
WEEK_DELTA = datetime.timedelta(weeks=1)
def __init__(self, key: bytes):
self.key = key
def _get_now(self) -> datetime.datetime:
return datetime.datetime.now()
def _calc_sig(self, iso_tuple: tuple[int, int, int], msg: bytes) -> bytes:
key = self.key + "{:04}{:02}".format(iso_tuple.year, iso_tuple.week).encode('ascii')
return urlsafe_b64encode(hmac.HMAC(key, msg, digestmod=sha256)
.digest()[:SIG_LENGTH_BYTES])
def verify_signature(self, msg: bytes, sig: bytes) -> bool:
if len(sig) != self.SIG_LENGTH_BASE64:
return False
now = self._get_now()
iso_week = now.isocalendar()
# check for current week
real_sig = self._calc_sig(iso_week, msg)
if sig == real_sig:
return True
# check for last week
last_week = now - self.WEEK_DELTA
last_week_iso = last_week.isocalendar()
real_sig = self._calc_sig(last_week_iso, msg)
return sig == real_sig
def parse_imgproxy_params(params: str) -> list[bytes]:
if params == 'full':
return []
params = params.encode('ascii')
sizes = params.split(b'x')
if not sizes:
raise BadOptions()
opts = []
try:
height, width = sizes[0], sizes[1]
except:
raise BadOptions()
if width and width in ALLOWED_SIZES:
opts.append(b'w:' + width)
if height and height in ALLOWED_SIZES:
opts.append(b'h:' + height)
if not opts:
raise BadOptions()
return opts
def create_filename(bucket: bytes, img_url: bytes) -> bytes:
img_url_hash = sha224(img_url).digest()
hash_b64 = urlsafe_b64encode(img_url_hash)
return bucket + b'/' + hash_b64[0:1] + b'/' + hash_b64[1:2] + b'/' + hash_b64
requester = ImgproxyDownloader(IMGPROXY_URL)
#requester = ImgproxyDownloader('')
#requester._get_full_path = lambda x: urlsafe_b64decode(x).decode('ascii')
async def download_file(img_url: str, file_path: str, requester=requester):
try:
return await requester.save(img_url, file_path)
except DownloadInProgress:
for i in range(DOWNLOAD_TIMEOUT_SEC):
await asyncio.sleep(1)
if await path.isfile(file_path):
return await requester.read_headers(file_path)
raise BadResponse('stalled download')
validator = SignatureValidator(SIG_SECRET)
@app.router.get("/*")
async def handle_signed_request(request: Request) -> Response:
# check path
url = request.route_values['tail'].split('/')
if len(url) not in (4, 5):
logger.debug("wrong number of path elements: %d", len(url))
return bad_request()
bucket = url.pop(0).encode('ascii')
img_url = url[2].encode('ascii')
sig_msg = bucket + b'/' + img_url
if len(url) == 4:
sig_msg += b'/' + url[3].encode('ascii')
# validate signature
if not validator.verify_signature(sig_msg, url[1].encode('ascii')):
logger.debug("failed to verify signature %s for %s", url[1], sig_msg)
return responses.status_code(410)
if logger.isEnabledFor(logging.INFO):
url_b64 = url[2] + ("=" * (len(url[2]) % 4))
log_url = urlsafe_b64decode(url_b64).decode('ascii', errors='ignore')
if len(log_url) > 100 and not logger.isEnabledFor(logging.DEBUG):
log_url = log_url[:95] + '<...>'
else:
log_url = url[2]
# parse options
is_proxy = len(url) == 4 and url[3] == 'proxy'
try:
imgproxy_opts = parse_imgproxy_params(url[0])
except BadOptions:
logger.debug("invalid resize options: %s", url[0])
return bad_request()
is_resize = bool(imgproxy_opts)
if not is_proxy:
imgproxy_opts.append(b'fn:' + img_url)
img_path = create_filename(bucket, img_url)
# download and perma-store image through imgproxy
base_path = request.get_first_header(b'root_dir').decode('ascii')
file_path = path_join(base_path, img_path.decode('ascii'))
if await path.isfile(file_path):
headers = await requester.read_headers(file_path)
elif is_proxy:
headers = {}
else:
try:
headers = await download_file(url[2], file_path)
except BadResponse as e:
err_msg = "failed to fetch from remote server"
if e.msg:
err_msg += f" ({e.msg})"
if e.msg.startswith('backoff'):
logger.debug("failed to fetch %s: %s", log_url, e.msg)
else:
logger.info("failed to fetch %s: %s", log_url, e.msg)
return responses.status_code(520, err_msg)
logger.info("downloaded image %s", log_url)
# set headers and leave everything else to nginx
if is_proxy:
resp = responses.status_code(305)
elif is_resize:
resp = responses.see_other(b'/' + img_path)
else:
resp = responses.redirect(b'/' + img_path)
resp.set_header(b'imgproxy-opts', b'/'.join(imgproxy_opts) + b'/')
resp.set_header(b'imgproxy-path', img_path)
resp.set_header(b'imgproxy-url', img_url)
for k, v in headers.items():
resp.set_header(k.encode('ascii'), v.encode('ascii'))
return resp