pdf-tei-editor / fastapi_app /lib /plugins /plugin_tools.py
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
19.4 kB
"""
Utility functions for backend plugins
"""
import logging
import re
from pathlib import Path
from typing import Any
from fastapi_app.config import get_settings
logger = logging.getLogger(__name__)
def get_plugin_config(
config_key: str,
env_var: str,
default: Any = None,
value_type: str = "string"
) -> Any:
"""
Get plugin configuration value with env var fallback.
Priority: config.json > environment variable > default
Creates config key from env var if it doesn't exist.
Args:
config_key: Dot-notation config key (e.g., "plugin.local-sync.enabled")
env_var: Environment variable name
default: Default value if neither source has value
value_type: Type for validation ("string", "boolean", "number", "array")
Returns:
Configuration value
Example:
>>> enabled = get_plugin_config(
... "plugin.local-sync.enabled",
... "PLUGIN_LOCAL_SYNC_ENABLED",
... default=False,
... value_type="boolean"
... )
"""
from fastapi_app.lib.utils.config_utils import get_config
import os
config = get_config()
# Try to get from config
value = config.get(config_key)
if value is None:
# Check environment variable
env_value = os.environ.get(env_var)
if env_value is not None:
# Parse env value based on type
if value_type == "boolean":
value = env_value.lower() in ("true", "1", "yes")
elif value_type == "number":
value = int(env_value) if env_value.isdigit() else float(env_value)
elif value_type == "array":
import json
value = json.loads(env_value)
else:
value = env_value
# Create config key from env var
config.set(config_key, value)
else:
# Use default
value = default
if value is not None:
config.set(config_key, value)
return value
def _extract_sandbox_methods() -> list[dict[str, str]]:
"""
Extract public method signatures from PluginSandbox class.
Reads backend-plugin-sandbox.js and parses JSDoc comments and method signatures
to build a list of available public methods.
Returns:
List of dicts with 'name', 'params', and 'doc' keys
Example return:
[
{
'name': 'openDocument',
'params': ['stableId'],
'doc': 'Open a document by updating xml state and closing dialog'
},
...
]
"""
# Find the sandbox module file
sandbox_file = get_settings().project_root_dir / 'app' / 'src' / 'modules' / 'backend-plugin-sandbox.js'
if not sandbox_file.exists():
# Fallback to empty list if file not found
return []
content = sandbox_file.read_text(encoding='utf-8')
methods = []
# Pattern to match JSDoc comment followed by method definition
# Matches: /** ... */ async methodName(param1, param2) {
pattern = r'/\*\*\s*(.*?)\s*\*/\s*(?:async\s+)?(\w+)\s*\((.*?)\)\s*\{'
for match in re.finditer(pattern, content, re.DOTALL):
jsdoc = match.group(1)
method_name = match.group(2)
params_str = match.group(3)
# Skip constructor and private methods
if method_name == 'constructor' or method_name.startswith('_'):
continue
# Extract parameter names (ignore types and defaults)
params = []
if params_str.strip():
for param in params_str.split(','):
param = param.strip()
# Extract just the name (before = or :)
param_name = re.split(r'[=:]', param)[0].strip()
if param_name:
params.append(param_name)
# Extract first line of JSDoc as description
doc = ''
for line in jsdoc.split('\n'):
line = line.strip().lstrip('*').strip()
# Skip @param, @returns, etc.
if line and not line.startswith('@'):
doc = line
break
methods.append({
'name': method_name,
'params': params,
'doc': doc
})
return methods
def generate_sandbox_client_script() -> str:
"""
Generate JavaScript code that establishes connection with parent window
and provides sandbox API access.
Dynamically generates client methods based on PluginSandbox class definition.
Returns:
JavaScript code as string to be embedded in <script> tag
Usage:
In plugin route handler:
from fastapi_app.lib.plugins.plugin_tools import generate_sandbox_client_script
html = f'''
<html>
<head>
<script>{generate_sandbox_client_script()}</script>
</head>
<body>
<button onclick="sandbox.openDocumentAtLine('abc123', 42)">
Open at line 42
</button>
</body>
</html>
'''
"""
# Extract available methods
methods = _extract_sandbox_methods()
if not methods:
logger.warning("No sandbox methods extracted from PluginSandbox class. "
"Sandbox client will have no methods available.")
# Generate method wrappers
method_code = []
for method in methods:
params_str = ', '.join(method['params'])
method_code.append(f"""
/**
* {method['doc']}
* @param {{{', '.join(f'{p}' for p in method['params'])}}}
*/
{method['name']}({params_str}) {{
return callSandboxMethod('{method['name']}', {params_str});
}}""")
methods_js = ',\n'.join(method_code)
return f"""
// Sandbox client for inter-window communication
// Auto-generated from PluginSandbox class definition
(function() {{
'use strict';
// Determine parent window (iframe uses parent, popup uses opener)
const parentWindow = window.parent !== window ? window.parent : window.opener;
if (!parentWindow) {{
console.warn('SandboxClient: No parent window found. Page must be opened from within the application.');
// Create stub sandbox so callers get a descriptive error instead of "undefined"
const notAvailable = (method) => () => {{
throw new Error('Sandbox not available: this page must be opened from within the application (no parent window).');
}};
window.sandbox = new Proxy({{}}, {{ get: (_, prop) => notAvailable(prop) }});
return;
}}
let requestId = 0;
const pendingRequests = new Map();
// Listen for responses from parent
window.addEventListener('message', (event) => {{
if (!event.data || event.data.type !== 'SANDBOX_RESPONSE') {{
return;
}}
const {{ requestId: respId, result, error }} = event.data;
const pending = pendingRequests.get(respId);
if (!pending) return;
pendingRequests.delete(respId);
if (error) {{
pending.reject(new Error(error));
}} else {{
pending.resolve(result);
}}
}});
/**
* Call sandbox method in parent window
* @param {{string}} method - Sandbox method name
* @param {{...any}} args - Method arguments
* @returns {{Promise<any>}} Method result
*/
function callSandboxMethod(method, ...args) {{
return new Promise((resolve, reject) => {{
const reqId = requestId++;
pendingRequests.set(reqId, {{ resolve, reject }});
// Send command to parent (iframe or opener)
parentWindow.postMessage({{
type: 'SANDBOX_COMMAND',
method,
args,
requestId: reqId
}}, '*');
// Timeout after 10 seconds
setTimeout(() => {{
if (pendingRequests.has(reqId)) {{
pendingRequests.delete(reqId);
reject(new Error(`Request timeout: ${{method}}`));
}}
}}, 10000);
}});
}}
// Expose sandbox API with dynamically generated methods
window.sandbox = {{{methods_js}
}};
// SSE event forwarding support
(function() {{
const sseCallbacks = {{}};
// Listen for forwarded SSE events from parent
window.addEventListener('message', (event) => {{
if (!event.data || event.data.type !== 'SSE_EVENT') return;
const {{ subscriptionId, data }} = event.data;
const cbs = sseCallbacks[subscriptionId];
if (cbs) {{
cbs.forEach(cb => {{
try {{ cb(JSON.parse(data)); }} catch {{ cb(data); }}
}});
}}
}});
// Override subscribeSSE to accept a callback parameter
const origSubscribe = window.sandbox.subscribeSSE;
if (origSubscribe) {{
window.sandbox.subscribeSSE = async function(eventType, callback) {{
const subId = await origSubscribe.call(window.sandbox, eventType);
if (!sseCallbacks[subId]) sseCallbacks[subId] = [];
sseCallbacks[subId].push(callback);
return subId;
}};
}} else {{
console.error('SandboxClient: subscribeSSE method not available. Sandbox methods may not have been generated correctly.');
}}
// Override unsubscribeSSE to clean up local callbacks
const origUnsubscribe = window.sandbox.unsubscribeSSE;
if (origUnsubscribe) {{
window.sandbox.unsubscribeSSE = async function(subscriptionId) {{
delete sseCallbacks[subscriptionId];
return origUnsubscribe.call(window.sandbox, subscriptionId);
}};
}}
}})();
console.log('SandboxClient: Connected to parent window');
}})();
""".strip()
def wrap_html_with_sandbox_client(html_content: str) -> str:
"""
Wrap HTML content with sandbox client script.
Args:
html_content: HTML content (can be partial or complete document)
Returns:
Complete HTML document with sandbox client script
"""
script = generate_sandbox_client_script()
# If already complete document, inject script into head
if '<html' in html_content.lower():
# Insert before closing </head> or after <head>
if '</head>' in html_content:
return html_content.replace('</head>', f'<script>{script}</script>\n</head>', 1)
elif '<head>' in html_content:
return html_content.replace('<head>', f'<head>\n<script>{script}</script>', 1)
else:
# No head tag, add it
return html_content.replace('<html', f'<html>\n<head><script>{script}</script></head>', 1)
else:
# Partial HTML, wrap it
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script>{script}</script>
</head>
<body>
{html_content}
</body>
</html>"""
def load_plugin_html(
caller_file: str,
template_name: str,
inject_sandbox: bool = True,
) -> str:
"""
Load an HTML template from a plugin's ``static/`` directory (preferred) or
the legacy ``html/`` directory.
Looks for the template in ``static/`` first; falls back to ``html/`` for
backwards compatibility (deprecated, prefer ``static/``).
If *inject_sandbox* is True, the sandbox client script is injected into
the ``<head>`` via :func:`wrap_html_with_sandbox_client`.
Args:
caller_file: ``__file__`` of the calling module – used to locate the
plugin's ``static/`` (or ``html/``) directory.
template_name: File name inside ``static/`` (e.g. ``"view.html"``).
inject_sandbox: Inject the sandbox client script (default True).
Returns:
HTML string ready to be returned as ``HTMLResponse``.
Example::
from fastapi_app.lib.plugins.plugin_tools import load_plugin_html
@router.get("/view", response_class=HTMLResponse)
async def view(...):
html = load_plugin_html(__file__, "view.html")
return HTMLResponse(content=html)
"""
import warnings
plugin_dir = Path(caller_file).resolve().parent
# Preferred: static/
template_path = plugin_dir / "static" / template_name
if not template_path.exists():
# Fallback: html/ (deprecated)
legacy_path = plugin_dir / "html" / template_name
if legacy_path.exists():
warnings.warn(
f"Plugin HTML template found in deprecated 'html/' directory: {legacy_path}. "
"Move it to 'static/' instead.",
DeprecationWarning,
stacklevel=2,
)
template_path = legacy_path
else:
raise FileNotFoundError(
f"Plugin HTML template not found in 'static/' or 'html/': {template_name}"
)
html = template_path.read_text(encoding="utf-8")
if inject_sandbox:
html = wrap_html_with_sandbox_client(html)
return html
def escape_html(text: str) -> str:
"""
Escape HTML special characters.
Args:
text: Text to escape
Returns:
Escaped text safe for HTML insertion
"""
if not text:
return ""
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#x27;")
)
def validate_javascript_content(content: str, filename: str) -> tuple[bool, list[str]]:
"""
Validate JavaScript content for dangerous patterns.
Checks for patterns that could introduce security risks:
- Network access (fetch, XMLHttpRequest, WebSocket)
- Dynamic code execution (eval, Function constructor)
- Dynamic imports
- Storage access (cookies, localStorage, sessionStorage, indexedDB)
Args:
content: JavaScript source code
filename: Name of the file (for error messages)
Returns:
Tuple of (is_valid, list of warning messages)
Example:
>>> valid, warnings = validate_javascript_content(code, "my-extension.js")
>>> if not valid:
... for warning in warnings:
... logger.warning(warning)
"""
warnings = []
# Strip comments before checking so JSDoc/TypeScript annotations
# (e.g. @param {import('...')}) don't trigger false positives.
# Order matters: block comments first, then line comments.
code = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL)
code = re.sub(r'^\s*//[^\n]*', '', code, flags=re.MULTILINE)
# Patterns that indicate potential security issues
dangerous_patterns = [
(r'\bfetch\s*\(\s*(?![\'"`]/api/)', 'Network access via fetch() to external URLs'),
(r'\bXMLHttpRequest\b', 'Network access via XMLHttpRequest'),
(r'\bWebSocket\b', 'WebSocket connection'),
(r'\beval\s*\(', 'Dynamic code execution via eval()'),
(r'\bnew\s+Function\s*\(', 'Dynamic code execution via Function constructor'),
(r'(?<!@)\bimport\s*\(', 'Dynamic import'),
(r'\bwindow\s*\.\s*open\s*\(', 'Opening new windows'),
(r'\blocation\s*\.\s*href\s*=', 'Navigation/redirect'),
(r'\bdocument\s*\.\s*cookie\b', 'Cookie access'),
(r'\blocalStorage\b', 'localStorage access'),
(r'\bsessionStorage\b', 'sessionStorage access'),
(r'\bindexedDB\b', 'IndexedDB access'),
]
for pattern, description in dangerous_patterns:
if re.search(pattern, code):
warnings.append(f"{filename}: {description} detected")
return (len(warnings) == 0, warnings)
def generate_datatable_page(
title: str,
headers: list[str],
rows: list[list[str]],
table_id: str = "dataTable",
page_length: int = 25,
default_sort_col: int = 0,
default_sort_dir: str = "desc",
enable_sandbox_client: bool = True,
custom_css: str = "",
custom_js: str = "",
extra_content_before_table: str = ""
) -> str:
"""
Generate a complete HTML page with a DataTables-powered table.
Args:
title: Page title
headers: List of column headers
rows: List of rows, each row is a list of cell values (can include HTML)
table_id: HTML ID for the table element
page_length: Number of rows per page
default_sort_col: Column index to sort by default
default_sort_dir: Sort direction ("asc" or "desc")
enable_sandbox_client: Include sandbox client script for inter-window communication
custom_css: Additional CSS to include in <style> tag
custom_js: Additional JavaScript to run after DataTable initialization
extra_content_before_table: Additional HTML content to insert before the table
Returns:
Complete HTML document as string
"""
# Generate table rows HTML
rows_html = []
for row in rows:
cells = "".join(f"<td>{cell}</td>" for cell in row)
rows_html.append(f"<tr>{cells}</tr>")
# Build DataTable initialization options
datatable_options = {
"order": [[default_sort_col, default_sort_dir]],
"pageLength": page_length,
"language": {
"search": "Search:",
"lengthMenu": "Show _MENU_ entries",
"info": "Showing _START_ to _END_ of _TOTAL_ entries"
}
}
import json
datatable_options_json = json.dumps(datatable_options)
# Generate sandbox client script if requested
sandbox_script = ""
if enable_sandbox_client:
sandbox_script = f"<script>{generate_sandbox_client_script()}</script>"
# Build complete HTML
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{escape_html(title)}</title>
{sandbox_script}
<!-- DataTables CSS -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.7/css/jquery.dataTables.min.css">
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 20px;
background-color: #fff;
}}
h1 {{
margin-bottom: 20px;
color: #333;
}}
#{table_id} {{
font-size: 0.9em;
width: 100%;
}}
#{table_id} thead th {{
background-color: #f5f5f5;
font-weight: 600;
}}
#{table_id} tbody tr:nth-child(even) {{
background-color: #f9f9f9;
}}
#{table_id} tbody tr:hover {{
background-color: #f0f0f0;
}}
.dataTables_wrapper .dataTables_paginate .paginate_button {{
padding: 0.3em 0.8em;
}}
{custom_css}
</style>
</head>
<body>
<h1>{escape_html(title)}</h1>
{extra_content_before_table}
<table id="{table_id}" class="display stripe hover">
<thead>
<tr>
{''.join(f'<th>{escape_html(h)}</th>' for h in headers)}
</tr>
</thead>
<tbody>
{''.join(rows_html)}
</tbody>
</table>
<!-- jQuery (required for DataTables) -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- DataTables JS -->
<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
<script>
$(document).ready(function() {{
var table = $('#{table_id}').DataTable({datatable_options_json});
console.log('DataTable initialized');
{custom_js}
}});
</script>
</body>
</html>"""
return html