1286 lines
42 KiB
Python
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,
|
|
)
|