fix: security hardening — self-hosted JS, XSS protection, SSRF blocking

- Replace all CDN script tags (marked, plotly) with self-hosted static files
- Add DOMPurify for sanitizing markdown-rendered HTML
- Add escapeHtml() helper to base.html for all innerHTML operations
- Sanitize dynamic data in innerHTML across 13 templates
- Add security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
- Add SSRF protection to proposal intake URL fetcher (block private/loopback IPs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 04:47:32 +01:00
parent d1a20fa02e
commit f8ed2b83e9
18 changed files with 94 additions and 42 deletions

View File

@@ -12,10 +12,13 @@ Usage:
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import ipaddress
import json import json
import re import re
import socket
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse
from dotenv import load_dotenv from dotenv import load_dotenv
# Load .env from project root (same pattern as analyzer.py) # Load .env from project root (same pattern as analyzer.py)
@@ -108,8 +111,26 @@ class ProposalIntake:
) )
raise SystemExit(1) raise SystemExit(1)
@staticmethod
def _is_private_url(url: str) -> bool:
"""Block requests to private/loopback IP ranges (SSRF protection)."""
try:
hostname = urlparse(url).hostname
if not hostname:
return True
addr = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
for _, _, _, _, sockaddr in addr:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return True
except (socket.gaierror, ValueError, OSError):
return True
return False
def fetch_url(self, url: str) -> str: def fetch_url(self, url: str) -> str:
"""Fetch a URL and return its text content (best-effort).""" """Fetch a URL and return its text content (best-effort)."""
if self._is_private_url(url):
return f"[Blocked: private/internal URL {url}]"
try: try:
resp = httpx.get(url, follow_redirects=True, timeout=30, resp = httpx.get(url, follow_redirects=True, timeout=30,
headers={"User-Agent": "IETF-Draft-Analyzer/1.0"}) headers={"User-Agent": "IETF-Draft-Analyzer/1.0"})

View File

@@ -67,7 +67,10 @@ def create_app(dev: bool = False) -> Flask:
g.start_time = time.time() g.start_time = time.time()
@application.after_request @application.after_request
def log_request(response): def set_security_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
if hasattr(g, "start_time"): if hasattr(g, "start_time"):
duration = (time.time() - g.start_time) * 1000 duration = (time.time() - g.start_time) * 1000
logger = logging.getLogger("webui") logger = logging.getLogger("webui")

6
src/webui/static/js/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
src/webui/static/js/purify.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -381,7 +381,7 @@ function selectGap(gapId) {
document.getElementById('detailPanel').classList.remove('hidden'); document.getElementById('detailPanel').classList.remove('hidden');
document.getElementById('detailTitle').innerHTML = document.getElementById('detailTitle').innerHTML =
`<span class="severity-${gap.severity}">GAP:</span> ${gap.topic}`; `<span class="severity-${escapeHtml(gap.severity)}">GAP:</span> ${escapeHtml(gap.topic)}`;
// Components in same layer (what exists nearby) // Components in same layer (what exists nearby)
const nearby = COMPONENTS.filter(c => c.layer === gap.layer); const nearby = COMPONENTS.filter(c => c.layer === gap.layer);
@@ -409,10 +409,10 @@ function selectGap(gapId) {
</div> </div>
<div class="text-xs font-semibold text-slate-400 mb-1">What's Missing</div> <div class="text-xs font-semibold text-slate-400 mb-1">What's Missing</div>
<div class="text-xs text-slate-300 leading-relaxed mb-3">${gap.description}</div> <div class="text-xs text-slate-300 leading-relaxed mb-3">${escapeHtml(gap.description)}</div>
<div class="text-xs font-semibold text-slate-400 mb-1">Evidence</div> <div class="text-xs font-semibold text-slate-400 mb-1">Evidence</div>
<div class="text-xs text-slate-400 leading-relaxed mb-3 p-2 rounded bg-slate-800/60 border border-slate-700/30">${gap.evidence || 'No evidence recorded'}</div> <div class="text-xs text-slate-400 leading-relaxed mb-3 p-2 rounded bg-slate-800/60 border border-slate-700/30">${escapeHtml(gap.evidence || 'No evidence recorded')}</div>
<div class="text-xs font-semibold text-slate-400 mb-1">Priority</div> <div class="text-xs font-semibold text-slate-400 mb-1">Priority</div>
<div class="text-xs text-slate-300 mb-3">${sevLabel[gap.severity] || gap.severity}</div> <div class="text-xs text-slate-300 mb-3">${sevLabel[gap.severity] || gap.severity}</div>

View File

@@ -197,7 +197,7 @@ function synthesizeAnswer() {
<h2 class="text-lg font-semibold text-white">AI Answer</h2> <h2 class="text-lg font-semibold text-white">AI Answer</h2>
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-900/30 text-blue-400 border border-blue-800/30">just generated</span> <span class="text-xs px-2 py-0.5 rounded-full bg-blue-900/30 text-blue-400 border border-blue-800/30">just generated</span>
</div> </div>
<div class="text-slate-300 text-sm leading-relaxed whitespace-pre-line">${data.answer}</div> <div class="text-slate-300 text-sm leading-relaxed whitespace-pre-line">${escapeHtml(data.answer)}</div>
</div>`; </div>`;
} }
}) })

