]> git.mar77i.info Git - hublib/blob - hub/static.py
f3e0d844ad68d6459801f36ba3abd6f8580ff1c7
[hublib] / hub / static.py
1 from pathlib import Path
2
3 from falcon.constants import MEDIA_HTML, MEDIA_JS, MEDIA_TEXT
4 from falcon.request import Request
5 from falcon.response import Response
6 from falcon.status_codes import HTTP_OK
7 from jinja2 import Template
8
9 from .websocket import WebSocketApp
10
11 MEDIA_CSS = "text/css"
12
13
14 class StaticFile:
15 """
16 Basic static file wrapper.
17 """
18 media_types_per_suffix: dict[str, str] = {
19 ".html": MEDIA_HTML,
20 ".js": MEDIA_JS,
21 ".css": MEDIA_CSS,
22 ".txt": MEDIA_TEXT,
23 }
24
25 def __init__(self, path: Path):
26 self.path = path
27 self.name = path.name
28 self.media_type = self.get_media_type(path)
29 self.mtime: float | None = None
30 self.content: bytes | None = None
31
32 @classmethod
33 def get_media_type(cls, path: Path):
34 return cls.media_types_per_suffix[path.suffix]
35
36 def get(self) -> bytes:
37 mtime = self.path.stat().st_mtime
38 if mtime != self.mtime:
39 with open(self.path, "rb") as fh:
40 self.content = fh.read()
41 self.mtime = mtime
42 return self.content
43
44 def __repr__(self):
45 return f"<{type(self).__name__} {self.path}>"
46
47
48 class TreeFileApp:
49 """
50 Map a directory tree base_dir to a base_uri and serve it statically.
51 Map index.html files to their relative roots:
52 index.html to "/" (root uri) to index.html and
53 a/index.html to "/a" (directory uri)
54 """
55 @staticmethod
56 def is_ignored_filename(path):
57 return path.name.startswith(".")
58
59 @classmethod
60 def scan_files(cls, base_dir):
61 stack = [base_dir.iterdir()]
62 while len(stack):
63 try:
64 path = next(stack[-1])
65 except StopIteration:
66 stack.pop()
67 continue
68 if path.is_dir():
69 stack.append(path.iterdir())
70 elif not cls.is_ignored_filename(path):
71 yield path
72
73 def get_file(self, path: Path) -> StaticFile:
74 return StaticFile(path)
75
76 def uri_tail(self, path: Path) -> str:
77 """
78 Return the "local" path, relative to self.base_dir, if applicable
79 """
80 assert isinstance(path, Path)
81 if path.is_absolute():
82 path = path.relative_to(self.base_dir)
83 return str(path)
84
85 def uri(self, path: Path) -> str:
86 uri_tail = self.uri_tail(path)
87 if uri_tail == "index.html":
88 return self.base_uri or "/"
89 index_suffix = "/index.html"
90 if uri_tail.endswith(index_suffix):
91 uri_tail = uri_tail[:-len(index_suffix)]
92 return f"{self.base_uri}/{uri_tail.lstrip('/')}"
93
94 @staticmethod
95 def create_name(root_base_uri, base_uri):
96 if base_uri.startswith(root_base_uri):
97 base_uri = base_uri[len(root_base_uri):]
98 return base_uri.replace("/", ".").strip(".")
99
100 def __init__(self, root, base_dir, base_uri="/", name=None):
101 self.root = root
102 self.base_dir = base_dir
103 self.base_uri = base_uri.rstrip("/")
104 self.name = name or self.create_name(self.root.base_uri, self.base_uri)
105 assert self.name, self.name
106 self.files_per_uris: dict[str, StaticFile] = {}
107 for path in self.scan_files(base_dir):
108 static_file = self.get_file(path)
109 uri = self.uri(static_file.path)
110 self.files_per_uris[uri] = static_file
111 root.add_route(uri, self)
112
113 async def on_get(self, req: Request, resp: Response):
114 resource = self.files_per_uris[req.path]
115 resp.content_type = resource.media_type
116 resp.data = resource.get()
117 resp.status = HTTP_OK
118
119
120 class StaticTemplateFile(StaticFile):
121 TEMPLATE_SUFFIX = ".j2"
122 content: Template | None
123
124 def __init__(self, path: Path, hubapp: "TemplateTreeFileApp"):
125 super().__init__(path)
126 self.name = path.stem
127 self.hubapp = hubapp
128 self.context = {"static_file": self}
129
130 def get(self) -> bytes:
131 mtime = self.path.stat().st_mtime
132 if mtime != self.mtime:
133 env = self.hubapp.root.env
134 self.content = env.get_template(
135 str(self.path.relative_to(env.loader.searchpath[0]))
136 )
137 self.mtime = mtime
138 return self.content.render(self.context).encode()
139
140 @classmethod
141 def get_media_type(cls, path: Path) -> str:
142 assert path.suffix == cls.TEMPLATE_SUFFIX
143 return cls.media_types_per_suffix[path.suffixes[-2]]
144
145
146 class TemplateTreeFileApp(TreeFileApp):
147 def get_file(self, path: Path) -> StaticFile:
148 if path.suffix == StaticTemplateFile.TEMPLATE_SUFFIX:
149 return StaticTemplateFile(path, self)
150 return super().get_file(path)
151
152 def uri_tail(self, path: Path) -> str:
153 uri_tail = super().uri_tail(path)
154 if uri_tail.endswith(StaticTemplateFile.TEMPLATE_SUFFIX):
155 uri_tail = uri_tail[:-len(StaticTemplateFile.TEMPLATE_SUFFIX)]
156 return uri_tail
157
158
159 class HubApp(TemplateTreeFileApp):
160 def __init__(self, app, base_dir, base_uri="/"):
161 super().__init__(app, base_dir, base_uri)
162 self.ws_app = WebSocketApp(self)
163 for suffix in ("client", "master"):
164 app.add_route(self.uri(Path(f"ws_{suffix}")), self.ws_app, suffix=suffix)
165
166 @staticmethod
167 def is_master_uri(path: Path) -> bool:
168 assert isinstance(path, Path)
169 basename = path.name
170 pos = basename.find(".")
171 if pos != -1:
172 basename = basename[:pos]
173 return basename in ("master", "ws_master")
174
175 def uri(self, path: Path) -> str:
176 uri = super().uri(path)
177 if not self.is_master_uri(path):
178 return uri
179 pos = uri.rstrip("/").rfind("/")
180 assert pos >= 0
181 scrambled_uri = self.root.scramble(uri)
182 return f"{uri[:pos]}/{scrambled_uri}"