moosey-cms 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- moosey_cms/.python-version +1 -0
- moosey_cms/README.md +0 -0
- moosey_cms/__init__.py +12 -0
- moosey_cms/cache.py +68 -0
- moosey_cms/file_watcher.py +31 -0
- moosey_cms/filters.py +522 -0
- moosey_cms/helpers.py +256 -0
- moosey_cms/hot_reload_script.py +91 -0
- moosey_cms/main.py +281 -0
- moosey_cms/md.py +192 -0
- moosey_cms/models.py +79 -0
- moosey_cms/py.typed +0 -0
- moosey_cms/pyproject.toml +28 -0
- moosey_cms/seo.py +155 -0
- moosey_cms/static/js/reload-script.js +77 -0
- moosey_cms-0.1.0.dist-info/METADATA +370 -0
- moosey_cms-0.1.0.dist-info/RECORD +18 -0
- moosey_cms-0.1.0.dist-info/WHEEL +4 -0
moosey_cms/helpers.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) 2026 Anthony Mugendi
|
|
3
|
+
|
|
4
|
+
This software is released under the MIT License.
|
|
5
|
+
https://opensource.org/licenses/MIT
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import frontmatter
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Dict, Any
|
|
12
|
+
from jinja2 import TemplateNotFound
|
|
13
|
+
from jinja2.sandbox import SandboxedEnvironment
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from slugify import slugify
|
|
16
|
+
from inflection import singularize
|
|
17
|
+
from pprint import pprint
|
|
18
|
+
from markupsafe import Markup
|
|
19
|
+
|
|
20
|
+
from .models import Dirs
|
|
21
|
+
from .md import parse_markdown
|
|
22
|
+
from .cache import cache, cache_fn
|
|
23
|
+
|
|
24
|
+
from .seo import seo_tags
|
|
25
|
+
from . import filters
|
|
26
|
+
|
|
27
|
+
# We initialize this once. It denies access to dangerous attributes like __class__
|
|
28
|
+
_safe_env = SandboxedEnvironment(
|
|
29
|
+
trim_blocks=True,
|
|
30
|
+
lstrip_blocks=True
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
cache_debug = True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_model(MyModel, data):
|
|
37
|
+
if not isinstance(data, MyModel):
|
|
38
|
+
MyModel(**data)
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
@cache_fn(debug=cache_debug)
|
|
42
|
+
def template_exists(templates, name: str) -> bool:
|
|
43
|
+
try:
|
|
44
|
+
templates.get_template(name)
|
|
45
|
+
return True
|
|
46
|
+
except TemplateNotFound as e:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@cache_fn(debug=cache_debug)
|
|
51
|
+
def get_secure_target(user_path: str, relative_to_path: Path) -> Path:
|
|
52
|
+
"""
|
|
53
|
+
Safely resolves a user-provided path against the relative_to_path.
|
|
54
|
+
|
|
55
|
+
1. Checks for null bytes (C-string exploit).
|
|
56
|
+
2. Resolves '..' and symlinks to finding the absolute path.
|
|
57
|
+
3. Ensures the resolved path is still inside relative_to_path.
|
|
58
|
+
"""
|
|
59
|
+
# Prevent Null Byte Injection
|
|
60
|
+
if "\0" in user_path:
|
|
61
|
+
raise ValueError("Security Alert: Null byte detected in path.")
|
|
62
|
+
|
|
63
|
+
# Convert to path and strip leading slashes to ensure it joins correctly
|
|
64
|
+
# e.g., "/etc/passwd" becomes "etc/passwd" (relative)
|
|
65
|
+
clean_path = user_path.strip("/")
|
|
66
|
+
|
|
67
|
+
# Create the naive path
|
|
68
|
+
naive_path = relative_to_path / clean_path
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
# Resolve: This converts symlinks and '..' to their real physical location
|
|
72
|
+
resolved_path = naive_path.resolve()
|
|
73
|
+
except OSError:
|
|
74
|
+
# Happens on Windows if path contains illegal chars like < > :
|
|
75
|
+
raise ValueError("Invalid characters in path.")
|
|
76
|
+
|
|
77
|
+
# The Firewall: strict check if the result is inside the jail
|
|
78
|
+
if not resolved_path.is_relative_to(relative_to_path):
|
|
79
|
+
raise ValueError(f"Path Traversal Attempt: {user_path}")
|
|
80
|
+
|
|
81
|
+
return resolved_path
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@cache_fn(debug=cache_debug)
|
|
85
|
+
def find_best_template(templates, path_str: str, is_index_file: bool = False) -> str:
|
|
86
|
+
"""
|
|
87
|
+
Determines the best template based on the path hierarchy.
|
|
88
|
+
path_str: The clean relative path (e.g. 'posts/stories/my-story')
|
|
89
|
+
is_index_file: True if we are rendering a directory index (e.g. 'posts/stories/index.md')
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
parts = [p for p in path_str.strip("/").split("/") if p]
|
|
93
|
+
|
|
94
|
+
# use index,html for home...
|
|
95
|
+
if len(parts)==0:
|
|
96
|
+
index_candidate = 'index.html'
|
|
97
|
+
if template_exists(templates, index_candidate):
|
|
98
|
+
return index_candidate
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# 1. Exact Match (Specific File Override)
|
|
102
|
+
# We skip this for index files, as their "Exact Match" is essentially
|
|
103
|
+
# the folder name check in step 2B.
|
|
104
|
+
if not is_index_file:
|
|
105
|
+
candidate = "/".join(parts) + ".html"
|
|
106
|
+
|
|
107
|
+
if template_exists(templates, candidate):
|
|
108
|
+
return candidate
|
|
109
|
+
|
|
110
|
+
# If we didn't find specific 'my-story.html',
|
|
111
|
+
# pop the filename so we start searching from parent 'stories'
|
|
112
|
+
if parts:
|
|
113
|
+
parts.pop()
|
|
114
|
+
|
|
115
|
+
# 2. Recursive Parent Search
|
|
116
|
+
while len(parts) > 0:
|
|
117
|
+
current_folder = parts[-1] # e.g. "stories"
|
|
118
|
+
parent_path = parts[:-1] # e.g. ["posts"]
|
|
119
|
+
|
|
120
|
+
# A. Singular Check (The "Item" Template)
|
|
121
|
+
# e.g. "posts/story.html"
|
|
122
|
+
# Only valid if we are NOT rendering a directory list (index file)
|
|
123
|
+
if not is_index_file:
|
|
124
|
+
singular_name = singularize(current_folder)
|
|
125
|
+
singular_candidate = "/".join(parent_path + [singular_name]) + ".html"
|
|
126
|
+
|
|
127
|
+
print('>>>>parts', parts)
|
|
128
|
+
|
|
129
|
+
if template_exists(templates, singular_candidate):
|
|
130
|
+
return singular_candidate
|
|
131
|
+
|
|
132
|
+
# B. Plural/Folder Check (The "Section" Template)
|
|
133
|
+
# e.g. "posts/stories.html"
|
|
134
|
+
plural_candidate = "/".join(parts) + ".html"
|
|
135
|
+
if template_exists(templates, plural_candidate):
|
|
136
|
+
return plural_candidate
|
|
137
|
+
|
|
138
|
+
# Traverse up
|
|
139
|
+
parts.pop()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# 3. Final Fallback
|
|
143
|
+
return "page.html"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@cache_fn(debug=cache_debug)
|
|
147
|
+
def parse_markdown_file(file):
|
|
148
|
+
data = frontmatter.load(file)
|
|
149
|
+
stats = file.stat()
|
|
150
|
+
# Last date
|
|
151
|
+
data.metadata["date"] = {
|
|
152
|
+
"updated": datetime.fromtimestamp(stats.st_mtime),
|
|
153
|
+
"created": datetime.fromtimestamp(stats.st_ctime),
|
|
154
|
+
}
|
|
155
|
+
# add slug
|
|
156
|
+
data.metadata["slug"] = slugify(str(file.stem))
|
|
157
|
+
|
|
158
|
+
data.html = parse_markdown(data.content)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
return data
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# We need the sandbox to have the same filters (fancy_date, etc) as the main app
|
|
165
|
+
def ensure_sandbox_filters(main_templates):
|
|
166
|
+
if not _safe_env.filters:
|
|
167
|
+
_safe_env.filters.update(main_templates.env.filters)
|
|
168
|
+
# Also copy globals if they are safe data (like site_data)
|
|
169
|
+
# BUT be careful not to copy 'request' or 'app' objects
|
|
170
|
+
safe_globals = {
|
|
171
|
+
k: v for k, v in main_templates.env.globals.items()
|
|
172
|
+
if k in ['site_data', 'site_code', 'mode'] # Whitelist specific globals
|
|
173
|
+
}
|
|
174
|
+
_safe_env.globals.update(safe_globals)
|
|
175
|
+
|
|
176
|
+
# template_render_content only in sandbox mode
|
|
177
|
+
@cache_fn(debug=cache_debug)
|
|
178
|
+
def template_render_content(templates, content, data, safe=True):
|
|
179
|
+
if not content:
|
|
180
|
+
return ""
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
# Sync filters/globals from the main app to our sandbox
|
|
184
|
+
ensure_sandbox_filters(templates)
|
|
185
|
+
|
|
186
|
+
# Use the SAFE environment, not the main one
|
|
187
|
+
template = _safe_env.from_string(content)
|
|
188
|
+
|
|
189
|
+
# Render
|
|
190
|
+
rendered = template.render(**data)
|
|
191
|
+
return Markup(rendered) if safe else rendered
|
|
192
|
+
except Exception as e:
|
|
193
|
+
print(f"⚠️ Template Rendering Error: {e}")
|
|
194
|
+
# Fallback: Return raw content if injection fails, rather than crashing
|
|
195
|
+
return content
|
|
196
|
+
|
|
197
|
+
@cache_fn(debug=cache_debug)
|
|
198
|
+
def get_directory_navigation(
|
|
199
|
+
physical_folder: Path, current_url: str, relative_to_path: Path
|
|
200
|
+
) -> List[Dict[str, Any]]:
|
|
201
|
+
"""
|
|
202
|
+
Scans the folder containing the current file to generate a sidebar menu.
|
|
203
|
+
"""
|
|
204
|
+
if not physical_folder.exists() or not physical_folder.is_dir():
|
|
205
|
+
return []
|
|
206
|
+
|
|
207
|
+
items = []
|
|
208
|
+
try:
|
|
209
|
+
# Iterate over files in the folder
|
|
210
|
+
for entry in sorted(
|
|
211
|
+
physical_folder.iterdir(), key=lambda x: (not x.is_dir(), x.name)
|
|
212
|
+
):
|
|
213
|
+
if entry.name.startswith("."):
|
|
214
|
+
continue # Skip hidden
|
|
215
|
+
|
|
216
|
+
# Skip self-reference if inside index
|
|
217
|
+
if entry.name == "index.md":
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
# if dir only list if it has an index.md
|
|
221
|
+
if entry.is_dir() and not (entry / 'index.md').exists() :
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# Build URL
|
|
226
|
+
try:
|
|
227
|
+
rel_path = entry.relative_to(relative_to_path)
|
|
228
|
+
# Strip .md for URL, keep pure for directories
|
|
229
|
+
url_slug = str(rel_path).replace(".md", "").replace("\\", "/")
|
|
230
|
+
entry_url = f"/{url_slug}"
|
|
231
|
+
except ValueError:
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
items.append(
|
|
235
|
+
{
|
|
236
|
+
"name": entry.stem.replace("-", " ").title(),
|
|
237
|
+
"url": entry_url,
|
|
238
|
+
"is_active": entry_url == current_url,
|
|
239
|
+
"is_dir": entry.is_dir(),
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
except OSError:
|
|
243
|
+
pass # Ignore permission errors
|
|
244
|
+
|
|
245
|
+
return items
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@cache_fn(debug=cache_debug)
|
|
249
|
+
def get_breadcrumbs(url_path: str) -> List[Dict[str, str]]:
|
|
250
|
+
parts = [p for p in url_path.strip("/").split("/") if p]
|
|
251
|
+
crumbs = [{"name": "Home", "url": "/"}]
|
|
252
|
+
current = ""
|
|
253
|
+
for p in parts:
|
|
254
|
+
current += f"/{p}"
|
|
255
|
+
crumbs.append({"name": p.replace("-", " ").title(), "url": current})
|
|
256
|
+
return crumbs
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) 2026 Anthony Mugendi
|
|
3
|
+
|
|
4
|
+
This software is released under the MIT License.
|
|
5
|
+
https://opensource.org/licenses/MIT
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from fastapi import FastAPI, Request, Response
|
|
10
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
11
|
+
from starlette.types import ASGIApp
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ScriptInjectorMiddleware(BaseHTTPMiddleware):
|
|
15
|
+
def __init__(self, app: ASGIApp, script: str):
|
|
16
|
+
super().__init__(app)
|
|
17
|
+
self.script = script
|
|
18
|
+
|
|
19
|
+
async def dispatch(self, request: Request, call_next):
|
|
20
|
+
# Process the request and get the response
|
|
21
|
+
response = await call_next(request)
|
|
22
|
+
|
|
23
|
+
# We only want to touch HTML pages, not JSON APIs or Images
|
|
24
|
+
content_type = response.headers.get("content-type", "")
|
|
25
|
+
# get content length
|
|
26
|
+
content_length = response.headers.get("content-length")
|
|
27
|
+
|
|
28
|
+
# Skip if not HTML
|
|
29
|
+
if "text/html" not in content_type:
|
|
30
|
+
return response
|
|
31
|
+
|
|
32
|
+
# Skip if too big (e.g. > 20KB) to prevent Memory DoS
|
|
33
|
+
if content_length and int(content_length) > 20 * 1024 :
|
|
34
|
+
return response
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Read the response body
|
|
38
|
+
# Note: Response body is a stream, we must consume it to modify it
|
|
39
|
+
response_body = [section async for section in response.body_iterator]
|
|
40
|
+
full_body = b"".join(response_body)
|
|
41
|
+
|
|
42
|
+
# Prepare the injection
|
|
43
|
+
# Encode the script to bytes
|
|
44
|
+
injection = self.script.encode("utf-8")
|
|
45
|
+
|
|
46
|
+
# Inject the script
|
|
47
|
+
# We look for the closing body tag
|
|
48
|
+
if b"</body>" in full_body:
|
|
49
|
+
full_body = full_body.replace(b"</body>", injection + b"</body>")
|
|
50
|
+
else:
|
|
51
|
+
# Fallback: Just append if no body tag found
|
|
52
|
+
full_body += injection
|
|
53
|
+
|
|
54
|
+
# Create a NEW Response object
|
|
55
|
+
# We cannot modify the existing response easily because Content-Length
|
|
56
|
+
# would be wrong. Creating a new one recalculates headers.
|
|
57
|
+
new_response = Response(
|
|
58
|
+
content=full_body,
|
|
59
|
+
status_code=response.status_code,
|
|
60
|
+
headers=dict(response.headers),
|
|
61
|
+
media_type=response.media_type,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Remove Content-Length so Starlette recalculates it automatically
|
|
65
|
+
if "content-length" in new_response.headers:
|
|
66
|
+
del new_response.headers["content-length"]
|
|
67
|
+
|
|
68
|
+
return new_response
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def inject_script_middleware(app, host, port):
|
|
72
|
+
# Your custom script to inject
|
|
73
|
+
package_root = Path(__file__).resolve().parent
|
|
74
|
+
javascript_file = package_root / "static" / "js" / "reload-script.js"
|
|
75
|
+
|
|
76
|
+
if not javascript_file.exists():
|
|
77
|
+
print(f"⚠️ CMS Error: Hot reload script not found at: {js_file}")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
with open(javascript_file, encoding="utf-8") as f:
|
|
81
|
+
content = f.read()
|
|
82
|
+
|
|
83
|
+
script_data = content.replace(
|
|
84
|
+
"{{host}}",
|
|
85
|
+
f"{host}:{port}",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Add the middleware
|
|
89
|
+
app.add_middleware(
|
|
90
|
+
ScriptInjectorMiddleware, script=f"<script>{script_data}</script>"
|
|
91
|
+
)
|
moosey_cms/main.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) 2026 Anthony Mugendi
|
|
3
|
+
|
|
4
|
+
This software is released under the MIT License.
|
|
5
|
+
https://opensource.org/licenses/MIT
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from inflection import singularize
|
|
11
|
+
from fastapi import APIRouter, Request
|
|
12
|
+
from pprint import pprint
|
|
13
|
+
|
|
14
|
+
from fastapi.templating import Jinja2Templates
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
from . import filters
|
|
18
|
+
from . import helpers
|
|
19
|
+
|
|
20
|
+
from .cache import clear_cache_on_file_change, clear_cache
|
|
21
|
+
from .file_watcher import start_watching
|
|
22
|
+
from .hot_reload_script import inject_script_middleware
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
from fastapi import WebSocket, WebSocketDisconnect
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConnectionManager:
|
|
29
|
+
def __init__(self):
|
|
30
|
+
self.active_connections: list[WebSocket] = []
|
|
31
|
+
|
|
32
|
+
async def connect(self, websocket: WebSocket):
|
|
33
|
+
await websocket.accept()
|
|
34
|
+
self.active_connections.append(websocket)
|
|
35
|
+
|
|
36
|
+
def disconnect(self, websocket: WebSocket):
|
|
37
|
+
if websocket in self.active_connections:
|
|
38
|
+
self.active_connections.remove(websocket)
|
|
39
|
+
|
|
40
|
+
async def broadcast(self, message: str):
|
|
41
|
+
# Iterate over a copy to avoid modification errors
|
|
42
|
+
for connection in self.active_connections[:]:
|
|
43
|
+
try:
|
|
44
|
+
await connection.send_text(message)
|
|
45
|
+
except Exception:
|
|
46
|
+
self.disconnect(connection)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
from .models import CMSConfig, Dirs, SiteCode, SiteData
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def init_cms(
|
|
53
|
+
app,
|
|
54
|
+
host: str,
|
|
55
|
+
port: int,
|
|
56
|
+
dirs: Dirs,
|
|
57
|
+
mode: str,
|
|
58
|
+
site_data: SiteData = {},
|
|
59
|
+
site_code: SiteCode = {},
|
|
60
|
+
):
|
|
61
|
+
|
|
62
|
+
# validate dirs inputs
|
|
63
|
+
CMSConfig(
|
|
64
|
+
host=host,
|
|
65
|
+
port=port,
|
|
66
|
+
dirs=dirs,
|
|
67
|
+
mode=mode,
|
|
68
|
+
site_data=site_data,
|
|
69
|
+
site_code=site_code,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# resolve paths
|
|
73
|
+
dirs = {k: p.resolve() for k, p in dirs.items()}
|
|
74
|
+
|
|
75
|
+
# create templates
|
|
76
|
+
# templates = Jinja2Templates(directory=str(dirs["templates"]))
|
|
77
|
+
templates = Jinja2Templates(directory=str(dirs["templates"]), extensions=[])
|
|
78
|
+
|
|
79
|
+
# Important for filters like seo to access them
|
|
80
|
+
app.state.site_code = site_code
|
|
81
|
+
app.state.site_data = site_data
|
|
82
|
+
app.state.mode = mode
|
|
83
|
+
|
|
84
|
+
# This ensures site_data is available in 404.html and base.html automatically
|
|
85
|
+
templates.env.globals["site_data"] = site_data
|
|
86
|
+
templates.env.globals["site_code"] = site_code
|
|
87
|
+
templates.env.globals["mode"] = mode
|
|
88
|
+
|
|
89
|
+
# Register all custom filters once
|
|
90
|
+
filters.register_filters(templates.env)
|
|
91
|
+
|
|
92
|
+
# We need to capture the current event loop to schedule the broadcast
|
|
93
|
+
loop = asyncio.get_event_loop()
|
|
94
|
+
|
|
95
|
+
# we want to watch even in production mode
|
|
96
|
+
# The logic is if one does a 'git pull' we want the site content to update
|
|
97
|
+
def on_change_callback(file_path, event_type):
|
|
98
|
+
# 1. Clear the cache (Sync)
|
|
99
|
+
clear_cache_on_file_change(file_path, event_type)
|
|
100
|
+
|
|
101
|
+
# 2. Trigger WebSocket Broadcast (Thread-safe Async call)
|
|
102
|
+
# This tells FastAPI loop to run the broadcast coroutine
|
|
103
|
+
if loop.is_running():
|
|
104
|
+
asyncio.run_coroutine_threadsafe(reloader.broadcast("reload"), loop)
|
|
105
|
+
|
|
106
|
+
# start watching dirs with the NEW combined callback
|
|
107
|
+
for d in dirs:
|
|
108
|
+
start_watching(dirs[d], on_change_callback)
|
|
109
|
+
|
|
110
|
+
reloader = None
|
|
111
|
+
# init manage hot reloading
|
|
112
|
+
if mode == "development":
|
|
113
|
+
reloader = ConnectionManager()
|
|
114
|
+
inject_script_middleware(app, host, port)
|
|
115
|
+
|
|
116
|
+
init_routes(app=app, dirs=dirs, templates=templates, reloader=reloader, mode=mode)
|
|
117
|
+
|
|
118
|
+
return app
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def init_routes(app, dirs: Dirs, templates, mode, reloader):
|
|
122
|
+
|
|
123
|
+
# init router
|
|
124
|
+
router = APIRouter()
|
|
125
|
+
|
|
126
|
+
# middleware to add security headers
|
|
127
|
+
@app.middleware("http")
|
|
128
|
+
async def add_security_headers(request: Request, call_next):
|
|
129
|
+
response = await call_next(request)
|
|
130
|
+
# Prevent MIME-sniffing
|
|
131
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
132
|
+
# Enable XSS protection in older browsers
|
|
133
|
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
134
|
+
# Prevent clickjacking
|
|
135
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
136
|
+
return response
|
|
137
|
+
|
|
138
|
+
# only init hot reload websocket route in dvt mode
|
|
139
|
+
if mode == "development":
|
|
140
|
+
|
|
141
|
+
@app.websocket("/ws/hot-reload")
|
|
142
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
143
|
+
await reloader.connect(websocket)
|
|
144
|
+
try:
|
|
145
|
+
while True:
|
|
146
|
+
# Keep connection open. We don't really care what the client sends
|
|
147
|
+
# but we must await receive to keep the socket alive.
|
|
148
|
+
await websocket.receive_text()
|
|
149
|
+
except WebSocketDisconnect:
|
|
150
|
+
reloader.disconnect(websocket)
|
|
151
|
+
|
|
152
|
+
@router.get("/{full_path:path}", include_in_schema=False)
|
|
153
|
+
async def catch_all(request: Request, full_path: str):
|
|
154
|
+
|
|
155
|
+
app = request.app
|
|
156
|
+
|
|
157
|
+
mode = app.state.mode
|
|
158
|
+
|
|
159
|
+
# if dvt mode, no caches
|
|
160
|
+
if mode == "development":
|
|
161
|
+
clear_cache()
|
|
162
|
+
|
|
163
|
+
# 1. Normalize Path
|
|
164
|
+
clean_path = full_path.strip("/")
|
|
165
|
+
if clean_path == "":
|
|
166
|
+
clean_path = "index"
|
|
167
|
+
|
|
168
|
+
# 2. Security: Resolve Path
|
|
169
|
+
try:
|
|
170
|
+
target_path_base = helpers.get_secure_target(
|
|
171
|
+
clean_path, relative_to_path=dirs["content"]
|
|
172
|
+
)
|
|
173
|
+
except ValueError:
|
|
174
|
+
# Path traversal detected or invalid chars
|
|
175
|
+
return templates.TemplateResponse(
|
|
176
|
+
"404.html", {"request": request}, status_code=404
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# 3. File Resolution Logic
|
|
180
|
+
target_file: Path = None
|
|
181
|
+
is_index: bool = False
|
|
182
|
+
|
|
183
|
+
if target_path_base.is_dir():
|
|
184
|
+
target_file = target_path_base / "index.md"
|
|
185
|
+
is_index = True
|
|
186
|
+
else:
|
|
187
|
+
try:
|
|
188
|
+
target_file = helpers.get_secure_target(
|
|
189
|
+
f"{clean_path}.md", relative_to_path=dirs["content"]
|
|
190
|
+
)
|
|
191
|
+
is_index = False
|
|
192
|
+
except ValueError:
|
|
193
|
+
return templates.TemplateResponse(
|
|
194
|
+
"404.html", {"request": request}, status_code=404
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# 4. Existence Check
|
|
198
|
+
if not target_file.exists():
|
|
199
|
+
return templates.TemplateResponse(
|
|
200
|
+
"404.html", {"request": request}, status_code=404
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# 5. Load Content
|
|
204
|
+
# We use utf-8 strictly.
|
|
205
|
+
html_content = None
|
|
206
|
+
|
|
207
|
+
# Base template data (globals will be merged by Jinja automatically)
|
|
208
|
+
template_data = {}
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
md_data = helpers.parse_markdown_file(target_file)
|
|
212
|
+
front_matter = md_data.metadata
|
|
213
|
+
|
|
214
|
+
# Merge front matter
|
|
215
|
+
template_data = {
|
|
216
|
+
**template_data,
|
|
217
|
+
**front_matter,
|
|
218
|
+
"site_data": app.state.site_data,
|
|
219
|
+
"site_code": app.state.site_code,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# Render jinja inside frontmatter strings
|
|
224
|
+
for k in front_matter:
|
|
225
|
+
if isinstance(front_matter[k], str):
|
|
226
|
+
front_matter[k] = helpers.template_render_content(
|
|
227
|
+
templates, front_matter[k], template_data, False
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
html_content = md_data.html
|
|
232
|
+
|
|
233
|
+
# Render jinja inside markdown body
|
|
234
|
+
html_content = helpers.template_render_content(
|
|
235
|
+
templates, html_content, template_data, False
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
except Exception as e:
|
|
239
|
+
print(f"Error rendering content: {e}")
|
|
240
|
+
return templates.TemplateResponse(
|
|
241
|
+
"404.html", {"request": request}, status_code=404
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# 6. Determine Context Data (Nav, Breadcrumbs)
|
|
245
|
+
nav_folder = target_file.parent
|
|
246
|
+
current_url = f"/{clean_path}" if clean_path != "index" else "/"
|
|
247
|
+
nav_items = helpers.get_directory_navigation(
|
|
248
|
+
physical_folder=nav_folder,
|
|
249
|
+
current_url=current_url,
|
|
250
|
+
relative_to_path=dirs["content"],
|
|
251
|
+
)
|
|
252
|
+
breadcrumbs = helpers.get_breadcrumbs(full_path)
|
|
253
|
+
|
|
254
|
+
# 7. Find Template
|
|
255
|
+
search_path = "" if clean_path == "index" else clean_path
|
|
256
|
+
template_name = helpers.find_best_template(
|
|
257
|
+
templates, search_path, is_index_file=is_index
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
template_data = {**template_data, **md_data}
|
|
261
|
+
|
|
262
|
+
# 8. Render
|
|
263
|
+
return templates.TemplateResponse(
|
|
264
|
+
template_name,
|
|
265
|
+
{
|
|
266
|
+
"app_state": request.app.state,
|
|
267
|
+
"request": request,
|
|
268
|
+
"content": html_content,
|
|
269
|
+
"title": template_data.get(
|
|
270
|
+
"title", clean_path.split("/")[-1].replace("-", " ").title()
|
|
271
|
+
),
|
|
272
|
+
"breadcrumbs": breadcrumbs,
|
|
273
|
+
"nav_items": nav_items,
|
|
274
|
+
"debug_template_used": template_name,
|
|
275
|
+
**template_data,
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
app.include_router(router, prefix="")
|
|
280
|
+
|
|
281
|
+
return router
|