Coverage for an_website/utils/static_file_handling.py: 91.525%

59 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-11-16 19:56 +0000

1# This program is free software: you can redistribute it and/or modify 

2# it under the terms of the GNU Affero General Public License as 

3# published by the Free Software Foundation, either version 3 of the 

4# License, or (at your option) any later version. 

5# 

6# This program is distributed in the hope that it will be useful, 

7# but WITHOUT ANY WARRANTY; without even the implied warranty of 

8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

9# GNU Affero General Public License for more details. 

10# 

11# You should have received a copy of the GNU Affero General Public License 

12# along with this program. If not, see <https://www.gnu.org/licenses/>. 

13 

14"""Useful stuff for handling static files.""" 

15 

16from __future__ import annotations 

17 

18import logging 

19import sys 

20from collections.abc import Mapping 

21from functools import cache 

22from importlib.resources.abc import Traversable 

23from pathlib import Path 

24from typing import Final 

25 

26import defity 

27import orjson as json 

28import tornado.web 

29from blake3 import blake3 

30from openmoji_dist import VERSION as OPENMOJI_VERSION, get_openmoji_data 

31 

32from .. import DIR as ROOT_DIR, STATIC_DIR 

33from .utils import Handler, recurse_directory 

34 

35LOGGER: Final = logging.getLogger(__name__) 

36 

37 

38def hash_file(path: Traversable) -> str: 

39 """Hash a file with BLAKE3.""" 

40 hasher = blake3() 

41 with path.open("rb") as file: 

42 for data in file: 

43 hasher.update(data) 

44 return hasher.hexdigest(8) 

45 

46 

47def create_file_hashes_dict() -> dict[str, str]: 

48 """Create a dict of file hashes.""" 

49 static = Path("/static") 

50 file_hashes_dict = { 

51 f"{(static / path).as_posix()}": hash_file(STATIC_DIR / path) 

52 for path in recurse_directory(STATIC_DIR, lambda path: path.is_file()) 

53 } 

54 file_hashes_dict["/favicon.png"] = file_hashes_dict["/static/favicon.png"] 

55 file_hashes_dict["/favicon.jxl"] = file_hashes_dict["/static/favicon.jxl"] 

56 file_hashes_dict["/humans.txt"] = file_hashes_dict["/static/humans.txt"] 

57 return file_hashes_dict 

58 

59 

60FILE_HASHES_DICT: Final[Mapping[str, str]] = create_file_hashes_dict() 

61 

62CONTENT_TYPES: Final[Mapping[str, str]] = json.loads( 

63 (ROOT_DIR / "vendored" / "media-types.json").read_bytes() 

64) 

65 

66 

67def get_handlers() -> list[Handler]: 

68 """Return a list of handlers for static files.""" 

69 # pylint: disable=import-outside-toplevel, cyclic-import 

70 from .static_file_from_traversable import TraversableStaticFileHandler 

71 

72 handlers: list[Handler] = [ 

73 ( 

74 "/static/openmoji/(.*)", 

75 TraversableStaticFileHandler, 

76 {"root": get_openmoji_data(), "hashes": {}}, 

77 ), 

78 ( 

79 r"(?:/static)?/(\.env|favicon\.(?:png|jxl)|humans\.txt|robots\.txt)", 

80 TraversableStaticFileHandler, 

81 {"root": STATIC_DIR, "hashes": FILE_HASHES_DICT}, 

82 ), 

83 ( 

84 "/favicon.ico", 

85 tornado.web.RedirectHandler, 

86 {"url": fix_static_path("favicon.png")}, 

87 ), 

88 ] 

89 debug_style_dir = ( 

90 ROOT_DIR.absolute().parent / "style" 

91 if isinstance(ROOT_DIR, Path) 

92 else None 

93 ) 

94 if sys.flags.dev_mode and debug_style_dir and debug_style_dir.exists(): 

95 # add handlers for the unminified CSS files 

96 handlers.append( 

97 ( 

98 r"/static/css/(.+\.css)", 

99 TraversableStaticFileHandler, 

100 {"root": debug_style_dir, "hashes": {}}, 

101 ) 

102 ) 

103 handlers.append( 

104 ( 

105 r"/static/(.*)", 

106 TraversableStaticFileHandler, 

107 {"root": STATIC_DIR, "hashes": FILE_HASHES_DICT}, 

108 ) 

109 ) 

110 return handlers 

111 

112 

113@cache 

114def fix_static_path(path: str) -> str: 

115 """Fix the path for static files.""" 

116 if not path.startswith("/"): 

117 path = f"/static/{path}" 

118 if "?" in path: 

119 path = path.split("?")[0] 

120 if path.startswith("/static/openmoji/"): 

121 return f"{path}?v={OPENMOJI_VERSION}" 

122 path = path.lower() 

123 if path in FILE_HASHES_DICT: 

124 hash_ = FILE_HASHES_DICT[path] 

125 return f"{path}?v={hash_}" 

126 LOGGER.warning("%s not in FILE_HASHES_DICT", path) 

127 return path 

128 

129 

130def content_type_from_path(url_path: str, file: Traversable) -> str | None: 

131 """Extract the Content-Type from a path.""" 

132 content_type: str | None = CONTENT_TYPES.get(Path(url_path).suffix[1:]) 

133 if not content_type: 

134 with file.open("rb") as io: 

135 content_type = defity.from_file(io) 

136 if content_type and content_type.startswith("text/"): 

137 content_type += "; charset=UTF-8" 

138 return content_type