diff --git a/custom_components/hacs/helpers/functions/file_etag.py b/custom_components/hacs/helpers/functions/file_etag.py new file mode 100644 index 00000000000..16057f525c8 --- /dev/null +++ b/custom_components/hacs/helpers/functions/file_etag.py @@ -0,0 +1,21 @@ +# pylint: disable=missing-class-docstring,missing-module-docstring,missing-function-docstring,no-member +import os + +from custom_components.hacs.share import get_hacs +from fnvhash import fnv1a_32 +from pathlib import Path + + +def get_etag(path) -> bool: + file_path = Path(path) + if not file_path.exists(): + return None + return fnv1a_32(file_path.read_bytes()) + + +async def async_get_etag(path) -> bool: + hass = get_hacs().hass + fnv = await hass.async_add_executor_job(get_etag, path) + if fnv is None: + return None + return str(hex(fnv)) diff --git a/custom_components/hacs/manifest.json b/custom_components/hacs/manifest.json index 2d9c6d24c2f..568e83abb88 100644 --- a/custom_components/hacs/manifest.json +++ b/custom_components/hacs/manifest.json @@ -18,8 +18,9 @@ "aiofiles>=0.5.0", "aiogithubapi>=2.0.0<3.0.0", "backoff>=1.10.0", + "fnvhash>=0.1.0<1.0.0", "hacs_frontend==202011221222", "semantic_version>=2.8.5", "queueman==0.5" ] -} \ No newline at end of file +} diff --git a/custom_components/hacs/webresponses/category.py b/custom_components/hacs/webresponses/category.py index b8458c400a7..9f51c09befc 100644 --- a/custom_components/hacs/webresponses/category.py +++ b/custom_components/hacs/webresponses/category.py @@ -2,6 +2,8 @@ from custom_components.hacs.helpers.functions.logger import getLogger from custom_components.hacs.helpers.functions.path_exsist import async_path_exsist +from custom_components.hacs.helpers.functions.file_etag import async_get_etag + from custom_components.hacs.share import get_hacs _LOGGER = getLogger() @@ -9,31 +11,83 @@ async def async_serve_category_file(request, requested_file): hacs = get_hacs() + response = None + try: if requested_file.startswith("themes/"): servefile = f"{hacs.core.config_path}/{requested_file}" + response = await async_serve_static_file(request, servefile, requested_file) else: servefile = f"{hacs.core.config_path}/www/community/{requested_file}" - - if await async_path_exsist(servefile): - _LOGGER.debug("Serving %s from %s", requested_file, servefile) - response = web.FileResponse(servefile) - if requested_file.startswith("themes/"): - response.headers["Cache-Control"] = "public, max-age=2678400" - else: - response.headers["Cache-Control"] = "no-store, max-age=0" - response.headers["Pragma"] = "no-store" - return response - else: - _LOGGER.error( - "%s tried to request '%s' but the file does not exist", - request.remote, - servefile, + response = await async_serve_static_file_with_etag( + request, servefile, requested_file ) + except (Exception, BaseException): + _LOGGER.exception("Error trying to serve %s", requested_file) + + if response is not None: + return response + + return web.Response(status=404) + + +async def async_serve_static_file(request, servefile, requested_file): + """Serve a static file without an etag.""" + if await async_path_exsist(servefile): + _LOGGER.debug("Serving %s from %s", requested_file, servefile) + response = web.FileResponse(servefile) + response.headers["Cache-Control"] = "public, max-age=2678400" + return response - except (Exception, BaseException) as exception: + _LOGGER.error( + "%s tried to request '%s' but the file does not exist", + request.remote, + servefile, + ) + return None + + +async def async_serve_static_file_with_etag(request, servefile, requested_file): + """Serve a static file with an etag.""" + etag = await async_get_etag(servefile) + if_none_match_header = request.headers.get("if-none-match") + + if ( + etag is not None + and if_none_match_header is not None + and _match_etag(etag, if_none_match_header) + ): _LOGGER.debug( - "there was an issue trying to serve %s - %s", requested_file, exception + "Serving %s from %s with etag %s (not-modified)", + requested_file, + servefile, + etag, ) + return web.Response(status=304) - return web.Response(status=404) + if etag is not None: + _LOGGER.debug( + "Serving %s from %s with etag %s (not cached)", + requested_file, + servefile, + etag, + ) + response = web.FileResponse(servefile) + response.headers["Cache-Control"] = "must-revalidate, max-age=0" + response.headers["Etag"] = etag + return response + + _LOGGER.error( + "%s tried to request '%s' but the file does not exist", + request.remote, + servefile, + ) + return None + + +def _match_etag(etag, if_none_match_header): + """Check to see if an etag matches.""" + for if_none_match_ele in if_none_match_header.split(","): + if if_none_match_ele.strip() == etag: + return True + return False diff --git a/requirements.txt b/requirements.txt index e63ccc10b3b..8685fd00465 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ attrs==20.3.0 backoff==1.10.0 bellybutton==0.3.0 colorlog==4.6.2 +fnvhash>=0.1.0<1.0.0 hacs_frontend==202011221222 pre-commit==2.8.2 PyGithub==1.53