View File

@@ -425,8 +425,8 @@ const network = {{ network | tojson }};
const moreCount = (d.drafts || []).length > 5 ? `<div class="text-slate-500 mt-1">+${d.drafts.length - 5} more</div>` : ''; const moreCount = (d.drafts || []).length > 5 ? `<div class="text-slate-500 mt-1">+${d.drafts.length - 5} more</div>` : '';
tooltip.innerHTML = ` tooltip.innerHTML = `
<div class="font-semibold text-white mb-1">${d.name}</div> <div class="font-semibold text-white mb-1">${escapeHtml(d.name)}</div>
<div class="text-slate-400 text-xs mb-2">${d.org || 'Unknown org'}</div> <div class="text-slate-400 text-xs mb-2">${escapeHtml(d.org || 'Unknown org')}</div>
<div class="flex gap-4 text-xs mb-2"> <div class="flex gap-4 text-xs mb-2">
<span><span class="text-blue-400 font-medium">${d.draft_count}</span> drafts</span> <span><span class="text-blue-400 font-medium">${d.draft_count}</span> drafts</span>
<span>avg <span class="text-emerald-400 font-medium">${d.avg_score}</span></span> <span>avg <span class="text-emerald-400 font-medium">${d.avg_score}</span></span>

View File

@@ -5,8 +5,23 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}IETF Draft Analyzer{% endblock %}</title> <title>{% block title %}IETF Draft Analyzer{% endblock %}</title>
<script src="/static/js/tailwind.js"></script> <script src="/static/js/tailwind.js"></script>
<script src="/static/js/purify.min.js"></script>
<link rel="stylesheet" href="/static/css/fonts.css"> <link rel="stylesheet" href="/static/css/fonts.css">
<script> <script>
// Global HTML escape helper for safe innerHTML usage
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Safe markdown rendering: marked + DOMPurify
function safeMarkdown(md) {
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
return DOMPurify.sanitize(marked.parse(md || ''));
}
return escapeHtml(md || '');
}
tailwind.config = { tailwind.config = {
darkMode: 'class', darkMode: 'class',
theme: { theme: {

View File

@@ -135,10 +135,11 @@
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="/static/js/marked.min.js"></script>
<script src="/static/js/purify.min.js"></script>
<script> <script>
const rawMd = {{ draft.content_md | tojson }}; const rawMd = {{ draft.content_md | tojson }};
document.getElementById('renderedView').innerHTML = marked.parse(rawMd); document.getElementById('renderedView').innerHTML = DOMPurify.sanitize(marked.parse(rawMd));
function toggleView(view) { function toggleView(view) {
const rendered = document.getElementById('renderedView'); const rendered = document.getElementById('renderedView');

View File

@@ -135,7 +135,8 @@
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="/static/js/marked.min.js"></script>
<script src="/static/js/purify.min.js"></script>
<script> <script>
let rawMarkdown = ''; let rawMarkdown = '';
@@ -186,7 +187,7 @@ document.getElementById('generateForm').addEventListener('submit', async (e) =>
tagsList.appendChild(span); tagsList.appendChild(span);
}); });
document.getElementById('markdownPreview').innerHTML = marked.parse(data.content); document.getElementById('markdownPreview').innerHTML = DOMPurify.sanitize(marked.parse(data.content));
document.getElementById('previewLoading').classList.add('hidden'); document.getElementById('previewLoading').classList.add('hidden');
document.getElementById('previewContent').classList.remove('hidden'); document.getElementById('previewContent').classList.remove('hidden');
document.getElementById('previewActions').classList.remove('hidden'); document.getElementById('previewActions').classList.remove('hidden');

View File

@@ -5,7 +5,7 @@
{% block extra_head %} {% block extra_head %}
<script src="/static/js/d3.v7.min.js"></script> <script src="/static/js/d3.v7.min.js"></script>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script> <script src="/static/js/plotly.min.js"></script>
<style> <style>
#citationSvg { #citationSvg {
width: 100%; width: 100%;
@@ -462,7 +462,7 @@ document.querySelectorAll('.tab-btn').forEach(btn => {
<td class="px-4 py-2.5 text-slate-500 text-xs">${i + 1}</td> <td class="px-4 py-2.5 text-slate-500 text-xs">${i + 1}</td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<a href="https://www.rfc-editor.org/rfc/rfc${parseInt(rfc.ref_id)}" target="_blank" rel="noopener" <a href="https://www.rfc-editor.org/rfc/rfc${parseInt(rfc.ref_id)}" target="_blank" rel="noopener"
class="text-orange-400 hover:text-orange-300 transition font-medium text-sm">${rfc.title}</a> class="text-orange-400 hover:text-orange-300 transition font-medium text-sm">${escapeHtml(rfc.title)}</a>
</td> </td>
<td class="px-4 py-2.5 text-right"> <td class="px-4 py-2.5 text-right">
<span class="px-2 py-0.5 rounded-full text-xs font-medium <span class="px-2 py-0.5 rounded-full text-xs font-medium
@@ -540,9 +540,9 @@ document.querySelectorAll('.tab-btn').forEach(btn => {
node.on('mouseover', function(event, d) { node.on('mouseover', function(event, d) {
const typeLabel = d.type === 'rfc' ? 'RFC' : d.type === 'bcp' ? 'BCP' : 'Draft'; const typeLabel = d.type === 'rfc' ? 'RFC' : d.type === 'bcp' ? 'BCP' : 'Draft';
const catLine = d.category ? `<div class="text-slate-500 text-xs mb-1">${d.category}</div>` : ''; const catLine = d.category ? `<div class="text-slate-500 text-xs mb-1">${escapeHtml(d.category)}</div>` : '';
tooltip.innerHTML = ` tooltip.innerHTML = `
<div class="font-semibold text-white mb-1">${d.title}</div> <div class="font-semibold text-white mb-1">${escapeHtml(d.title)}</div>
${catLine} ${catLine}
<div class="flex gap-4 text-xs"> <div class="flex gap-4 text-xs">
<span class="text-slate-400">${typeLabel}</span> <span class="text-slate-400">${typeLabel}</span>

View File

@@ -211,12 +211,12 @@ async function runComparison() {
}); });
const data = await resp.json(); const data = await resp.json();
if (data.error) { if (data.error) {
result.innerHTML = '<div class="text-red-400">Error: ' + data.error + '</div>'; result.innerHTML = '<div class="text-red-400">Error: ' + escapeHtml(data.error) + '</div>';
} else { } else {
result.innerHTML = '<div class="text-slate-300 whitespace-pre-line leading-relaxed">' + data.text + '</div>'; result.innerHTML = '<div class="text-slate-300 whitespace-pre-line leading-relaxed">' + escapeHtml(data.text) + '</div>';
} }
} catch (e) { } catch (e) {
result.innerHTML = '<div class="text-red-400">Error: ' + e.message + '</div>'; result.innerHTML = '<div class="text-red-400">Error: ' + escapeHtml(e.message) + '</div>';
} }
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Regenerate'; btn.textContent = 'Regenerate';

View File

@@ -440,8 +440,8 @@ function renderTags(tags) {
const container = document.getElementById('tagContainer'); const container = document.getElementById('tagContainer');
container.innerHTML = tags.map(t => container.innerHTML = tags.map(t =>
`<span class="tag-chip inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30"> `<span class="tag-chip inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30">
${t} ${escapeHtml(t)}
<button onclick="removeTag('${t}')" class="hover:text-red-400 transition ml-0.5">&times;</button> <button onclick="removeTag('${escapeHtml(t)}')" class="hover:text-red-400 transition ml-0.5">&times;</button>
</span>` </span>`
).join(''); ).join('');
} }

View File

@@ -301,19 +301,19 @@ if (data.empty) {
const detail = document.getElementById('linkDetail'); const detail = document.getElementById('linkDetail');
const simPct = (link.best_pair_sim * 100).toFixed(0); const simPct = (link.best_pair_sim * 100).toFixed(0);
document.getElementById('linkTitle').innerHTML = document.getElementById('linkTitle').innerHTML =
`<span style="color:${PALETTE[link.source % PALETTE.length]}">${link.source_theme}</span>` + `<span style="color:${PALETTE[link.source % PALETTE.length]}">${escapeHtml(link.source_theme)}</span>` +
` <span class="text-slate-500">↔</span> ` + ` <span class="text-slate-500">↔</span> ` +
`<span style="color:${PALETTE[link.target % PALETTE.length]}">${link.target_theme}</span>` + `<span style="color:${PALETTE[link.target % PALETTE.length]}">${escapeHtml(link.target_theme)}</span>` +
` <span class="text-slate-500 text-xs font-normal ml-2">${simPct}% similar</span>`; ` <span class="text-slate-500 text-xs font-normal ml-2">${simPct}% similar</span>`;
document.getElementById('linkContent').innerHTML = ` document.getElementById('linkContent').innerHTML = `
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="bg-slate-900/50 rounded p-3 border border-slate-700/30"> <div class="bg-slate-900/50 rounded p-3 border border-slate-700/30">
<div class="text-slate-300 font-medium mb-1">${link.idea_a}</div> <div class="text-slate-300 font-medium mb-1">${escapeHtml(link.idea_a)}</div>
<a href="/drafts/${link.idea_a_draft}" class="text-blue-400/70 hover:text-blue-300 text-[10px] font-mono">${link.idea_a_draft}</a> <a href="/drafts/${encodeURIComponent(link.idea_a_draft)}" class="text-blue-400/70 hover:text-blue-300 text-[10px] font-mono">${escapeHtml(link.idea_a_draft)}</a>
</div> </div>
<div class="bg-slate-900/50 rounded p-3 border border-slate-700/30"> <div class="bg-slate-900/50 rounded p-3 border border-slate-700/30">
<div class="text-slate-300 font-medium mb-1">${link.idea_b}</div> <div class="text-slate-300 font-medium mb-1">${escapeHtml(link.idea_b)}</div>
<a href="/drafts/${link.idea_b_draft}" class="text-blue-400/70 hover:text-blue-300 text-[10px] font-mono">${link.idea_b_draft}</a> <a href="/drafts/${encodeURIComponent(link.idea_b_draft)}" class="text-blue-400/70 hover:text-blue-300 text-[10px] font-mono">${escapeHtml(link.idea_b_draft)}</a>
</div> </div>
</div> </div>
<p class="text-slate-500 text-[10px] mt-1">These two ideas from different clusters have the strongest cross-cluster similarity.</p> <p class="text-slate-500 text-[10px] mt-1">These two ideas from different clusters have the strongest cross-cluster similarity.</p>
@@ -351,8 +351,8 @@ if (data.empty) {
const draftTag = idea.drafts.length > 1 const draftTag = idea.drafts.length > 1
? `<span class="text-slate-600">(${idea.drafts.length} drafts)</span>` ? `<span class="text-slate-600">(${idea.drafts.length} drafts)</span>`
: `<span class="text-slate-600">${idea.drafts[0].replace('draft-', '').substring(0, 20)}</span>`; : `<span class="text-slate-600">${idea.drafts[0].replace('draft-', '').substring(0, 20)}</span>`;
return `<li class="text-xs text-slate-400 truncate" title="${idea.description || idea.title}"> return `<li class="text-xs text-slate-400 truncate" title="${escapeHtml(idea.description || idea.title)}">
<span class="text-slate-300">${idea.title}</span> ${draftTag} <span class="text-slate-300">${escapeHtml(idea.title)}</span> ${draftTag}
</li>`; </li>`;
}).join(''); }).join('');
const previewExtra = uniqueIdeas.length > 3 const previewExtra = uniqueIdeas.length > 3
@@ -364,8 +364,8 @@ if (data.empty) {
`<a href="/drafts/${d}" class="text-blue-400/70 hover:text-blue-300 transition">${d.replace('draft-', '').substring(0, 28)}</a>` `<a href="/drafts/${d}" class="text-blue-400/70 hover:text-blue-300 transition">${d.replace('draft-', '').substring(0, 28)}</a>`
).join(', '); ).join(', ');
return `<div class="py-2 border-b border-slate-800/50 last:border-0"> return `<div class="py-2 border-b border-slate-800/50 last:border-0">
<div class="text-xs text-slate-200 font-medium">${idea.title}</div> <div class="text-xs text-slate-200 font-medium">${escapeHtml(idea.title)}</div>
${idea.description ? `<div class="text-xs text-slate-500 mt-0.5 leading-relaxed">${idea.description.substring(0, 200)}</div>` : ''} ${idea.description ? `<div class="text-xs text-slate-500 mt-0.5 leading-relaxed">${escapeHtml(idea.description.substring(0, 200))}</div>` : ''}
<div class="text-[10px] text-slate-600 mt-1 font-mono">${draftLinks}</div> <div class="text-[10px] text-slate-600 mt-1 font-mono">${draftLinks}</div>
</div>`; </div>`;
}).join(''); }).join('');
@@ -410,7 +410,7 @@ if (data.empty) {
card.innerHTML = ` card.innerHTML = `
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<div class="w-3 h-3 rounded-full flex-shrink-0" style="background: ${color}"></div> <div class="w-3 h-3 rounded-full flex-shrink-0" style="background: ${color}"></div>
<h3 class="text-sm font-semibold text-white truncate">${cluster.theme}</h3> <h3 class="text-sm font-semibold text-white truncate">${escapeHtml(cluster.theme)}</h3>
${crossBadge} ${crossBadge}
<span class="ml-auto text-xs text-slate-500 flex-shrink-0">${cluster.size} ideas</span> <span class="ml-auto text-xs text-slate-500 flex-shrink-0">${cluster.size} ideas</span>
<svg id="chevron-${i}" class="w-4 h-4 text-slate-500 flex-shrink-0 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg> <svg id="chevron-${i}" class="w-4 h-4 text-slate-500 flex-shrink-0 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>

View File

@@ -11,12 +11,14 @@
<h1 class="text-2xl font-bold text-white">Dashboard Overview</h1> <h1 class="text-2xl font-bold text-white">Dashboard Overview</h1>
<p class="text-slate-400 text-sm mt-1">IETF AI/Agent Internet-Drafts at a glance. Drafts are fetched from the IETF Datatracker, then analyzed by Claude AI across five dimensions (novelty, maturity, overlap, momentum, relevance) to produce a composite score from 1.0 to 5.0.</p> <p class="text-slate-400 text-sm mt-1">IETF AI/Agent Internet-Drafts at a glance. Drafts are fetched from the IETF Datatracker, then analyzed by Claude AI across five dimensions (novelty, maturity, overlap, momentum, relevance) to produce a composite score from 1.0 to 5.0.</p>
</div> </div>
{% if is_admin %}
<a href="/export/obsidian" <a href="/export/obsidian"
class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600/80 hover:bg-purple-500 text-white text-sm font-medium rounded-lg transition-colors flex-shrink-0" class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600/80 hover:bg-purple-500 text-white text-sm font-medium rounded-lg transition-colors flex-shrink-0"
title="Download all research data as an Obsidian vault with interlinked notes, Mermaid charts, and YAML frontmatter"> title="Download all research data as an Obsidian vault with interlinked notes, Mermaid charts, and YAML frontmatter">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Download for Obsidian Download for Obsidian
</a> </a>
{% endif %}
</div> </div>
<!-- Stat cards --> <!-- Stat cards -->

View File

@@ -383,7 +383,7 @@ function runGenerate() {
data.proposals.forEach((p, i) => { data.proposals.forEach((p, i) => {
const gapPills = (p.gap_ids || []).map(gid => const gapPills = (p.gap_ids || []).map(gid =>
`<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">#${gid}</span>` `<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">#${parseInt(gid)}</span>`
).join(' '); ).join(' ');
const card = document.createElement('div'); const card = document.createElement('div');
@@ -393,14 +393,14 @@ function runGenerate() {
card.onclick = () => fillFormFromProposal(i); card.onclick = () => fillFormFromProposal(i);
card.innerHTML = ` card.innerHTML = `
<div class="flex items-start justify-between gap-3 mb-1.5"> <div class="flex items-start justify-between gap-3 mb-1.5">
<h3 class="text-sm font-semibold text-white">${p.title}</h3> <h3 class="text-sm font-semibold text-white">${escapeHtml(p.title)}</h3>
<span class="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-green-500/20 text-green-400 ring-1 ring-green-500/30 whitespace-nowrap">SAVED</span> <span class="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-green-500/20 text-green-400 ring-1 ring-green-500/30 whitespace-nowrap">SAVED</span>
</div> </div>
<p class="text-xs text-slate-400 mb-2">${p.description || ''}</p> <p class="text-xs text-slate-400 mb-2">${escapeHtml(p.description || '')}</p>
<div class="flex items-center gap-3 flex-wrap text-[10px]"> <div class="flex items-center gap-3 flex-wrap text-[10px]">
<span class="text-slate-500">Gaps: ${gapPills || '<span class="text-slate-600">none</span>'}</span> <span class="text-slate-500">Gaps: ${gapPills || '<span class="text-slate-600">none</span>'}</span>
${p.intended_wg ? `<span class="text-slate-500">WG: <span class="text-slate-400">${p.intended_wg}</span></span>` : ''} ${p.intended_wg ? `<span class="text-slate-500">WG: <span class="text-slate-400">${escapeHtml(p.intended_wg)}</span></span>` : ''}
${p.draft_name ? `<span class="text-slate-500 font-mono">${p.draft_name}</span>` : ''} ${p.draft_name ? `<span class="text-slate-500 font-mono">${escapeHtml(p.draft_name)}</span>` : ''}
</div> </div>
<p class="text-[10px] text-blue-400/60 mt-2">Click to load into editor below &darr;</p> <p class="text-[10px] text-blue-400/60 mt-2">Click to load into editor below &darr;</p>
`; `;

View File

@@ -150,7 +150,7 @@ function runIntake() {
data.proposals.forEach((p, i) => { data.proposals.forEach((p, i) => {
const gapPills = (p.gap_ids || []).map(gid => const gapPills = (p.gap_ids || []).map(gid =>
`<a href="/gaps/${gid}" class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400 hover:text-blue-400 transition">#${gid}</a>` `<a href="/gaps/${parseInt(gid)}" class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400 hover:text-blue-400 transition">#${parseInt(gid)}</a>`
).join(' '); ).join(' ');
const card = document.createElement('div'); const card = document.createElement('div');
@@ -158,10 +158,10 @@ function runIntake() {
card.style.animationDelay = `${i * 0.1}s`; card.style.animationDelay = `${i * 0.1}s`;
card.innerHTML = ` card.innerHTML = `
<div class="flex items-start justify-between gap-3 mb-2"> <div class="flex items-start justify-between gap-3 mb-2">
<a href="/proposals/${p.id}" class="text-base font-semibold text-white hover:text-blue-400 transition">${p.title}</a> <a href="/proposals/${parseInt(p.id)}" class="text-base font-semibold text-white hover:text-blue-400 transition">${escapeHtml(p.title)}</a>
<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30 whitespace-nowrap">IDEA</span> <span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30 whitespace-nowrap">IDEA</span>
</div> </div>
<p class="text-sm text-slate-400 mb-3">${p.description}</p> <p class="text-sm text-slate-400 mb-3">${escapeHtml(p.description)}</p>
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<span class="text-[10px] text-slate-500">Gaps:</span> <span class="text-[10px] text-slate-500">Gaps:</span>
${gapPills || '<span class="text-[10px] text-slate-600">none linked</span>'} ${gapPills || '<span class="text-[10px] text-slate-600">none linked</span>'}

View File

@@ -197,13 +197,13 @@ document.getElementById('scatter').on('plotly_click', function(data) {
top20.forEach((d, i) => { top20.forEach((d, i) => {
const shortName = d.name.replace('draft-', '').substring(0, 40); const shortName = d.name.replace('draft-', '').substring(0, 40);
const sourceBadge = d.source !== 'ietf' ? ` <span style="display:inline-block;padding:1px 5px;border-radius:4px;font-size:0.55rem;font-weight:600;background:rgba(168,85,247,0.15);color:#c084fc;border:1px solid rgba(168,85,247,0.3);vertical-align:middle">${d.source.toUpperCase()}</span>` : ''; const sourceBadge = d.source !== 'ietf' ? ` <span style="display:inline-block;padding:1px 5px;border-radius:4px;font-size:0.55rem;font-weight:600;background:rgba(168,85,247,0.15);color:#c084fc;border:1px solid rgba(168,85,247,0.3);vertical-align:middle">${escapeHtml(d.source.toUpperCase())}</span>` : '';
const row = document.createElement('tr'); const row = document.createElement('tr');
row.className = 'hover:bg-slate-800/50 transition'; row.className = 'hover:bg-slate-800/50 transition';
row.innerHTML = ` row.innerHTML = `
<td class="px-4 py-3 text-slate-500 font-mono text-xs">${i + 1}</td> <td class="px-4 py-3 text-slate-500 font-mono text-xs">${i + 1}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<a href="/drafts/${d.name}" class="text-blue-400 hover:text-blue-300 transition text-xs font-mono">${shortName}</a>${sourceBadge} <a href="/drafts/${encodeURIComponent(d.name)}" class="text-blue-400 hover:text-blue-300 transition text-xs font-mono">${escapeHtml(shortName)}</a>${sourceBadge}
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">
<span class="score-badge ${scoreClass(d.score)}">${d.score.toFixed(2)}</span> <span class="score-badge ${scoreClass(d.score)}">${d.score.toFixed(2)}</span>
@@ -214,7 +214,7 @@ document.getElementById('scatter').on('plotly_click', function(data) {
<td class="px-4 py-3 text-center">${dimBadge(d.momentum)}</td> <td class="px-4 py-3 text-center">${dimBadge(d.momentum)}</td>
<td class="px-4 py-3 text-center">${dimBadge(d.overlap, true)}</td> <td class="px-4 py-3 text-center">${dimBadge(d.overlap, true)}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">${d.category}</span> <span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">${escapeHtml(d.category)}</span>
</td> </td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);