moosey-cms 0.3.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/helpers.py ADDED
@@ -0,0 +1,313 @@
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, Optional
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, frontmatter: Optional[dict] = None) -> str:
86
+ """
87
+ Determines the best template based on hierarchy or Frontmatter override.
88
+ """
89
+
90
+ # 0. Check Frontmatter Override First
91
+ if frontmatter and frontmatter.get('template'):
92
+ candidate = frontmatter.get('template')
93
+ # Ensure it ends with .html if user forgot
94
+ if not candidate.endswith('.html'):
95
+ candidate += '.html'
96
+
97
+ if template_exists(templates, candidate):
98
+ return candidate
99
+
100
+ parts = [p for p in path_str.strip("/").split("/") if p]
101
+
102
+ if len(parts) == 0:
103
+ index_candidate = 'index.html'
104
+ if template_exists(templates, index_candidate):
105
+ return index_candidate
106
+
107
+ # 1. Exact Match
108
+ if not is_index_file:
109
+ candidate = "/".join(parts) + ".html"
110
+ if template_exists(templates, candidate):
111
+ return candidate
112
+ if parts:
113
+ parts.pop()
114
+
115
+ # 2. Recursive Parent Search
116
+ while len(parts) > 0:
117
+ current_folder = parts[-1]
118
+ parent_path = parts[:-1]
119
+
120
+ # A. Singular Check
121
+ if not is_index_file:
122
+ singular_name = singularize(current_folder)
123
+ singular_candidate = "/".join(parent_path + [singular_name]) + ".html"
124
+ if template_exists(templates, singular_candidate):
125
+ return singular_candidate
126
+
127
+ # B. Plural/Folder Check
128
+ plural_candidate = "/".join(parts) + ".html"
129
+ if template_exists(templates, plural_candidate):
130
+ return plural_candidate
131
+
132
+ parts.pop()
133
+
134
+ # 3. Final Fallback
135
+ return "page.html"
136
+
137
+
138
+ @cache_fn(debug=cache_debug)
139
+ def parse_markdown_file(file):
140
+ data = frontmatter.load(file)
141
+ stats = file.stat()
142
+
143
+ # Ensure date metadata exists
144
+ if "date" not in data.metadata or not isinstance(data.metadata["date"], dict):
145
+ data.metadata["date"] = {}
146
+
147
+ data.metadata["date"]["updated"] = datetime.fromtimestamp(stats.st_mtime)
148
+ data.metadata["date"]["created"] = datetime.fromtimestamp(stats.st_ctime)
149
+ data.metadata["slug"] = slugify(str(file.stem))
150
+
151
+ data.html = parse_markdown(data.content)
152
+ return data
153
+
154
+
155
+ # We need the sandbox to have the same filters (fancy_date, etc) as the main app
156
+ def ensure_sandbox_filters(main_templates):
157
+ if not _safe_env.filters:
158
+ _safe_env.filters.update(main_templates.env.filters)
159
+ # Also copy globals if they are safe data (like site_data)
160
+ # BUT be careful not to copy 'request' or 'app' objects
161
+ safe_globals = {
162
+ k: v for k, v in main_templates.env.globals.items()
163
+ if k in ['site_data', 'site_code', 'mode'] # Whitelist specific globals
164
+ }
165
+ _safe_env.globals.update(safe_globals)
166
+
167
+ # template_render_content only in sandbox mode
168
+ @cache_fn(debug=cache_debug)
169
+ def template_render_content(templates, content, data, safe=True):
170
+ if not content: return ""
171
+
172
+ try:
173
+ # Sync filters/globals from the main app to our sandbox
174
+ ensure_sandbox_filters(templates)
175
+
176
+ # Use the SAFE environment, not the main one
177
+ template = _safe_env.from_string(content)
178
+
179
+ # Render
180
+ rendered = template.render(**data)
181
+ return Markup(rendered) if safe else rendered
182
+ except Exception as e:
183
+ print(f"⚠️ Template Rendering Error: {e}")
184
+ # Fallback: Return raw content if injection fails, rather than crashing
185
+ return content
186
+
187
+ @cache_fn(debug=cache_debug)
188
+ def get_directory_navigation(
189
+ physical_folder: Path, current_url: str, relative_to_path: Path, mode: str = "production"
190
+ ) -> List[Dict[str, Any]]:
191
+ """
192
+ Scans folder for sidebar menu. Supports advanced frontmatter features.
193
+ """
194
+ if not physical_folder.exists() or not physical_folder.is_dir():
195
+ return []
196
+
197
+ items = []
198
+ try:
199
+ for entry in physical_folder.iterdir():
200
+ if entry.name.startswith("."): continue
201
+ if entry.name == "index.md": continue
202
+ if entry.is_dir() and not (entry / 'index.md').exists(): continue
203
+
204
+ # Determine Metadata Source
205
+ meta_file = entry / 'index.md' if entry.is_dir() else entry
206
+
207
+ # Defaults
208
+ sort_order = 9999
209
+ display_title = entry.stem.replace("-", " ").title()
210
+ nav_group = None
211
+ external_url = None
212
+ is_visible = True
213
+ target = "_self"
214
+
215
+ try:
216
+ # Load minimal metadata
217
+ post = frontmatter.load(meta_file)
218
+ meta = post.metadata
219
+
220
+ # 1. Visibility & Draft Check
221
+ if meta.get('visible') is False:
222
+ is_visible = False
223
+
224
+ if meta.get('draft') is True and mode != 'development':
225
+ is_visible = False
226
+
227
+
228
+ if not is_visible:
229
+ continue
230
+
231
+ # 2. Ordering
232
+ if 'order' in meta: sort_order = int(meta['order'])
233
+
234
+ # 3. Titles & Grouping
235
+ if 'nav_title' in meta: display_title = meta['nav_title']
236
+ elif 'title' in meta: display_title = meta['title']
237
+
238
+ nav_group = meta.get('group') or ""
239
+
240
+ # 4. External Links
241
+ if 'external_link' in meta:
242
+ external_url = meta['external_link']
243
+ target = "_blank"
244
+ elif 'redirect' in meta:
245
+ external_url = meta['redirect']
246
+
247
+ except Exception:
248
+ pass
249
+
250
+ # Build URL
251
+ if external_url:
252
+ entry_url = external_url
253
+ is_active = False # External links are never 'active' page
254
+ else:
255
+ try:
256
+ rel_path = entry.relative_to(relative_to_path)
257
+ url_slug = str(rel_path).replace(".md", "").replace("\\", "/")
258
+ entry_url = f"/{url_slug}"
259
+ is_active = (entry_url == current_url)
260
+ except ValueError:
261
+ continue
262
+
263
+ items.append({
264
+ "name": display_title,
265
+ "url": entry_url,
266
+ "is_active": is_active,
267
+ "is_dir": entry.is_dir(),
268
+ "order": sort_order,
269
+ "group": nav_group,
270
+ "target": target
271
+ })
272
+
273
+ # Sorting: order first, then Name
274
+ # items.sort(key=lambda x: (x['order'], x['name']))
275
+ group_min_orders = {}
276
+
277
+ for item in items:
278
+ g = item['group']
279
+ w = item['order']
280
+ # If we haven't seen this group, or if this item is lighter (more important)
281
+ if g not in group_min_orders or w < group_min_orders[g]:
282
+ group_min_orders[g] = w
283
+
284
+ # 2. Sort the list with a Tuple Key
285
+ items.sort(key=lambda x: (
286
+ # Primary: Group order (Groups with important items float to top)
287
+ group_min_orders[x['group']],
288
+
289
+ # Secondary: Group Name (Keep groups clustered together)
290
+ x['group'],
291
+
292
+ # Tertiary: Item order (Sort items inside the group)
293
+ x['order'],
294
+
295
+ # Quaternary: Item Name (Alphabetical fallback)
296
+ x['name']
297
+ ))
298
+
299
+ except OSError:
300
+ pass
301
+
302
+ return items
303
+
304
+
305
+ @cache_fn(debug=cache_debug)
306
+ def get_breadcrumbs(url_path: str) -> List[Dict[str, str]]:
307
+ parts = [p for p in url_path.strip("/").split("/") if p]
308
+ crumbs = [{"name": "Home", "url": "/"}]
309
+ current = ""
310
+ for p in parts:
311
+ current += f"/{p}"
312
+ crumbs.append({"name": p.replace("-", " ").title(), "url": current})
313
+ 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,283 @@
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, 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
+ ):
60
+
61
+ # validate dirs inputs
62
+ CMSConfig(
63
+ host=host,
64
+ port=port,
65
+ dirs=dirs,
66
+ mode=mode,
67
+ site_data=site_data
68
+ )
69
+
70
+ # resolve paths
71
+ dirs = {k: p.resolve() for k, p in dirs.items()}
72
+
73
+ # create templates
74
+ # templates = Jinja2Templates(directory=str(dirs["templates"]))
75
+ templates = Jinja2Templates(directory=str(dirs["templates"]), extensions=[])
76
+
77
+ # Important for filters like seo to access them
78
+ app.state.site_data = site_data
79
+ app.state.mode = mode
80
+
81
+ # This ensures site_data is available in 404.html and base.html automatically
82
+ templates.env.globals["site_data"] = site_data
83
+ templates.env.globals["mode"] = mode
84
+
85
+ # Register all custom filters once
86
+ filters.register_filters(templates.env)
87
+
88
+ # We need to capture the current event loop to schedule the broadcast
89
+ loop = asyncio.get_event_loop()
90
+
91
+ # we want to watch even in production mode
92
+ # The logic is if one does a 'git pull' we want the site content to update
93
+ def on_change_callback(file_path, event_type):
94
+ # 1. Clear the cache (Sync)
95
+ clear_cache_on_file_change(file_path, event_type)
96
+
97
+ # 2. Trigger WebSocket Broadcast (Thread-safe Async call)
98
+ # This tells FastAPI loop to run the broadcast coroutine
99
+ if loop.is_running():
100
+ asyncio.run_coroutine_threadsafe(reloader.broadcast("reload"), loop)
101
+
102
+ # start watching dirs with the NEW combined callback
103
+ for d in dirs:
104
+ start_watching(dirs[d], on_change_callback)
105
+
106
+ reloader = None
107
+ # init manage hot reloading
108
+ if mode == "development":
109
+ reloader = ConnectionManager()
110
+ inject_script_middleware(app, host, port)
111
+
112
+ init_routes(app=app, dirs=dirs, templates=templates, reloader=reloader, mode=mode)
113
+
114
+ return app
115
+
116
+
117
+ def init_routes(app, dirs: Dirs, templates, mode, reloader):
118
+
119
+ # init router
120
+ router = APIRouter()
121
+
122
+ # middleware to add security headers
123
+ @app.middleware("http")
124
+ async def add_security_headers(request: Request, call_next):
125
+ response = await call_next(request)
126
+ # Prevent MIME-sniffing
127
+ response.headers["X-Content-Type-Options"] = "nosniff"
128
+ # Enable XSS protection in older browsers
129
+ response.headers["X-XSS-Protection"] = "1; mode=block"
130
+ # Prevent clickjacking
131
+ response.headers["X-Frame-Options"] = "DENY"
132
+ return response
133
+
134
+ # only init hot reload websocket route in dvt mode
135
+ if mode == "development":
136
+
137
+ @app.websocket("/ws/hot-reload")
138
+ async def websocket_endpoint(websocket: WebSocket):
139
+ await reloader.connect(websocket)
140
+ try:
141
+ while True:
142
+ # Keep connection open. We don't really care what the client sends
143
+ # but we must await receive to keep the socket alive.
144
+ await websocket.receive_text()
145
+ except WebSocketDisconnect:
146
+ reloader.disconnect(websocket)
147
+
148
+ @router.get("/{full_path:path}", include_in_schema=False)
149
+ async def catch_all(request: Request, full_path: str):
150
+
151
+ app = request.app
152
+
153
+ mode = app.state.mode
154
+
155
+ # if dvt mode, no caches
156
+ if mode == "development":
157
+ clear_cache()
158
+
159
+ # 1. Normalize Path
160
+ clean_path = full_path.strip("/")
161
+ if clean_path == "":
162
+ clean_path = "index"
163
+
164
+ # 2. Security: Resolve Path
165
+ try:
166
+ target_path_base = helpers.get_secure_target(
167
+ clean_path, relative_to_path=dirs["content"]
168
+ )
169
+ except ValueError:
170
+ # Path traversal detected or invalid chars
171
+ return templates.TemplateResponse(
172
+ "404.html", {"request": request}, status_code=404
173
+ )
174
+
175
+ # 3. File Resolution Logic
176
+ target_file: Path = None
177
+ is_index: bool = False
178
+
179
+ if target_path_base.is_dir():
180
+ target_file = target_path_base / "index.md"
181
+ is_index = True
182
+ else:
183
+ try:
184
+ target_file = helpers.get_secure_target(
185
+ f"{clean_path}.md", relative_to_path=dirs["content"]
186
+ )
187
+ is_index = False
188
+ except ValueError:
189
+ return templates.TemplateResponse(
190
+ "404.html", {"request": request}, status_code=404
191
+ )
192
+
193
+ # 4. Existence Check
194
+ if not target_file.exists():
195
+ return templates.TemplateResponse(
196
+ "404.html", {"request": request}, status_code=404
197
+ )
198
+
199
+ # 5. Load Content
200
+ # We use utf-8 strictly.
201
+ html_content = None
202
+
203
+ # Base template data (globals will be merged by Jinja automatically)
204
+ template_data = {}
205
+
206
+ try:
207
+ md_data = helpers.parse_markdown_file(target_file)
208
+ front_matter = md_data.metadata
209
+
210
+ # never render drafts in production
211
+ if front_matter.get("draft") is True and mode != "development":
212
+ return templates.TemplateResponse(
213
+ "404.html", {"request": request}, status_code=404
214
+ )
215
+
216
+ # Merge front matter
217
+ template_data = {
218
+ **template_data,
219
+ **front_matter,
220
+ "site_data": app.state.site_data
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
+ html_content = md_data.html
231
+
232
+ # Render jinja inside markdown body
233
+ html_content = helpers.template_render_content(
234
+ templates, html_content, template_data, False
235
+ )
236
+
237
+ except Exception as e:
238
+ print(f"Error rendering content: {e}")
239
+ return templates.TemplateResponse(
240
+ "404.html", {"request": request}, status_code=404
241
+ )
242
+
243
+ # 6. Determine Context Data (Nav, Breadcrumbs)
244
+ nav_folder = target_file.parent
245
+ current_url = f"/{clean_path}" if clean_path != "index" else "/"
246
+ nav_items = helpers.get_directory_navigation(
247
+ physical_folder=nav_folder,
248
+ current_url=current_url,
249
+ relative_to_path=dirs["content"],
250
+ mode=mode,
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, frontmatter=front_matter
258
+ )
259
+
260
+ template_data = {**template_data, **md_data}
261
+
262
+ # pprint(nav_items)
263
+
264
+ # 8. Render
265
+ return templates.TemplateResponse(
266
+ template_name,
267
+ {
268
+ "app_state": request.app.state,
269
+ "request": request,
270
+ "content": html_content,
271
+ "title": template_data.get(
272
+ "title", clean_path.split("/")[-1].replace("-", " ").title()
273
+ ),
274
+ "breadcrumbs": breadcrumbs,
275
+ "nav_items": nav_items,
276
+ "debug_template_used": template_name,
277
+ **template_data,
278
+ },
279
+ )
280
+
281
+ app.include_router(router, prefix="")
282
+
283
+ return router