"""Blog routes for AgentForms content marketing.
Markdown-based blog with admin CRUD. Posts stored in
~/.hermes/blogs/agentforms/ directory.
Routes:
GET /blog/ — List all posts
GET /blog/<slug> — Single post
GET /blog/admin/ — Admin post list (login_required)
GET /blog/admin/new — Create new post
POST /blog/admin/new — Save new post
GET /blog/admin/<slug>/edit — Edit existing post
POST /blog/admin/<slug>/edit — Update post
POST /blog/admin/<slug>/delete — Delete post
"""
from flask import Blueprint, render_template, redirect, url_for, request, flash
import os
import glob
import re
import markdown2
import bleach
from app.auth_utils import login_required
blog_bp = Blueprint("blog_posts", __name__, url_prefix="/blog")
BLOG_DIR = os.environ.get("BLOG_DIR", "/app/blogs")
BLOG_INDEX = os.path.join(BLOG_DIR, "index.md")
# Bleach allowed tags for blog HTML
_ALLOWED_TAGS = [
"p", "br", "strong", "em", "b", "i", "u", "s", "a", "ul", "ol", "li",
"h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "code",
"table", "thead", "tbody", "tr", "th", "td", "hr", "div", "span",
"img", "kbd", "sub", "sup", "abbr", "cite", "dfn", "figure",
"figcaption", "details", "summary",
]
_ALLOWED_ATTR = {
"a": ["href", "title", "target", "rel"],
"img": ["src", "alt", "title"],
"abbr": ["title"],
"*": ["class", "id"],
}
def _sanitize_html(html: str) -> str:
"""Sanitize HTML output from markdown."""
return bleach.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTR, strip=True)
def _validate_slug(slug: str) -> str:
"""Validate and normalize a blog slug. Only allows safe characters."""
# Only allow lowercase alphanumerics and hyphens
safe = re.sub(r'[^a-z0-9\-]', '', slug.lower())
# Prevent path traversal
safe = safe.replace('.', '').replace('/', '').replace('\\', '')
return safe
def _generate_toc(html: str) -> str:
"""Extract h2/h3 headings from HTML and generate a table of contents."""
# Find h2 and h3 tags with their text content
headings = []
for match in re.finditer(r'<h([23])[^>]*>(.*?)</h\1>', html, re.DOTALL):
level = int(match.group(1))
# Strip inner tags to get plain text
text = re.sub(r'<[^>]+>', '', match.group(2)).strip()
slug = re.sub(r'[^\w\s-]', '', text.lower())
slug = re.sub(r'[\s]+', '-', slug).strip('-')
# Add id to the heading
headings.append({
'level': level,
'text': text,
'id': f'toc-{slug}',
})
if not headings:
return ""
# Inject IDs into HTML headings
toc_html = '<ol>'
for i, h in enumerate(headings):
# Inject id attribute into the heading
tag = f'h{h["level"]}'
html = re.sub(
rf'<{tag}([^>]*)>',
f'<{tag}\\1 id="{h["id"]}">',
html,
count=1,
)
if h['level'] == 2:
toc_html += f'<li><a href="#{h["id"]}">{h["text"]}</a>'
else:
toc_html += f'<li class="toc-h3"><a href="#{h["id"]}">{h["text"]}</a>'
toc_html += '</ol>'
return toc_html, html
def _word_count(text: str) -> int:
"""Count words in HTML content."""
plain = re.sub(r'<[^>]+>', ' ', text)
plain = re.sub(r'\s+', ' ', plain).strip()
return len(plain.split()) if plain else 0
def _get_posts() -> list[dict]:
"""Read all blog posts from directory."""
posts = []
if not os.path.exists(BLOG_DIR):
return posts
for fname in sorted(os.listdir(BLOG_DIR), reverse=True):
if fname == "index.md" or not fname.endswith(".md"):
continue
fpath = os.path.join(BLOG_DIR, fname)
with open(fpath, "r") as f:
content = f.read()
# Parse frontmatter (line-based to avoid --- in body content)
fm = {}
body = content
if content.startswith("---"):
lines = content.split("\n")
fm_end = -1
for i in range(1, len(lines)):
if lines[i].strip() == "---":
fm_end = i
break
if fm_end > 0:
fm_raw = "\n".join(lines[1:fm_end])
body = "\n".join(lines[fm_end+1:])
for line in fm_raw.strip().split("\n"):
if ":" in line:
k, v = line.split(":", 1)
fm[k.strip()] = v.strip().strip('"').strip("'")
else:
fm = {"title": fname.replace(".md", ""), "date": "", "summary": "", "tags": ""}
else:
fm = {"title": fname.replace(".md", ""), "date": "", "summary": "", "tags": ""}
body_html = _sanitize_html(markdown2.markdown(body.strip(), extras=["tables", "fenced-code"]))
wc = _word_count(body_html)
posts.append({
"slug": fname[:-3],
"title": fm.get("title", fname.replace(".md", "")),
"date": fm.get("date", ""),
"author": fm.get("author", "AgentForms"),
"summary": fm.get("summary", ""),
"tags": [t.strip() for t in fm.get("tags", "").split(",") if t.strip()],
"body_html": body_html,
"word_count": wc,
"reading_time": max(1, round(wc / 230)),
})
return posts
@blog_bp.route("/")
def index():
"""Blog listing page."""
posts = _get_posts()
return render_template("blog_index.html", posts=posts)
@blog_bp.route("/<path:slug>")
def post(slug: str):
"""Single blog post."""
# Validate slug - prevent path traversal
slug = _validate_slug(slug)
fpath = os.path.join(BLOG_DIR, slug + ".md")
# Resolve and verify the path stays within BLOG_DIR
real_path = os.path.realpath(fpath)
if not real_path.startswith(os.path.realpath(BLOG_DIR)):
return "Not found", 404
if not os.path.exists(fpath):
return "Not found", 404
with open(fpath, "r") as f:
content = f.read()
if content.startswith("---"):
# Parse frontmatter by finding the second standalone "---" line
lines = content.split("\n")
fm_end = -1
for i in range(1, len(lines)):
if lines[i].strip() == "---":
fm_end = i
break
if fm_end > 0:
fm_raw = "\n".join(lines[1:fm_end])
body = "\n".join(lines[fm_end+1:])
fm = {}
for line in fm_raw.strip().split("\n"):
if ":" in line:
k, v = line.split(":", 1)
fm[k.strip()] = v.strip().strip('"').strip("'")
else:
fm = {"title": slug.replace("-", " ").title()}
body = content
else:
fm = {"title": slug.replace("-", " ").title()}
body = content
body_html = _sanitize_html(markdown2.markdown(body.strip(), extras=["tables", "fenced-code"]))
wc = _word_count(body_html)
# Generate TOC
toc = ""
if '<h2' in body_html or '<h3' in body_html:
toc_result = _generate_toc(body_html)
if isinstance(toc_result, tuple):
toc, body_html = toc_result
return render_template("blog_post.html", post={
"slug": slug,
"title": fm.get("title", slug.replace("-", " ").title()),
"date": fm.get("date", ""),
"author": fm.get("author", "AgentForms"),
"summary": fm.get("summary", ""),
"tags": [t.strip() for t in fm.get("tags", "").split(",") if t.strip()],
"body_html": body_html,
"toc": toc,
"word_count": wc,
"reading_time": max(1, round(wc / 230)),
})
@blog_bp.route("/admin/")
@login_required
def admin_list():
"""Admin post list."""
posts = _get_posts()
return render_template("blog_admin.html", posts=posts, blog_dir=BLOG_DIR)
@blog_bp.route("/admin/new")
@login_required
def admin_new():
"""Create new post form."""
return render_template("blog_edit.html", post=None, blog_dir=BLOG_DIR)
@blog_bp.route("/admin/new", methods=["POST"])
@login_required
def admin_save_new():
"""Save new post."""
title = request.form.get("title", "").strip()
date = request.form.get("date", "").strip()
author = request.form.get("author", "AgentForms").strip()
summary = request.form.get("summary", "").strip()
tags = request.form.get("tags", "").strip()
content = request.form.get("content", "").strip()
if not title or not content:
flash("Title and content are required", "error")
return render_template("blog_edit.html", post=None, blog_dir=BLOG_DIR)
os.makedirs(BLOG_DIR, exist_ok=True)
slug = title.lower().replace(" ", "-").replace("&", "and").replace("'", "")
slug = "".join(c for c in slug if c.isalnum() or c in "-")
# Escape frontmatter values to prevent injection
def _escape_fm(val: str) -> str:
return val.replace("{", "{{").replace("}", "}}")
fm = f"""---
title: {_escape_fm(title)}
date: {_escape_fm(date)}
author: {_escape_fm(author)}
summary: {_escape_fm(summary)}
tags: {_escape_fm(tags)}
---
{content}"""
fpath = os.path.join(BLOG_DIR, slug + ".md")
with open(fpath, "w") as f:
f.write(fm)
flash(f'Post "{title}" created', "success")
return redirect(url_for("blog.admin_list"))
@blog_bp.route("/admin/<slug>/edit")
@login_required
def admin_edit(slug: str):
"""Edit existing post form."""
slug = _validate_slug(slug)
fpath = os.path.join(BLOG_DIR, slug + ".md")
if not os.path.exists(fpath):
flash("Post not found", "error")
return redirect(url_for("blog.admin_list"))
with open(fpath, "r") as f:
content = f.read()
fm = {}
body = content
if content.startswith("---"):
_, fm_end = content.split("---", 1)[1].split("---", 1)
for line in fm_end.strip().split("\n"):
if ":" in line:
k, v = line.split(":", 1)
fm[k.strip()] = v.strip()
body = content[content.find("---", content.find("---") + 3) + 3:]
return render_template("blog_edit.html", post={
"slug": slug,
"title": fm.get("title", ""),
"date": fm.get("date", ""),
"author": fm.get("author", "AgentForms"),
"summary": fm.get("summary", ""),
"tags": fm.get("tags", ""),
"content": body.strip(),
}, blog_dir=BLOG_DIR)
@blog_bp.route("/admin/<slug>/edit", methods=["POST"])
@login_required
def admin_save_edit(slug: str):
"""Update existing post."""
slug = _validate_slug(slug)
title = request.form.get("title", "").strip()
date = request.form.get("date", "").strip()
author = request.form.get("author", "AgentForms").strip()
summary = request.form.get("summary", "").strip()
tags = request.form.get("tags", "").strip()
content = request.form.get("content", "").strip()
if not title or not content:
flash("Title and content are required", "error")
return redirect(request.url)
fpath = os.path.join(BLOG_DIR, slug + ".md")
# Escape frontmatter values to prevent injection
def _escape_fm(val: str) -> str:
return val.replace("{", "{{").replace("}", "}}")
fm = f"""---
title: {_escape_fm(title)}
date: {_escape_fm(date)}
author: {_escape_fm(author)}
summary: {_escape_fm(summary)}
tags: {_escape_fm(tags)}
---
{content}"""
with open(fpath, "w") as f:
f.write(fm)
flash(f'Post "{title}" updated', "success")
return redirect(url_for("blog.admin_list"))
@blog_bp.route("/admin/<slug>/delete", methods=["POST"])
@login_required
def admin_delete(slug: str):
"""Delete post."""
slug = _validate_slug(slug)
fpath = os.path.join(BLOG_DIR, slug + ".md")
if os.path.exists(fpath):
os.remove(fpath)
flash("Post deleted", "success")
return redirect(url_for("blog.admin_list"))