Files
ietf-draft-analyzer/src/webui/templates/blog_generate.html
Christian Nennemann f8ed2b83e9 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>
2026-03-09 04:47:32 +01:00

230 lines
12 KiB
HTML

{% extends "base.html" %}
{% set active_page = "blog" %}
{% block title %}Generate Blog Post — IETF Draft Analyzer{% endblock %}
{% block extra_head %}
<style>
.prose { color: #cbd5e1; line-height: 1.75; }
.prose h1, .prose h2, .prose h3 { color: #f1f5f9; font-weight: 700; margin-top: 1.5em; margin-bottom: 0.5em; }
.prose h1 { font-size: 1.5rem; }
.prose h2 { font-size: 1.25rem; }
.prose h3 { font-size: 1.1rem; }
.prose p { margin-bottom: 1em; }
.prose strong { color: #e2e8f0; }
.prose em { color: #94a3b8; }
.prose ul, .prose ol { margin-bottom: 1em; padding-left: 1.5em; }
.prose li { margin-bottom: 0.25em; }
.prose blockquote { border-left: 3px solid #475569; padding-left: 1em; color: #94a3b8; margin: 1em 0; }
.prose code { background: #1e293b; padding: 0.2em 0.4em; border-radius: 4px; font-size: 0.9em; }
.prose pre { background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; padding: 1em; overflow-x: auto; margin: 1em 0; }
.prose pre code { background: none; padding: 0; }
.prose a { color: #60a5fa; text-decoration: underline; }
.spinner { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<nav class="mb-6 text-sm">
<a href="/blog" class="text-blue-400 hover:text-blue-300 transition">Blog Drafts</a>
<span class="text-slate-600 mx-2">/</span>
<span class="text-slate-400">Generate</span>
</nav>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Input form -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h1 class="text-xl font-bold text-white mb-4">Generate Blog Post</h1>
<p class="text-sm text-slate-400 mb-6">Claude will use your project data (gaps, proposals, drafts, stats) to write a Medium-style blog post.</p>
<form id="generateForm" class="space-y-5">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1.5">Topic *</label>
<input type="text" name="topic" required placeholder="e.g., 'all analysis results', 'capability-based security for agents', 'gap #3 deep dive'"
class="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition">
<p class="text-xs text-slate-500 mt-1">What should the post be about? Reference gaps, proposals, or general topics.</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1.5">Style</label>
<select name="style" class="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 transition">
<option value="deep-dive">Deep Dive (2500-3000 words)</option>
<option value="overview">Overview (1500-2000 words)</option>
<option value="opinion">Opinion (1500-2000 words)</option>
<option value="listicle">Listicle (1500-2000 words)</option>
<option value="comparison">Comparison (2000-2500 words)</option>
<option value="series-post">Series Post (2000-2500 words)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1.5">Additional Instructions</label>
<textarea name="instructions" rows="3" placeholder="Optional: tone, audience, specific data to highlight, angles to take..."
class="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition resize-y"></textarea>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cheap" class="rounded border-slate-600 bg-slate-950 text-blue-500 focus:ring-blue-500 focus:ring-offset-0">
<span class="text-sm text-slate-400">Use Haiku (cheaper, faster, less detailed)</span>
</label>
</div>
<button type="submit" id="submitBtn"
class="w-full inline-flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 hover:bg-purple-500 disabled:bg-slate-700 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-lg transition">
<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="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span id="btnText">Generate Blog Post</span>
</button>
</form>
<!-- Error display -->
<div id="errorBox" class="hidden mt-4 p-4 bg-red-900/30 border border-red-500/30 rounded-lg">
<p class="text-sm text-red-400" id="errorText"></p>
</div>
<!-- Usage info -->
<div id="usageBox" class="hidden mt-4 p-4 bg-slate-800/50 rounded-lg border border-slate-700">
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Generation Info</h3>
<div class="grid grid-cols-2 gap-2 text-xs text-slate-400">
<span>Model:</span><span id="usageModel" class="text-slate-300"></span>
<span>Input tokens:</span><span id="usageIn" class="text-slate-300"></span>
<span>Output tokens:</span><span id="usageOut" class="text-slate-300"></span>
<span>Cost:</span><span id="usageCost" class="text-slate-300"></span>
</div>
<a id="viewLink" href="#" class="inline-flex items-center gap-1 mt-3 text-sm text-blue-400 hover:text-blue-300 transition">
View saved draft &rarr;
</a>
</div>
</div>
<!-- Preview pane -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Preview</h2>
<div id="previewActions" class="hidden flex items-center gap-2">
<button onclick="copyContent()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-lg transition">Copy MD</button>
</div>
</div>
<div id="previewEmpty" class="text-center py-16">
<svg class="w-16 h-16 text-slate-700 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"/></svg>
<p class="text-slate-500 text-sm">Your generated blog post will appear here.</p>
</div>
<div id="previewLoading" class="hidden text-center py-16">
<svg class="w-10 h-10 text-purple-400 mx-auto mb-4 spinner" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25"></circle>
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" class="opacity-75"></path>
</svg>
<p class="text-slate-400 text-sm">Generating blog post with Claude...</p>
<p class="text-slate-500 text-xs mt-1">This may take 30-60 seconds.</p>
</div>
<div id="previewContent" class="hidden">
<div id="previewTitle" class="mb-4">
<h1 class="text-xl font-bold text-white" id="titleText"></h1>
<p class="text-sm text-slate-400 mt-1" id="subtitleText"></p>
<div class="flex flex-wrap gap-1.5 mt-2" id="tagsList"></div>
</div>
<div class="prose max-w-none" id="markdownPreview"></div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="/static/js/marked.min.js"></script>
<script src="/static/js/purify.min.js"></script>
<script>
let rawMarkdown = '';
document.getElementById('generateForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const btn = document.getElementById('submitBtn');
const btnText = document.getElementById('btnText');
const errorBox = document.getElementById('errorBox');
const usageBox = document.getElementById('usageBox');
// Reset state
errorBox.classList.add('hidden');
usageBox.classList.add('hidden');
document.getElementById('previewEmpty').classList.add('hidden');
document.getElementById('previewContent').classList.add('hidden');
document.getElementById('previewLoading').classList.remove('hidden');
document.getElementById('previewActions').classList.add('hidden');
btn.disabled = true;
btnText.textContent = 'Generating...';
const formData = new FormData(form);
try {
const resp = await fetch('/blog/generate', {
method: 'POST',
body: formData,
});
const data = await resp.json();
if (!resp.ok || data.error) {
throw new Error(data.error || 'Generation failed');
}
// Show preview
rawMarkdown = data.content;
document.getElementById('titleText').textContent = data.title;
document.getElementById('subtitleText').textContent = data.subtitle || '';
const tagsList = document.getElementById('tagsList');
tagsList.innerHTML = '';
(data.tags || []).forEach(tag => {
const span = document.createElement('span');
span.className = 'px-1.5 py-0.5 rounded bg-slate-800/50 text-slate-500 text-xs';
span.textContent = '#' + tag;
tagsList.appendChild(span);
});
document.getElementById('markdownPreview').innerHTML = DOMPurify.sanitize(marked.parse(data.content));
document.getElementById('previewLoading').classList.add('hidden');
document.getElementById('previewContent').classList.remove('hidden');
document.getElementById('previewActions').classList.remove('hidden');
// Usage info
if (data.usage) {
document.getElementById('usageModel').textContent = data.usage.model;
document.getElementById('usageIn').textContent = data.usage.input_tokens.toLocaleString();
document.getElementById('usageOut').textContent = data.usage.output_tokens.toLocaleString();
document.getElementById('usageCost').textContent = '$' + data.usage.cost_usd.toFixed(4);
usageBox.classList.remove('hidden');
}
if (data.id) {
document.getElementById('viewLink').href = '/blog/' + data.id;
document.getElementById('viewLink').classList.remove('hidden');
}
} catch (err) {
document.getElementById('previewLoading').classList.add('hidden');
document.getElementById('previewEmpty').classList.remove('hidden');
document.getElementById('errorText').textContent = err.message;
errorBox.classList.remove('hidden');
} finally {
btn.disabled = false;
btnText.textContent = 'Generate Blog Post';
}
});
function copyContent() {
navigator.clipboard.writeText(rawMarkdown).then(() => {
const btn = event.target;
const orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = orig, 1500);
});
}
</script>
{% endblock %}