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/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