CmdForge/src/cmdforge/web/routes.py

1286 lines
42 KiB
Python

"""Public web routes for the CmdForge UI."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, Dict, List, Optional, Tuple
from markupsafe import Markup, escape
from flask import current_app, redirect, render_template, request, session, url_for
from cmdforge.registry.db import connect_db, query_all, query_one
from . import web_bp
def _api_get(path: str, params: Optional[Dict[str, Any]] = None, token: Optional[str] = None) -> Tuple[int, Dict[str, Any]]:
client = current_app.test_client()
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
# Parse query string from path if present
from urllib.parse import urlparse, parse_qs
parsed = urlparse(path)
actual_path = parsed.path
query_params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
if params:
query_params.update(params)
response = client.get(actual_path, query_string=query_params, headers=headers)
return response.status_code, response.get_json(silent=True) or {}
def _api_post(path: str, data: Optional[Dict[str, Any]] = None, token: Optional[str] = None) -> Tuple[int, Dict[str, Any]]:
client = current_app.test_client()
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
import json
response = client.post(path, data=json.dumps(data or {}), headers=headers)
return response.status_code, response.get_json(silent=True) or {}
def _api_delete(path: str, token: Optional[str] = None) -> Tuple[int, Dict[str, Any]]:
client = current_app.test_client()
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
response = client.delete(path, headers=headers)
return response.status_code, response.get_json(silent=True) or {}
def _title_case(value: str) -> str:
return value.replace("-", " ").title()
def _build_pagination(meta: Dict[str, Any]) -> SimpleNamespace:
page = int(meta.get("page", 1))
total_pages = int(meta.get("total_pages", 1))
return SimpleNamespace(
page=page,
pages=total_pages,
has_prev=page > 1,
has_next=page < total_pages,
prev_num=page - 1,
next_num=page + 1,
)
def _render_readme(readme: Optional[str]) -> str:
if not readme:
return ""
escaped = escape(readme)
return Markup("<pre class=\"whitespace-pre-wrap\">") + escaped + Markup("</pre>")
def _load_categories() -> Tuple[List[SimpleNamespace], int]:
"""Load categories with their tool counts. Returns (categories, total_tools)."""
status, payload = _api_get("/api/v1/categories", params={"per_page": 100})
if status != 200:
return [], 0
categories = []
for item in payload.get("data", []):
name = item.get("name")
if not name:
continue
categories.append(SimpleNamespace(
name=name,
display_name=_title_case(name),
count=item.get("tool_count", 0),
description=item.get("description"),
icon=item.get("icon"),
))
# Get total tools from meta (includes all categories, even dynamic ones)
total_tools = payload.get("meta", {}).get("total_tools", sum(c.count for c in categories))
return categories, total_tools
def _load_publisher(slug: str) -> Optional[Dict[str, Any]]:
conn = connect_db()
try:
row = query_one(
conn,
"SELECT slug, display_name, bio, website, verified, created_at FROM publishers WHERE slug = ?",
[slug],
)
return dict(row) if row else None
finally:
conn.close()
def _load_publisher_tools(slug: str) -> List[Dict[str, Any]]:
conn = connect_db()
try:
rows = query_all(
conn,
"""
WITH latest AS (
SELECT owner, name, MAX(id) AS max_id
FROM tools
WHERE owner = ? AND version NOT LIKE '%-%'
GROUP BY owner, name
)
SELECT t.owner, t.name, t.version, t.description, t.category, t.downloads, t.published_at
FROM tools t
JOIN latest ON t.owner = latest.owner AND t.name = latest.name AND t.id = latest.max_id
ORDER BY t.downloads DESC, t.published_at DESC
""",
[slug],
)
return [dict(row) for row in rows]
finally:
conn.close()
def _load_tool_versions(owner: str, name: str) -> List[Dict[str, Any]]:
conn = connect_db()
try:
rows = query_all(
conn,
"SELECT version, published_at FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC",
[owner, name],
)
return [dict(row) for row in rows]
finally:
conn.close()
def _load_tool_id(owner: str, name: str, version: str) -> Optional[int]:
conn = connect_db()
try:
row = query_one(
conn,
"SELECT id FROM tools WHERE owner = ? AND name = ? AND version = ?",
[owner, name, version],
)
return int(row["id"]) if row else None
finally:
conn.close()
def _load_current_publisher() -> Optional[Dict[str, Any]]:
slug = session.get("user", {}).get("slug")
if not slug:
return None
conn = connect_db()
try:
row = query_one(
conn,
"""
SELECT id, slug, display_name, email, verified, bio, website, role, created_at
FROM publishers
WHERE slug = ?
""",
[slug],
)
if row:
data = dict(row)
data["role"] = data.get("role") or "user"
return data
return None
finally:
conn.close()
def _load_pending_prs(publisher_id: int) -> List[Dict[str, Any]]:
conn = connect_db()
try:
rows = query_all(
conn,
"""
SELECT owner, name, version, pr_url, status, created_at
FROM pending_prs
WHERE publisher_id = ?
ORDER BY created_at DESC
""",
[publisher_id],
)
return [dict(row) for row in rows]
finally:
conn.close()
def _require_login() -> Optional[Any]:
if not session.get("auth_token"):
next_url = request.path
if request.query_string:
next_url = f"{next_url}?{request.query_string.decode('utf-8')}"
return redirect(url_for("web.login", next=next_url))
return None
@web_bp.route("/", endpoint="home")
def home():
status, payload = _api_get("/api/v1/stats/popular", params={"limit": 6})
featured_tools = payload.get("data", []) if status == 200 else []
show_ads = current_app.config.get("SHOW_ADS", False)
return render_template(
"pages/index.html",
featured_tools=featured_tools,
featured_contributor=None,
show_ads=show_ads,
)
def _home_alias():
return home()
web_bp.add_url_rule("/", endpoint="index", view_func=_home_alias)
@web_bp.route("/tools", endpoint="tools")
def tools():
return _render_tools()
def _load_tags() -> List[SimpleNamespace]:
"""Load all tags with counts."""
status, payload = _api_get("/api/v1/tags", params={"per_page": 100})
if status != 200:
return []
tags = []
for item in payload.get("data", []):
name = item.get("name")
if not name:
continue
tags.append(SimpleNamespace(
name=name,
count=item.get("count", 0),
))
return tags
def _render_tools(category_override: Optional[str] = None):
page = request.args.get("page", 1)
sort = request.args.get("sort", "downloads")
category = category_override or request.args.get("category")
query = request.args.get("q")
# Parse tag filter parameters (include/exclude)
include_tags_param = request.args.get("include_tags", "")
exclude_tags_param = request.args.get("exclude_tags", "")
include_tags = [t.strip() for t in include_tags_param.split(",") if t.strip()]
exclude_tags = [t.strip() for t in exclude_tags_param.split(",") if t.strip()]
params = {"page": page, "per_page": 12, "sort": sort}
if category:
params["category"] = category
if include_tags:
params["tags"] = ",".join(include_tags)
status, payload = _api_get("/api/v1/tools", params=params)
if status != 200:
return render_template("errors/500.html"), 500
# Filter out excluded tags client-side (API doesn't support exclude)
tools = payload.get("data", [])
if exclude_tags:
def has_excluded_tag(tool):
tool_tags = tool.get("tags", [])
return any(t in tool_tags for t in exclude_tags)
tools = [t for t in tools if not has_excluded_tag(t)]
meta = payload.get("meta", {})
categories, all_tools_total = _load_categories()
all_tags = _load_tags()
return render_template(
"pages/tools.html",
tools=tools,
categories=categories,
current_category=category,
total_count=all_tools_total,
sort=sort,
query=query,
pagination=_build_pagination(meta),
all_tags=all_tags,
include_tags=include_tags,
exclude_tags=exclude_tags,
)
def _tools_alias():
return tools()
web_bp.add_url_rule("/tools", endpoint="tools_browse", view_func=_tools_alias)
@web_bp.route("/category/<name>", endpoint="category")
def category(name: str):
query = request.args.get("q")
if query:
return redirect(url_for("web.search", q=query, category=name))
return _render_tools(category_override=name)
@web_bp.route("/search", endpoint="search")
def search():
query = request.args.get("q", "").strip()
page = request.args.get("page", 1)
# Parse filter parameters
category = request.args.get("category")
categories_param = request.args.get("categories", "")
tags_param = request.args.get("tags", "")
owner = request.args.get("owner")
min_downloads = request.args.get("min_downloads")
sort = request.args.get("sort", "relevance")
# Parse multi-value params
active_categories = [c.strip() for c in categories_param.split(",") if c.strip()]
if category and category not in active_categories:
active_categories.append(category)
active_tags = [t.strip() for t in tags_param.split(",") if t.strip()]
if query:
params = {
"q": query,
"page": page,
"per_page": 12,
"sort": sort,
"include_facets": "true" # Always get facets for the UI
}
if active_categories:
params["categories"] = ",".join(active_categories)
if active_tags:
params["tags"] = ",".join(active_tags)
if owner:
params["owner"] = owner
if min_downloads:
params["min_downloads"] = min_downloads
status, payload = _api_get("/api/v1/tools/search", params=params)
if status != 200:
return render_template("errors/500.html"), 500
meta = payload.get("meta", {})
facets = payload.get("facets", {})
return render_template(
"pages/search.html",
query=query,
results=payload.get("data", []),
pagination=_build_pagination(meta),
popular_categories=[],
facets=facets,
active_categories=active_categories,
active_tags=active_tags,
active_owner=owner,
active_sort=sort,
active_min_downloads=min_downloads,
)
categories, _ = _load_categories()
popular_categories = sorted(categories, key=lambda c: c.count, reverse=True)[:8]
return render_template(
"pages/search.html",
query="",
results=[],
pagination=None,
popular_categories=popular_categories,
facets={},
active_categories=[],
active_tags=[],
active_owner=None,
active_sort="relevance",
active_min_downloads=None,
)
@web_bp.route("/tools/<owner>/<name>", endpoint="tool_detail")
def tool_detail(owner: str, name: str):
status, payload = _api_get(f"/api/v1/tools/{owner}/{name}")
if status == 404:
return render_template("errors/404.html"), 404
if status != 200:
return render_template("errors/500.html"), 500
tool = payload.get("data", {})
tool["tags"] = ", ".join(tool.get("tags", [])) if isinstance(tool.get("tags"), list) else tool.get("tags")
tool["readme_html"] = _render_readme(tool.get("readme"))
tool_id = _load_tool_id(owner, name, tool.get("version", ""))
tool["id"] = tool_id
publisher = _load_publisher(owner)
versions = _load_tool_versions(owner, name)
# Load rating summary
_, rating_payload = _api_get(f"/api/v1/tools/{owner}/{name}/rating")
rating = rating_payload.get("data", {})
# Load reviews (first page)
_, reviews_payload = _api_get(f"/api/v1/tools/{owner}/{name}/reviews", params={"per_page": 5})
reviews = reviews_payload.get("data", [])
reviews_meta = reviews_payload.get("meta", {})
# Load issues summary (open only)
_, issues_payload = _api_get(f"/api/v1/tools/{owner}/{name}/issues", params={"status": "open", "per_page": 5})
issues = issues_payload.get("data", [])
issues_meta = issues_payload.get("meta", {})
return render_template(
"pages/tool_detail.html",
tool=tool,
publisher=publisher,
versions=versions,
rating=rating,
reviews=reviews,
reviews_total=reviews_meta.get("total", 0),
issues=issues,
issues_total=issues_meta.get("total", 0),
)
@web_bp.route("/tools/<owner>/<name>/versions/<version>", endpoint="tool_version")
def tool_version(owner: str, name: str, version: str):
status, payload = _api_get(f"/api/v1/tools/{owner}/{name}", params={"version": version})
if status == 404:
return render_template("errors/404.html"), 404
if status != 200:
return render_template("errors/500.html"), 500
tool = payload.get("data", {})
tool["tags"] = ", ".join(tool.get("tags", [])) if isinstance(tool.get("tags"), list) else tool.get("tags")
tool["readme_html"] = _render_readme(tool.get("readme"))
tool["id"] = _load_tool_id(owner, name, tool.get("version", version))
publisher = _load_publisher(owner)
versions = _load_tool_versions(owner, name)
# Load rating summary
_, rating_payload = _api_get(f"/api/v1/tools/{owner}/{name}/rating")
rating = rating_payload.get("data", {})
# Load reviews (first page)
_, reviews_payload = _api_get(f"/api/v1/tools/{owner}/{name}/reviews", params={"per_page": 5})
reviews = reviews_payload.get("data", [])
reviews_meta = reviews_payload.get("meta", {})
# Load issues summary (open only)
_, issues_payload = _api_get(f"/api/v1/tools/{owner}/{name}/issues", params={"status": "open", "per_page": 5})
issues = issues_payload.get("data", [])
issues_meta = issues_payload.get("meta", {})
return render_template(
"pages/tool_detail.html",
tool=tool,
publisher=publisher,
versions=versions,
rating=rating,
reviews=reviews,
reviews_total=reviews_meta.get("total", 0),
issues=issues,
issues_total=issues_meta.get("total", 0),
)
@web_bp.route("/publishers/<slug>", endpoint="publisher")
def publisher(slug: str):
publisher_row = _load_publisher(slug)
if not publisher_row:
return render_template("errors/404.html"), 404
tools = _load_publisher_tools(slug)
# Load publisher stats and badges
_, stats_payload = _api_get(f"/api/v1/publishers/{slug}/stats")
stats = stats_payload.get("data", {})
return render_template(
"pages/publisher.html",
publisher=publisher_row,
tools=tools,
stats=stats,
)
def _publisher_alias(slug: str):
return publisher(slug)
web_bp.add_url_rule("/publishers/<slug>", endpoint="publisher_profile", view_func=_publisher_alias)
@web_bp.route("/tools/<owner>/<name>/reviews", endpoint="tool_reviews")
def tool_reviews(owner: str, name: str):
"""Full reviews page for a tool."""
status, payload = _api_get(f"/api/v1/tools/{owner}/{name}")
if status == 404:
return render_template("errors/404.html"), 404
if status != 200:
return render_template("errors/500.html"), 500
tool = payload.get("data", {})
page = request.args.get("page", 1, type=int)
sort = request.args.get("sort", "recent")
_, reviews_payload = _api_get(
f"/api/v1/tools/{owner}/{name}/reviews",
params={"page": page, "per_page": 20, "sort": sort}
)
reviews = reviews_payload.get("data", [])
meta = reviews_payload.get("meta", {})
_, rating_payload = _api_get(f"/api/v1/tools/{owner}/{name}/rating")
rating = rating_payload.get("data", {})
return render_template(
"pages/tool_reviews.html",
tool=tool,
reviews=reviews,
rating=rating,
pagination=_build_pagination(meta),
current_sort=sort,
)
@web_bp.route("/tools/<owner>/<name>/issues", endpoint="tool_issues")
def tool_issues(owner: str, name: str):
"""Full issues page for a tool."""
status, payload = _api_get(f"/api/v1/tools/{owner}/{name}")
if status == 404:
return render_template("errors/404.html"), 404
if status != 200:
return render_template("errors/500.html"), 500
tool = payload.get("data", {})
page = request.args.get("page", 1, type=int)
status_filter = request.args.get("status")
type_filter = request.args.get("type")
params = {"page": page, "per_page": 20}
if status_filter:
params["status"] = status_filter
if type_filter:
params["type"] = type_filter
_, issues_payload = _api_get(f"/api/v1/tools/{owner}/{name}/issues", params=params)
issues = issues_payload.get("data", [])
meta = issues_payload.get("meta", {})
return render_template(
"pages/tool_issues.html",
tool=tool,
issues=issues,
pagination=_build_pagination(meta),
current_status=status_filter,
current_type=type_filter,
)
def _render_dashboard_overview():
redirect_response = _require_login()
if redirect_response:
return redirect_response
token = session.get("auth_token")
user = _load_current_publisher() or session.get("user", {})
status, payload = _api_get("/api/v1/me/tools", token=token)
tools = payload.get("data", []) if status == 200 else []
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
tokens = token_payload.get("data", []) if token_status == 200 else []
stats = {
"tools_count": len(tools),
"total_downloads": sum(tool.get("downloads", 0) for tool in tools),
"tokens_count": len(tokens),
}
return render_template(
"dashboard/index.html",
user=user,
tools=tools,
stats=stats,
)
@web_bp.route("/dashboard", endpoint="dashboard")
def dashboard():
return _render_dashboard_overview()
@web_bp.route("/dashboard/tools", endpoint="dashboard_tools")
def dashboard_tools():
redirect_response = _require_login()
if redirect_response:
return redirect_response
token = session.get("auth_token")
user = _load_current_publisher() or session.get("user", {})
status, payload = _api_get("/api/v1/me/tools", token=token)
tools = payload.get("data", []) if status == 200 else []
# Load rating/issue data for each tool
for tool in tools:
_, rating_payload = _api_get(f"/api/v1/tools/{tool.get('owner')}/{tool.get('name')}/rating")
rating_data = rating_payload.get("data", {})
tool["rating"] = rating_data.get("average_rating", 0)
tool["rating_count"] = rating_data.get("rating_count", 0)
tool["open_issues"] = rating_data.get("open_issues", 0)
tool["security_issues"] = rating_data.get("security_issues", 0)
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
tokens = token_payload.get("data", []) if token_status == 200 else []
stats = {
"tools_count": len(tools),
"total_downloads": sum(tool.get("downloads", 0) for tool in tools),
"tokens_count": len(tokens),
}
pending_prs = []
if user and user.get("id"):
pending_prs = _load_pending_prs(int(user["id"]))
return render_template(
"dashboard/tools.html",
user=user,
tools=tools,
stats=stats,
pending_prs=pending_prs,
)
@web_bp.route("/dashboard/tokens", endpoint="dashboard_tokens")
def dashboard_tokens():
redirect_response = _require_login()
if redirect_response:
return redirect_response
token = session.get("auth_token")
user = _load_current_publisher() or session.get("user", {})
tools_status, tools_payload = _api_get("/api/v1/me/tools", token=token)
tools = tools_payload.get("data", []) if tools_status == 200 else []
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
all_tokens = token_payload.get("data", []) if token_status == 200 else []
# Filter out session tokens (auto-created on login) from display
tokens = [t for t in all_tokens if t.get("name") not in ("Web Session", "login")]
for item in tokens:
token_id = str(item.get("id", ""))
item["token_suffix"] = token_id[-6:] if token_id else ""
item["revoked_at"] = item.get("revoked_at")
stats = {
"tools_count": len(tools),
"total_downloads": sum(tool.get("downloads", 0) for tool in tools),
"tokens_count": len(tokens),
}
return render_template(
"dashboard/tokens.html",
user=user,
tools=tools,
stats=stats,
tokens=tokens,
)
@web_bp.route("/dashboard/settings", endpoint="dashboard_settings", methods=["GET", "POST"])
def dashboard_settings():
redirect_response = _require_login()
if redirect_response:
return redirect_response
token = session.get("auth_token")
user = _load_current_publisher() or session.get("user", {})
errors = []
success_message = None
if request.method == "POST":
form_type = request.form.get("form")
if form_type == "profile":
# Update profile
data = {
"display_name": request.form.get("display_name", "").strip(),
"bio": request.form.get("bio", "").strip(),
"website": request.form.get("website", "").strip(),
}
status, payload = _api_post("/api/v1/me", data=data, token=token)
if status == 200:
success_message = "Profile updated successfully."
user = _load_current_publisher() or session.get("user", {})
else:
errors.append(payload.get("error", {}).get("message", "Failed to update profile."))
elif form_type == "password":
# Change password
current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
if not current_password or not new_password:
errors.append("Please fill in all password fields.")
elif new_password != confirm_password:
errors.append("New passwords do not match.")
elif len(new_password) < 8:
errors.append("New password must be at least 8 characters.")
else:
data = {
"current_password": current_password,
"new_password": new_password,
}
status, payload = _api_post("/api/v1/me/password", data=data, token=token)
if status == 200:
success_message = "Password updated successfully."
else:
errors.append(payload.get("error", {}).get("message", "Failed to update password."))
tools_status, tools_payload = _api_get("/api/v1/me/tools", token=token)
tools = tools_payload.get("data", []) if tools_status == 200 else []
token_status, token_payload = _api_get("/api/v1/tokens", token=token)
tokens = token_payload.get("data", []) if token_status == 200 else []
stats = {
"tools_count": len(tools),
"total_downloads": sum(tool.get("downloads", 0) for tool in tools),
"tokens_count": len(tokens),
}
return render_template(
"dashboard/settings.html",
user=user,
tools=tools,
stats=stats,
tokens=tokens,
errors=errors,
success_message=success_message,
)
@web_bp.route("/docs", defaults={"path": ""}, endpoint="docs")
@web_bp.route("/docs/<path:path>", endpoint="docs")
def docs(path: str):
from .docs_content import get_doc, get_toc
toc = get_toc()
current = path or "getting-started"
# Get content from docs_content module
doc = get_doc(current)
if doc:
# Convert (id, title) tuples to objects with id, text, level attributes
headings = [
SimpleNamespace(id=h[0], text=h[1], level=2)
for h in doc.get("headings", [])
]
page = SimpleNamespace(
title=doc["title"],
description=doc["description"],
content_html=Markup(doc["content"]),
headings=headings,
parent=doc.get("parent"),
)
else:
page = SimpleNamespace(
title=_title_case(current),
description="CmdForge documentation",
content_html=Markup(f"<p>Documentation for <strong>{escape(current)}</strong> is coming soon.</p>"),
headings=[],
parent=None,
)
show_ads = current_app.config.get("SHOW_ADS", False)
return render_template(
"pages/docs.html",
page=page,
toc=toc,
current_path=current,
prev_page=None,
next_page=None,
show_ads=show_ads,
)
@web_bp.route("/tutorials", endpoint="tutorials")
def tutorials():
core_tutorials = []
video_tutorials = []
return render_template(
"pages/tutorials.html",
core_tutorials=core_tutorials,
video_tutorials=video_tutorials,
)
@web_bp.route("/tutorials/<path:path>", endpoint="tutorials_path")
def tutorials_path(path: str):
from .docs_content import get_doc, get_toc
doc = get_doc(path)
if doc:
# Convert (id, title) tuples to objects with id, text, level attributes
headings = [
SimpleNamespace(id=h[0], text=h[1], level=2)
for h in doc.get("headings", [])
]
page = SimpleNamespace(
title=doc["title"],
description=doc["description"],
content_html=Markup(doc["content"]),
headings=headings,
parent=doc.get("parent"),
)
return render_template(
"pages/docs.html",
page=page,
toc=get_toc(),
current_path=path,
prev_page=None,
next_page=None,
)
return render_template(
"pages/content.html",
title=_title_case(path),
body="Tutorial content for this topic is coming soon.",
)
web_bp.add_url_rule("/tutorials/<path:path>", endpoint="tutorial", view_func=tutorials_path)
@web_bp.route("/community", endpoint="community")
def community():
return render_template("pages/community.html")
@web_bp.route("/about", endpoint="about")
def about():
return render_template("pages/about.html")
@web_bp.route("/donate", endpoint="donate")
def donate():
return render_template("pages/donate.html")
@web_bp.route("/privacy", endpoint="privacy")
def privacy():
return render_template("pages/privacy.html")
@web_bp.route("/terms", endpoint="terms")
def terms():
return render_template("pages/terms.html")
@web_bp.route("/forgot-password", endpoint="forgot_password")
def forgot_password():
return render_template(
"pages/content.html",
title="Reset Password",
body="Password resets are not available yet. Please contact support if needed.",
)
@web_bp.route("/robots.txt", endpoint="robots")
def robots():
"""Serve robots.txt - we welcome all crawlers including AI."""
from flask import send_from_directory
return send_from_directory(
web_bp.static_folder,
"robots.txt",
mimetype="text/plain"
)
@web_bp.route("/sitemap.xml", endpoint="sitemap")
def sitemap():
"""Generate dynamic sitemap.xml with all public pages and tools."""
from flask import Response
from datetime import datetime
conn = connect_db()
try:
tools = query_all(
conn,
"""
WITH latest AS (
SELECT owner, name, MAX(id) AS max_id
FROM tools
WHERE version NOT LIKE '%-%'
AND visibility = 'public'
AND moderation_status = 'approved'
GROUP BY owner, name
)
SELECT t.owner, t.name, t.published_at
FROM tools t
JOIN latest ON t.owner = latest.owner AND t.name = latest.name AND t.id = latest.max_id
ORDER BY t.published_at DESC
""",
[],
)
tools = [dict(row) for row in tools]
finally:
conn.close()
base_url = request.url_root.rstrip("/")
today = datetime.utcnow().strftime("%Y-%m-%d")
# Static pages
static_pages = [
("/", "1.0", "daily"),
("/tools", "0.9", "daily"),
("/collections", "0.8", "weekly"),
("/docs", "0.8", "weekly"),
("/docs/getting-started", "0.8", "weekly"),
("/docs/installation", "0.7", "weekly"),
("/docs/first-tool", "0.7", "weekly"),
("/docs/publishing", "0.7", "weekly"),
("/docs/providers", "0.7", "weekly"),
("/tutorials", "0.7", "weekly"),
("/about", "0.5", "monthly"),
("/community", "0.5", "weekly"),
("/donate", "0.4", "monthly"),
("/privacy", "0.3", "monthly"),
("/terms", "0.3", "monthly"),
]
xml_parts = ['<?xml version="1.0" encoding="UTF-8"?>']
xml_parts.append('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">')
# Add static pages
for path, priority, freq in static_pages:
xml_parts.append(f""" <url>
<loc>{base_url}{path}</loc>
<lastmod>{today}</lastmod>
<changefreq>{freq}</changefreq>
<priority>{priority}</priority>
</url>""")
# Add tool pages
for tool in tools:
pub_date = tool.get("published_at", today)
if pub_date and "T" in str(pub_date):
pub_date = str(pub_date).split("T")[0]
xml_parts.append(f""" <url>
<loc>{base_url}/tools/{tool['owner']}/{tool['name']}</loc>
<lastmod>{pub_date or today}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>""")
xml_parts.append("</urlset>")
return Response("\n".join(xml_parts), mimetype="application/xml")
# ============================================
# Dashboard API Proxy Routes
# These proxy browser requests to the API using session auth
# ============================================
from flask import jsonify
@web_bp.route("/dashboard/api/tokens", methods=["POST"])
def dashboard_create_token():
"""Proxy token creation from dashboard to API."""
redirect_response = _require_login()
if redirect_response:
return jsonify({"error": {"code": "UNAUTHORIZED", "message": "Not logged in"}}), 401
token = session.get("auth_token")
data = request.get_json() or {}
status, payload = _api_post("/api/v1/tokens", data=data, token=token)
return jsonify(payload), status
@web_bp.route("/dashboard/api/tokens/<int:token_id>", methods=["DELETE"])
def dashboard_revoke_token(token_id: int):
"""Proxy token revocation from dashboard to API."""
redirect_response = _require_login()
if redirect_response:
return jsonify({"error": {"code": "UNAUTHORIZED", "message": "Not logged in"}}), 401
token = session.get("auth_token")
status, payload = _api_delete(f"/api/v1/tokens/{token_id}", token=token)
return jsonify(payload), status
@web_bp.route("/dashboard/api/tools/<owner>/<name>/deprecate", methods=["POST"])
def dashboard_deprecate_tool(owner: str, name: str):
"""Proxy tool deprecation from dashboard to API."""
redirect_response = _require_login()
if redirect_response:
return jsonify({"error": {"code": "UNAUTHORIZED", "message": "Not logged in"}}), 401
token = session.get("auth_token")
data = request.get_json() or {}
status, payload = _api_post(f"/api/v1/tools/{owner}/{name}/deprecate", data=data, token=token)
return jsonify(payload), status
@web_bp.route("/dashboard/api/tools/<owner>/<name>/undeprecate", methods=["POST"])
def dashboard_undeprecate_tool(owner: str, name: str):
"""Proxy tool undeprecation from dashboard to API."""
redirect_response = _require_login()
if redirect_response:
return jsonify({"error": {"code": "UNAUTHORIZED", "message": "Not logged in"}}), 401
token = session.get("auth_token")
data = request.get_json() or {}
status, payload = _api_post(f"/api/v1/tools/{owner}/{name}/undeprecate", data=data, token=token)
return jsonify(payload), status
# ============================================
# Collections Routes
# ============================================
@web_bp.route("/collections", endpoint="collections")
def collections():
"""List all available collections."""
status, payload = _api_get("/api/v1/collections")
if status != 200:
return render_template("errors/500.html"), 500
return render_template(
"pages/collections.html",
collections=payload.get("data", []),
)
@web_bp.route("/collections/<name>", endpoint="collection_detail")
def collection_detail(name: str):
"""Show a specific collection with its tools."""
status, payload = _api_get(f"/api/v1/collections/{name}")
if status == 404:
return render_template("errors/404.html"), 404
if status != 200:
return render_template("errors/500.html"), 500
collection = payload.get("data", {})
return render_template(
"pages/collection_detail.html",
collection=collection,
)
# ============================================
# Admin Dashboard Routes
# ============================================
def _require_moderator_role():
"""Check if current user has moderator or admin role."""
redirect_response = _require_login()
if redirect_response:
return redirect_response
user = _load_current_publisher()
if not user or user.get("role") not in ("moderator", "admin"):
return render_template("errors/403.html"), 403
return None
def _require_admin_role():
"""Check if current user has admin role."""
redirect_response = _require_login()
if redirect_response:
return redirect_response
user = _load_current_publisher()
if not user or user.get("role") != "admin":
return render_template("errors/403.html"), 403
return None
@web_bp.route("/dashboard/admin", endpoint="admin_dashboard")
def admin_dashboard():
"""Admin dashboard overview."""
forbidden = _require_moderator_role()
if forbidden:
return forbidden
user = _load_current_publisher()
token = session.get("auth_token")
# Get pending tools count
status, payload = _api_get("/api/v1/admin/tools/pending?per_page=1", token=token)
pending_count = payload.get("meta", {}).get("total", 0) if status == 200 else 0
# Get pending reports count
status, payload = _api_get("/api/v1/admin/reports?status=pending&per_page=1", token=token)
reports_count = payload.get("meta", {}).get("total", 0) if status == 200 else 0
# Get publisher count
status, payload = _api_get("/api/v1/admin/publishers?per_page=1", token=token)
publishers_count = payload.get("meta", {}).get("total", 0) if status == 200 else 0
return render_template(
"admin/index.html",
user=user,
active_page="admin_overview",
pending_count=pending_count,
reports_count=reports_count,
publishers_count=publishers_count,
)
@web_bp.route("/dashboard/admin/pending", endpoint="admin_pending")
def admin_pending():
"""List pending tools for moderation."""
forbidden = _require_moderator_role()
if forbidden:
return forbidden
user = _load_current_publisher()
token = session.get("auth_token")
page = request.args.get("page", 1, type=int)
status, payload = _api_get(f"/api/v1/admin/tools/pending?page={page}", token=token)
tools = payload.get("data", []) if status == 200 else []
meta = payload.get("meta", {})
return render_template(
"admin/pending.html",
user=user,
active_page="admin_pending",
tools=tools,
meta=meta,
)
@web_bp.route("/dashboard/admin/publishers", endpoint="admin_publishers")
def admin_publishers():
"""List all publishers."""
forbidden = _require_moderator_role()
if forbidden:
return forbidden
user = _load_current_publisher()
token = session.get("auth_token")
page = request.args.get("page", 1, type=int)
status, payload = _api_get(f"/api/v1/admin/publishers?page={page}", token=token)
publishers = payload.get("data", []) if status == 200 else []
meta = payload.get("meta", {})
return render_template(
"admin/publishers.html",
user=user,
active_page="admin_publishers",
publishers=publishers,
meta=meta,
)
@web_bp.route("/dashboard/admin/reports", endpoint="admin_reports")
def admin_reports():
"""List pending reports."""
forbidden = _require_moderator_role()
if forbidden:
return forbidden
user = _load_current_publisher()
token = session.get("auth_token")
page = request.args.get("page", 1, type=int)
status_filter = request.args.get("status", "pending")
status, payload = _api_get(f"/api/v1/admin/reports?page={page}&status={status_filter}", token=token)
reports = payload.get("data", []) if status == 200 else []
meta = payload.get("meta", {})
return render_template(
"admin/reports.html",
user=user,
active_page="admin_reports",
reports=reports,
meta=meta,
status_filter=status_filter,
)
@web_bp.route("/dashboard/admin/settings", endpoint="admin_settings")
def admin_settings():
"""Admin settings configuration page."""
forbidden = _require_admin_role()
if forbidden:
return forbidden
user = _load_current_publisher()
token = session.get("auth_token")
status, payload = _api_get("/api/v1/admin/settings", token=token)
if status != 200:
return render_template(
"admin/settings.html",
user=user,
active_page="admin_settings",
settings_by_category={},
categories=[],
token=token,
error=payload.get("error", "Failed to load settings"),
)
settings = payload.get("data", [])
categories = payload.get("available_categories", [])
# Group settings by category
settings_by_category = {}
for s in settings:
cat = s.get("category", "general")
if cat not in settings_by_category:
settings_by_category[cat] = []
settings_by_category[cat].append(s)
return render_template(
"admin/settings.html",
user=user,
active_page="admin_settings",
settings_by_category=settings_by_category,
categories=categories,
token=token,
)
@web_bp.route("/dashboard/admin/scrutiny", endpoint="admin_scrutiny")
def admin_scrutiny():
"""Scrutiny audit page - view all tool scrutiny results."""
forbidden = _require_moderator_role()
if forbidden:
return forbidden
user = _load_current_publisher()
token = session.get("auth_token")
page = request.args.get("page", 1, type=int)
scrutiny_filter = request.args.get("scrutiny_status", "")
moderation_filter = request.args.get("moderation_status", "")
params = {"page": page, "per_page": 50}
if scrutiny_filter:
params["scrutiny_status"] = scrutiny_filter
if moderation_filter:
params["moderation_status"] = moderation_filter
status, payload = _api_get("/api/v1/admin/scrutiny", params=params, token=token)
if status != 200:
return render_template(
"admin/scrutiny.html",
user=user,
active_page="admin_scrutiny",
tools=[],
meta={},
stats_summary={},
scrutiny_filter=scrutiny_filter,
moderation_filter=moderation_filter,
error=payload.get("error", "Failed to load scrutiny data"),
)
# Process stats into easy-to-use summary
stats = payload.get("stats", [])
stats_summary = {
"approved_approved": 0,
"review_pending": 0,
"review_approved": 0,
"total_pending": 0,
}
for s in stats:
scrutiny = s.get("scrutiny_status")
moderation = s.get("moderation_status")
count = s.get("count", 0)
if scrutiny == "approved" and moderation == "approved":
stats_summary["approved_approved"] += count
if scrutiny == "pending_review" and moderation == "pending":
stats_summary["review_pending"] += count
if scrutiny == "pending_review" and moderation == "approved":
stats_summary["review_approved"] += count
if moderation == "pending":
stats_summary["total_pending"] += count
return render_template(
"admin/scrutiny.html",
user=user,
active_page="admin_scrutiny",
tools=payload.get("data", []),
meta=payload.get("meta", {}),
stats_summary=stats_summary,
scrutiny_filter=scrutiny_filter,
moderation_filter=moderation_filter,
)