643 lines
19 KiB
Python
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")
|