smarttools/src/smarttools/web/routes.py

643 lines
19 KiB
Python

"""Public web routes for the SmartTools 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 smarttools.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}"
response = client.get(path, query_string=params or {}, 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() -> List[SimpleNamespace]:
status, payload = _api_get("/api/v1/categories", params={"per_page": 100})
if status != 200:
return []
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"),
))
return categories
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, created_at
FROM publishers
WHERE slug = ?
""",
[slug],
)
return dict(row) if row else 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 _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")
params = {"page": page, "per_page": 12, "sort": sort}
if category:
params["category"] = category
status, payload = _api_get("/api/v1/tools", params=params)
if status != 200:
return render_template("errors/500.html"), 500
meta = payload.get("meta", {})
categories = _load_categories()
return render_template(
"pages/tools.html",
tools=payload.get("data", []),
categories=categories,
current_category=category,
total_count=meta.get("total", 0),
sort=sort,
query=query,
pagination=_build_pagination(meta),
)
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)
category = request.args.get("category")
if query:
params = {"q": query, "page": page, "per_page": 12}
if category:
params["category"] = category
status, payload = _api_get("/api/v1/tools/search", params=params)
if status != 200:
return render_template("errors/500.html"), 500
meta = payload.get("meta", {})
return render_template(
"pages/search.html",
query=query,
results=payload.get("data", []),
pagination=_build_pagination(meta),
popular_categories=[],
)
categories = sorted(_load_categories(), key=lambda c: c.count, reverse=True)[:8]
return render_template(
"pages/search.html",
query="",
results=[],
pagination=None,
popular_categories=categories,
)
@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)
return render_template(
"pages/tool_detail.html",
tool=tool,
publisher=publisher,
versions=versions,
)
@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)
return render_template(
"pages/tool_detail.html",
tool=tool,
publisher=publisher,
versions=versions,
)
@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)
return render_template(
"pages/publisher.html",
publisher=publisher_row,
tools=tools,
)
def _publisher_alias(slug: str):
return publisher(slug)
web_bp.add_url_rule("/publishers/<slug>", endpoint="publisher_profile", view_func=_publisher_alias)
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 []
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)
tokens = token_payload.get("data", []) if token_status == 200 else []
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")
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", {})
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=[],
success_message=None,
)
@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="SmartTools 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):
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 '%-%'
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"),
("/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")