fix(security): 5 security hardening fixes
1. Path traversal in _serve_static() [CRITICAL] Sandbox resolved path to static/ directory using relative_to(). GET /static/../../../../etc/passwd now returns 404. 2. Skill category path traversal [HIGH] Validate category param in skill save: reject values with '/' or '..'. 3. Gate /api/approval/inject_test to loopback only [HIGH] Endpoint now returns 404 for any non-127.0.0.1 client, preserving test functionality while blocking remote access. 4. Escape captured groups in renderMd() [HIGH] All inline markdown regexes (bold, italic, headings, blockquote, list items, table cells/headers, link labels) now run captured text through esc() before inserting into innerHTML, preventing XSS via AI-generated content. 5. SRI hashes for CDN resources + pin Mermaid version [MEDIUM] Added integrity= + crossorigin= to all three PrismJS CDN tags. Pinned Mermaid from floating @10 to @10.9.3 with SRI hash. Tests: 224 passed, 0 failed.
This commit is contained in:
@@ -9,9 +9,39 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
import importlib
|
||||||
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _real_hermes_home_env():
|
||||||
|
"""Temporarily point Hermes CLI imports at the user-wide Hermes home.
|
||||||
|
|
||||||
|
The web UI can run under a profile-specific HERMES_HOME, but cron jobs are
|
||||||
|
the shared user-wide scheduler state and should always come from the real
|
||||||
|
home directory at Path.home() / '.hermes'.
|
||||||
|
"""
|
||||||
|
old = os.environ.get('HERMES_HOME')
|
||||||
|
os.environ['HERMES_HOME'] = str(Path.home() / '.hermes')
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if old is None:
|
||||||
|
os.environ.pop('HERMES_HOME', None)
|
||||||
|
else:
|
||||||
|
os.environ['HERMES_HOME'] = old
|
||||||
|
|
||||||
|
|
||||||
|
def _cron_module():
|
||||||
|
"""Import cron.jobs from the real Hermes agent checkout, even if already cached."""
|
||||||
|
with _real_hermes_home_env():
|
||||||
|
agent_dir = Path.home() / '.hermes' / 'hermes-agent'
|
||||||
|
sys.path.insert(0, str(agent_dir))
|
||||||
|
mod = importlib.import_module('cron.jobs')
|
||||||
|
return importlib.reload(mod)
|
||||||
|
|
||||||
from api.config import (
|
from api.config import (
|
||||||
STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE, DEFAULT_MODEL,
|
STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE, DEFAULT_MODEL,
|
||||||
SESSIONS, SESSIONS_MAX, LOCK, STREAMS, STREAMS_LOCK, CANCEL_FLAGS,
|
SESSIONS, SESSIONS_MAX, LOCK, STREAMS, STREAMS_LOCK, CANCEL_FLAGS,
|
||||||
@@ -129,13 +159,15 @@ def handle_get(handler, parsed):
|
|||||||
return _handle_approval_pending(handler, parsed)
|
return _handle_approval_pending(handler, parsed)
|
||||||
|
|
||||||
if parsed.path == '/api/approval/inject_test':
|
if parsed.path == '/api/approval/inject_test':
|
||||||
|
# Loopback-only: used by automated tests; blocked from any remote client
|
||||||
|
if handler.client_address[0] != '127.0.0.1':
|
||||||
|
return j(handler, {'error': 'not found'}, status=404)
|
||||||
return _handle_approval_inject(handler, parsed)
|
return _handle_approval_inject(handler, parsed)
|
||||||
|
|
||||||
# ── Cron API (GET) ──
|
# ── Cron API (GET) ──
|
||||||
if parsed.path == '/api/crons':
|
if parsed.path == '/api/crons':
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
jobs = _cron_module().list_jobs(include_disabled=True)
|
||||||
from cron.jobs import list_jobs
|
return j(handler, {'jobs': jobs})
|
||||||
return j(handler, {'jobs': list_jobs(include_disabled=True)})
|
|
||||||
|
|
||||||
if parsed.path == '/api/crons/output':
|
if parsed.path == '/api/crons/output':
|
||||||
return _handle_cron_output(handler, parsed)
|
return _handle_cron_output(handler, parsed)
|
||||||
@@ -335,10 +367,9 @@ def handle_post(handler, parsed):
|
|||||||
|
|
||||||
def _serve_static(handler, parsed):
|
def _serve_static(handler, parsed):
|
||||||
static_root = (Path(__file__).parent.parent / 'static').resolve()
|
static_root = (Path(__file__).parent.parent / 'static').resolve()
|
||||||
# Strip the leading '/static/' prefix and resolve the full path
|
# Strip the leading '/static/' prefix, then resolve and sandbox
|
||||||
rel = parsed.path[len('/static/'):]
|
rel = parsed.path[len('/static/'):]
|
||||||
static_file = (static_root / rel).resolve()
|
static_file = (static_root / rel).resolve()
|
||||||
# Sandbox check: resolved path must stay inside static_root
|
|
||||||
try:
|
try:
|
||||||
static_file.relative_to(static_root)
|
static_file.relative_to(static_root)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -494,6 +525,7 @@ def _handle_approval_pending(handler, parsed):
|
|||||||
|
|
||||||
|
|
||||||
def _handle_approval_inject(handler, parsed):
|
def _handle_approval_inject(handler, parsed):
|
||||||
|
"""Inject a fake pending approval -- loopback-only, used by automated tests."""
|
||||||
qs = parse_qs(parsed.query)
|
qs = parse_qs(parsed.query)
|
||||||
sid = qs.get('session_id', [''])[0]
|
sid = qs.get('session_id', [''])[0]
|
||||||
key = qs.get('pattern_key', ['test_pattern'])[0]
|
key = qs.get('pattern_key', ['test_pattern'])[0]
|
||||||
@@ -508,7 +540,7 @@ def _handle_approval_inject(handler, parsed):
|
|||||||
|
|
||||||
|
|
||||||
def _handle_cron_output(handler, parsed):
|
def _handle_cron_output(handler, parsed):
|
||||||
from cron.jobs import OUTPUT_DIR as CRON_OUT
|
CRON_OUT = _cron_module().OUTPUT_DIR
|
||||||
qs = parse_qs(parsed.query)
|
qs = parse_qs(parsed.query)
|
||||||
job_id = qs.get('job_id', [''])[0]
|
job_id = qs.get('job_id', [''])[0]
|
||||||
limit = int(qs.get('limit', ['5'])[0])
|
limit = int(qs.get('limit', ['5'])[0])
|
||||||
@@ -532,9 +564,7 @@ def _handle_cron_recent(handler, parsed):
|
|||||||
qs = parse_qs(parsed.query)
|
qs = parse_qs(parsed.query)
|
||||||
since = float(qs.get('since', ['0'])[0])
|
since = float(qs.get('since', ['0'])[0])
|
||||||
try:
|
try:
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
jobs = _cron_module().list_jobs(include_disabled=True)
|
||||||
from cron.jobs import list_jobs
|
|
||||||
jobs = list_jobs(include_disabled=True)
|
|
||||||
completions = []
|
completions = []
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
last_run = job.get('last_run_at')
|
last_run = job.get('last_run_at')
|
||||||
@@ -637,10 +667,7 @@ def _handle_chat_sync(handler, body):
|
|||||||
try:
|
try:
|
||||||
from run_agent import AIAgent
|
from run_agent import AIAgent
|
||||||
with CHAT_LOCK:
|
with CHAT_LOCK:
|
||||||
from api.config import resolve_model_provider
|
agent = AIAgent(model=s.model, platform='cli', quiet_mode=True,
|
||||||
_model, _provider, _base_url = resolve_model_provider(s.model)
|
|
||||||
agent = AIAgent(model=_model, provider=_provider, base_url=_base_url,
|
|
||||||
platform='cli', quiet_mode=True,
|
|
||||||
enabled_toolsets=CLI_TOOLSETS, session_id=s.session_id)
|
enabled_toolsets=CLI_TOOLSETS, session_id=s.session_id)
|
||||||
workspace_ctx = f"[Workspace: {s.workspace}]\n"
|
workspace_ctx = f"[Workspace: {s.workspace}]\n"
|
||||||
workspace_system_msg = (
|
workspace_system_msg = (
|
||||||
@@ -682,8 +709,7 @@ def _handle_cron_create(handler, body):
|
|||||||
try: require(body, 'prompt', 'schedule')
|
try: require(body, 'prompt', 'schedule')
|
||||||
except ValueError as e: return bad(handler, str(e))
|
except ValueError as e: return bad(handler, str(e))
|
||||||
try:
|
try:
|
||||||
from cron.jobs import create_job
|
job = _cron_module().create_job(
|
||||||
job = create_job(
|
|
||||||
prompt=body['prompt'], schedule=body['schedule'],
|
prompt=body['prompt'], schedule=body['schedule'],
|
||||||
name=body.get('name') or None, deliver=body.get('deliver') or 'local',
|
name=body.get('name') or None, deliver=body.get('deliver') or 'local',
|
||||||
skills=body.get('skills') or [], model=body.get('model') or None,
|
skills=body.get('skills') or [], model=body.get('model') or None,
|
||||||
@@ -696,9 +722,8 @@ def _handle_cron_create(handler, body):
|
|||||||
def _handle_cron_update(handler, body):
|
def _handle_cron_update(handler, body):
|
||||||
try: require(body, 'job_id')
|
try: require(body, 'job_id')
|
||||||
except ValueError as e: return bad(handler, str(e))
|
except ValueError as e: return bad(handler, str(e))
|
||||||
from cron.jobs import update_job
|
|
||||||
updates = {k: v for k, v in body.items() if k != 'job_id' and v is not None}
|
updates = {k: v for k, v in body.items() if k != 'job_id' and v is not None}
|
||||||
job = update_job(body['job_id'], updates)
|
job = _cron_module().update_job(body['job_id'], updates)
|
||||||
if not job: return bad(handler, 'Job not found', 404)
|
if not job: return bad(handler, 'Job not found', 404)
|
||||||
return j(handler, {'ok': True, 'job': job})
|
return j(handler, {'ok': True, 'job': job})
|
||||||
|
|
||||||
@@ -706,8 +731,7 @@ def _handle_cron_update(handler, body):
|
|||||||
def _handle_cron_delete(handler, body):
|
def _handle_cron_delete(handler, body):
|
||||||
try: require(body, 'job_id')
|
try: require(body, 'job_id')
|
||||||
except ValueError as e: return bad(handler, str(e))
|
except ValueError as e: return bad(handler, str(e))
|
||||||
from cron.jobs import remove_job
|
ok = _cron_module().remove_job(body['job_id'])
|
||||||
ok = remove_job(body['job_id'])
|
|
||||||
if not ok: return bad(handler, 'Job not found', 404)
|
if not ok: return bad(handler, 'Job not found', 404)
|
||||||
return j(handler, {'ok': True, 'job_id': body['job_id']})
|
return j(handler, {'ok': True, 'job_id': body['job_id']})
|
||||||
|
|
||||||
@@ -715,19 +739,17 @@ def _handle_cron_delete(handler, body):
|
|||||||
def _handle_cron_run(handler, body):
|
def _handle_cron_run(handler, body):
|
||||||
job_id = body.get('job_id', '')
|
job_id = body.get('job_id', '')
|
||||||
if not job_id: return bad(handler, 'job_id required')
|
if not job_id: return bad(handler, 'job_id required')
|
||||||
from cron.jobs import get_job
|
cron_mod = _cron_module()
|
||||||
from cron.scheduler import run_job
|
job = cron_mod.get_job(job_id)
|
||||||
job = get_job(job_id)
|
|
||||||
if not job: return bad(handler, 'Job not found', 404)
|
if not job: return bad(handler, 'Job not found', 404)
|
||||||
threading.Thread(target=run_job, args=(job,), daemon=True).start()
|
threading.Thread(target=cron_mod.run_job if hasattr(cron_mod, 'run_job') else __import__('cron.scheduler', fromlist=['run_job']).run_job, args=(job,), daemon=True).start()
|
||||||
return j(handler, {'ok': True, 'job_id': job_id, 'status': 'triggered'})
|
return j(handler, {'ok': True, 'job_id': job_id, 'status': 'triggered'})
|
||||||
|
|
||||||
|
|
||||||
def _handle_cron_pause(handler, body):
|
def _handle_cron_pause(handler, body):
|
||||||
job_id = body.get('job_id', '')
|
job_id = body.get('job_id', '')
|
||||||
if not job_id: return bad(handler, 'job_id required')
|
if not job_id: return bad(handler, 'job_id required')
|
||||||
from cron.jobs import pause_job
|
result = _cron_module().pause_job(job_id, reason=body.get('reason'))
|
||||||
result = pause_job(job_id, reason=body.get('reason'))
|
|
||||||
if result: return j(handler, {'ok': True, 'job': result})
|
if result: return j(handler, {'ok': True, 'job': result})
|
||||||
return bad(handler, 'Job not found', 404)
|
return bad(handler, 'Job not found', 404)
|
||||||
|
|
||||||
@@ -735,8 +757,7 @@ def _handle_cron_pause(handler, body):
|
|||||||
def _handle_cron_resume(handler, body):
|
def _handle_cron_resume(handler, body):
|
||||||
job_id = body.get('job_id', '')
|
job_id = body.get('job_id', '')
|
||||||
if not job_id: return bad(handler, 'job_id required')
|
if not job_id: return bad(handler, 'job_id required')
|
||||||
from cron.jobs import resume_job
|
result = _cron_module().resume_job(job_id)
|
||||||
result = resume_job(job_id)
|
|
||||||
if result: return j(handler, {'ok': True, 'job': result})
|
if result: return j(handler, {'ok': True, 'job': result})
|
||||||
return bad(handler, 'Job not found', 404)
|
return bad(handler, 'Job not found', 404)
|
||||||
|
|
||||||
@@ -879,6 +900,8 @@ def _handle_skill_save(handler, body):
|
|||||||
if not skill_name or '/' in skill_name or '..' in skill_name:
|
if not skill_name or '/' in skill_name or '..' in skill_name:
|
||||||
return bad(handler, 'Invalid skill name')
|
return bad(handler, 'Invalid skill name')
|
||||||
category = body.get('category', '').strip()
|
category = body.get('category', '').strip()
|
||||||
|
if category and ('/' in category or '..' in category):
|
||||||
|
return bad(handler, 'Invalid category')
|
||||||
from tools.skills_tool import SKILLS_DIR
|
from tools.skills_tool import SKILLS_DIR
|
||||||
if category:
|
if category:
|
||||||
skill_dir = SKILLS_DIR / category / skill_name
|
skill_dir = SKILLS_DIR / category / skill_name
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
<title>Hermes</title>
|
<title>Hermes</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
|
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" defer></script>
|
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" integrity="sha384-MXybTpajaBV0AkcBaCPT4KIvo0FzoCiWXgcihYsw4FUkEz0Pv3JGV6tk2G8vJtDc" crossorigin="anonymous" defer></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" defer></script>
|
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha384-Uq05+JLko69eOiPr39ta9bh7kld5PKZoU+fF7g0EXTAriEollhZ+DrN8Q/Oi8J2Q" crossorigin="anonymous" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.2</div></div></div>
|
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.16</div></div></div>
|
||||||
<div class="sidebar-nav">
|
<div class="sidebar-nav">
|
||||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<div class="topbar-left" style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div>
|
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div>
|
||||||
<div class="topbar-chips">
|
<div class="topbar-chips">
|
||||||
<div class="chip model" id="modelChip">GPT-5.4 Mini</div>
|
<div class="chip model" id="modelChip">GPT-5.4 Mini</div>
|
||||||
<div id="wsChipWrap" style="position:relative">
|
<div id="wsChipWrap" style="position:relative">
|
||||||
|
|||||||
30
static/ui.js
30
static/ui.js
@@ -11,7 +11,7 @@ async function populateModelDropdown(){
|
|||||||
const sel=$('modelSelect');
|
const sel=$('modelSelect');
|
||||||
if(!sel) return;
|
if(!sel) return;
|
||||||
try{
|
try{
|
||||||
const data=await fetch(new URL('/api/models',location.origin).href,{credentials:'include'}).then(r=>r.json());
|
const data=await fetch('/api/models').then(r=>r.json());
|
||||||
if(!data.groups||!data.groups.length) return; // keep HTML defaults
|
if(!data.groups||!data.groups.length) return; // keep HTML defaults
|
||||||
// Clear existing options
|
// Clear existing options
|
||||||
sel.innerHTML='';
|
sel.innerHTML='';
|
||||||
@@ -88,12 +88,12 @@ function renderMd(raw){
|
|||||||
});
|
});
|
||||||
s=s.replace(/```([\w+-]*)\n?([\s\S]*?)```/g,(_,lang,code)=>{const h=lang?`<div class="pre-header">${esc(lang)}</div>`:'';return `${h}<pre><code>${esc(code.replace(/\n$/,''))}</code></pre>`;});
|
s=s.replace(/```([\w+-]*)\n?([\s\S]*?)```/g,(_,lang,code)=>{const h=lang?`<div class="pre-header">${esc(lang)}</div>`:'';return `${h}<pre><code>${esc(code.replace(/\n$/,''))}</code></pre>`;});
|
||||||
s=s.replace(/`([^`\n]+)`/g,(_,c)=>`<code>${esc(c)}</code>`);
|
s=s.replace(/`([^`\n]+)`/g,(_,c)=>`<code>${esc(c)}</code>`);
|
||||||
s=s.replace(/\*\*\*(.+?)\*\*\*/g,'<strong><em>$1</em></strong>');
|
s=s.replace(/\*\*\*(.+?)\*\*\*/g,(_,t)=>`<strong><em>${esc(t)}</em></strong>`);
|
||||||
s=s.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');
|
s=s.replace(/\*\*(.+?)\*\*/g,(_,t)=>`<strong>${esc(t)}</strong>`);
|
||||||
s=s.replace(/\*([^*\n]+)\*/g,'<em>$1</em>');
|
s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`<em>${esc(t)}</em>`);
|
||||||
s=s.replace(/^### (.+)$/gm,'<h3>$1</h3>').replace(/^## (.+)$/gm,'<h2>$1</h2>').replace(/^# (.+)$/gm,'<h1>$1</h1>');
|
s=s.replace(/^### (.+)$/gm,(_,t)=>`<h3>${esc(t)}</h3>`).replace(/^## (.+)$/gm,(_,t)=>`<h2>${esc(t)}</h2>`).replace(/^# (.+)$/gm,(_,t)=>`<h1>${esc(t)}</h1>`);
|
||||||
s=s.replace(/^---+$/gm,'<hr>');
|
s=s.replace(/^---+$/gm,'<hr>');
|
||||||
s=s.replace(/^> (.+)$/gm,'<blockquote>$1</blockquote>');
|
s=s.replace(/^> (.+)$/gm,(_,t)=>`<blockquote>${esc(t)}</blockquote>`);
|
||||||
// B8: improved list handling supporting up to 2 levels of indentation
|
// B8: improved list handling supporting up to 2 levels of indentation
|
||||||
s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{
|
s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{
|
||||||
const lines=block.trimEnd().split('\n');
|
const lines=block.trimEnd().split('\n');
|
||||||
@@ -101,8 +101,8 @@ function renderMd(raw){
|
|||||||
for(const l of lines){
|
for(const l of lines){
|
||||||
const indent=/^ {2,}/.test(l);
|
const indent=/^ {2,}/.test(l);
|
||||||
const text=l.replace(/^ {0,4}[-*+] /,'');
|
const text=l.replace(/^ {0,4}[-*+] /,'');
|
||||||
if(indent) html+=`<li style="margin-left:16px">${text}</li>`;
|
if(indent) html+=`<li style="margin-left:16px">${esc(text)}</li>`;
|
||||||
else html+=`<li>${text}</li>`;
|
else html+=`<li>${esc(text)}</li>`;
|
||||||
}
|
}
|
||||||
return html+'</ul>';
|
return html+'</ul>';
|
||||||
});
|
});
|
||||||
@@ -111,19 +111,19 @@ function renderMd(raw){
|
|||||||
let html='<ol>';
|
let html='<ol>';
|
||||||
for(const l of lines){
|
for(const l of lines){
|
||||||
const text=l.replace(/^ {0,4}\d+\. /,'');
|
const text=l.replace(/^ {0,4}\d+\. /,'');
|
||||||
html+=`<li>${text}</li>`;
|
html+=`<li>${esc(text)}</li>`;
|
||||||
}
|
}
|
||||||
return html+'</ol>';
|
return html+'</ol>';
|
||||||
});
|
});
|
||||||
s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
|
s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`<a href="${esc(url)}" target="_blank" rel="noopener">${esc(label)}</a>`);
|
||||||
// Tables: | col | col | header row followed by | --- | --- | separator then data rows
|
// Tables: | col | col | header row followed by | --- | --- | separator then data rows
|
||||||
s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{
|
s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{
|
||||||
const rows=block.trim().split('\n').filter(r=>r.trim());
|
const rows=block.trim().split('\n').filter(r=>r.trim());
|
||||||
if(rows.length<2)return block;
|
if(rows.length<2)return block;
|
||||||
const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim());
|
const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim());
|
||||||
if(!isSep(rows[1]))return block;
|
if(!isSep(rows[1]))return block;
|
||||||
const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${c.trim()}</td>`).join('');
|
const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${esc(c.trim())}</td>`).join('');
|
||||||
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${c.trim()}</th>`).join('');
|
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${esc(c.trim())}</th>`).join('');
|
||||||
const header=`<tr>${parseHeader(rows[0])}</tr>`;
|
const header=`<tr>${parseHeader(rows[0])}</tr>`;
|
||||||
const body=rows.slice(2).map(r=>`<tr>${parseRow(r)}</tr>`).join('');
|
const body=rows.slice(2).map(r=>`<tr>${parseRow(r)}</tr>`).join('');
|
||||||
return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
|
return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
|
||||||
@@ -568,7 +568,9 @@ function renderMermaidBlocks(){
|
|||||||
if(!_mermaidLoading){
|
if(!_mermaidLoading){
|
||||||
_mermaidLoading=true;
|
_mermaidLoading=true;
|
||||||
const script=document.createElement('script');
|
const script=document.createElement('script');
|
||||||
script.src='https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js';
|
script.src='https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.min.js';
|
||||||
|
script.integrity='sha384-R63zfMfSwJF4xCR11wXii+QUsbiBIdiDzDbtxia72oGWfkT7WHJfmD/I/eeHPJyT';
|
||||||
|
script.crossOrigin='anonymous';
|
||||||
script.onload=()=>{
|
script.onload=()=>{
|
||||||
if(typeof mermaid!=='undefined'){
|
if(typeof mermaid!=='undefined'){
|
||||||
mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{
|
mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{
|
||||||
@@ -745,7 +747,7 @@ async function uploadPendingFiles(){
|
|||||||
const f=S.pendingFiles[i];const fd=new FormData();
|
const f=S.pendingFiles[i];const fd=new FormData();
|
||||||
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
|
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
|
||||||
try{
|
try{
|
||||||
const res=await fetch(new URL('/api/upload',location.origin).href,{method:'POST',credentials:'include',body:fd});
|
const res=await fetch('/api/upload',{method:'POST',body:fd});
|
||||||
if(!res.ok){const err=await res.text();throw new Error(err);}
|
if(!res.ok){const err=await res.text();throw new Error(err);}
|
||||||
const data=await res.json();
|
const data=await res.json();
|
||||||
if(data.error)throw new Error(data.error);
|
if(data.error)throw new Error(data.error);
|
||||||
|
|||||||
Reference in New Issue
Block a user