@@ -160,6 +160,38 @@ async def sanitize_request(self, request):
160160 "path_qs" : str (request .url ).split (request .base_url .netloc , 1 )[1 ],
161161 }
162162
163+ @staticmethod
164+ def make_etag_fn (deploy_seed , route_ref = None ):
165+ """Build an etag() Jinja helper bound to a deploy seed.
166+
167+ Parameters (all optional):
168+ weak, style ('iis'|'hash'), key, length, changenumber, fmt
169+ """
170+ if route_ref is None :
171+ route_ref = ['' ]
172+
173+ def etag (weak = False , style = 'iis' , key = None , length = None , changenumber = 0 , fmt = None ):
174+ material = f"{ deploy_seed } :{ key if key is not None else route_ref [0 ]} "
175+ digest = hashlib .sha1 (material .encode ()).hexdigest ()
176+ default_len = 32 if style == 'hash' else 13
177+ n = default_len if length is None else int (length )
178+ n = max (1 , min (n , len (digest )))
179+ hexpart = digest [:n ]
180+
181+ if fmt is not None :
182+ token = str (fmt ).format (hex = hexpart , n = changenumber , changenumber = changenumber )
183+ value = token if '"' in token else f'"{ token } "'
184+ elif style == 'hash' :
185+ value = f'"{ hexpart } "'
186+ else :
187+ value = f'"{ hexpart } :{ changenumber } "'
188+
189+ if weak :
190+ value = f'W/{ value } '
191+ return value
192+
193+ return etag
194+
163195 def _resolve_deploy_config (self ):
164196 """Evaluate Jinja expressions in config.yaml once at startup.
165197
@@ -172,11 +204,14 @@ def _resolve_deploy_config(self):
172204 live as Jinja expressions instead of hardcoded strings.
173205 """
174206 deploy_seed = self .config .get ('deploy_seed' ) or secrets .token_hex (8 )
207+ route_ref = ['' ]
175208
176209 eval_env = ImmutableSandboxedEnvironment (autoescape = False )
177210 eval_env .globals ['random' ] = self .random_filter
178211 eval_env .globals ['deploy_seed' ] = deploy_seed
179212 eval_env .globals ['md5' ] = lambda s : hashlib .md5 (str (s ).encode ()).hexdigest ()
213+ eval_env .globals ['etag' ] = self .make_etag_fn (deploy_seed , route_ref )
214+ eval_env .globals ['route' ] = ''
180215
181216 def evaluate (value ):
182217 if isinstance (value , str ) and ('{{' in value or '{%' in value ):
@@ -207,7 +242,9 @@ def evaluate(value):
207242 self .http_config ['headers' ] = evaluate (self .http_config ['headers' ])
208243
209244 for endpoint in self .http_config .get ('endpoints' , []):
210- for details in endpoint .values ():
245+ for route , details in endpoint .items ():
246+ route_ref [0 ] = route
247+ eval_env .globals ['route' ] = route
211248 if not isinstance (details , list ):
212249 details = [details ]
213250 for detail in details :
@@ -236,6 +273,8 @@ def create_jinja_env(self):
236273 'get_current_time' : lambda : datetime .now (timezone .utc ).strftime ('%a, %d %b %Y %H:%M:%S GMT' ),
237274 'uuid' : lambda : str (uuid .uuid4 ()),
238275 'vars' : self .http_config .get ('vars' , {}),
276+ 'etag' : getattr (self , '_etag_fn' , self .make_etag_fn (getattr (self , '_deploy_seed' , '' ))),
277+ 'deploy_seed' : getattr (self , '_deploy_seed' , '' ),
239278 })
240279 env .filters ['quote' ] = lambda s : quote (str (s ), safe = '' )
241280 env .undefined = Undefined
@@ -294,6 +333,12 @@ def parse_front_matter(self, content):
294333
295334 # --- content / response building ---------------------------------------
296335
336+ # Suffixes served as Jinja text templates from templates/.
337+ _JINJA_FILE_SUFFIXES = frozenset ({
338+ '.html' , '.htm' , '.css' , '.js' , '.json' , '.xml' , '.txt' , '.svg' ,
339+ '.csv' , '.md' , '.map' ,
340+ })
341+
297342 async def get_content (self , endpoint_config , request = None ):
298343 if not endpoint_config :
299344 return "" , 200
@@ -306,12 +351,18 @@ async def get_content(self, endpoint_config, request=None):
306351 try :
307352 # Guard against path traversal outside the template folder.
308353 file_path .relative_to (self .template_folder .resolve ())
354+ if not file_path .is_file ():
355+ raise FileNotFoundError (file_path )
356+ status = int (endpoint_config .get ('status_code' , 200 ))
357+ # Binary assets (fonts, icons, …) — raw bytes, no Jinja.
358+ if file_path .suffix .lower () not in self ._JINJA_FILE_SUFFIXES :
359+ return file_path .read_bytes (), status
309360 raw_content = file_path .read_text ()
310361 template = self .env .from_string (raw_content )
311362 template .globals ['request' ] = await self .sanitize_request (request )
312363 metadata , body = self .parse_front_matter (template .render ())
313- return body , int (metadata .get ('status_code' , 200 ))
314- except (ValueError , FileNotFoundError ) as e :
364+ return body , int (metadata .get ('status_code' , status ))
365+ except (ValueError , FileNotFoundError , UnicodeDecodeError ) as e :
315366 print (f"Error: { e } " )
316367
317368 elif 'ai' in endpoint_config :
@@ -502,13 +553,15 @@ async def handle_static_file(self, request):
502553 # paths (../, symlinks, encoded traversal, NUL bytes) cannot escape the
503554 # skin's files/ directory (LFI). Anything outside falls through to the
504555 # normal default response, so a probe looks like an ordinary 404.
505- static_root = self .static_folder .resolve ()
506556 try :
507- file_path = (static_root / rel ).resolve ()
508- file_path .relative_to (static_root )
557+ static_root = self .static_folder .resolve ()
558+ base = self .static_folder .resolve ()
559+ file_path = (self .static_folder / rel ).resolve ()
560+ file_path .relative_to (base )
509561 if file_path .is_file ():
510562 content = file_path .read_bytes ()
511- content_type = mimetypes .guess_type (str (file_path ))[0 ] or 'application/octet-stream'
563+ content_type = (mimetypes .guess_type (str (file_path ))[0 ]
564+ or 'application/octet-stream' )
512565 return content , 200 , {'Content-Type' : content_type }
513566 except (ValueError , OSError ):
514567 pass
0 commit comments