Add registry improvements, tag filtering, and admin tools
Registry API improvements: - Add /api/v1/me POST endpoint for profile updates - Add /api/v1/me/password POST endpoint for password changes - Fix visibility filtering on categories and tags endpoints to only show approved public tools - Allow underscores in tool names for fabric pattern compatibility - Fix sqlite3.Row access (use bracket notation instead of .get()) Web UI improvements: - Add tag filter to /tools page with three-state buttons (include/exclude/neutral) - Add mobile-friendly tag filter in responsive view - Display tags on tool cards in the tools listing - Add dashboard settings form handlers for profile and password Admin improvements: - Add scrutiny audit page for reviewing tool safety analysis - Improve pending tools page with scrutiny report display - Add scrutiny stats to admin dashboard Fabric sync improvements: - Add direct database publishing with scrutiny vetting - Support auto-approve for tools passing scrutiny - Improve error handling and logging - Add source attribution for imported tools Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
@@ -57,7 +57,7 @@ ALLOWED_SORT = {
|
||||
"/categories": {"name", "tool_count"},
|
||||
}
|
||||
|
||||
TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
||||
TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||
OWNER_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,37}[a-z0-9]$")
|
||||
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
RESERVED_SLUGS = {"official", "admin", "system", "api", "registry", "cmdforge"}
|
||||
@@ -1018,10 +1018,12 @@ def create_app() -> Flask:
|
||||
categories_payload = yaml.safe_load(categories_yaml.read_text(encoding="utf-8")) or {}
|
||||
predefined_categories = (categories_payload or {}).get("categories", [])
|
||||
|
||||
# Get counts for all categories in the database
|
||||
# Get counts for all categories in the database (filtered by visibility)
|
||||
vis_filter, vis_params = build_visibility_filter()
|
||||
counts = query_all(
|
||||
g.db,
|
||||
"SELECT category, COUNT(DISTINCT owner || '/' || name) AS total FROM tools GROUP BY category",
|
||||
f"SELECT category, COUNT(DISTINCT owner || '/' || name) AS total FROM tools WHERE 1=1 {vis_filter} GROUP BY category",
|
||||
vis_params,
|
||||
)
|
||||
count_map = {row["category"]: row["total"] for row in counts}
|
||||
|
||||
@@ -1079,31 +1081,35 @@ def create_app() -> Flask:
|
||||
category = request.args.get("category")
|
||||
limit = min(int(request.args.get("limit", 100)), 500)
|
||||
|
||||
# Build visibility filter
|
||||
vis_filter, vis_params = build_visibility_filter("tools")
|
||||
|
||||
# Build query - extract tags from JSON array and count occurrences
|
||||
if category:
|
||||
rows = query_all(
|
||||
g.db,
|
||||
"""
|
||||
f"""
|
||||
SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count
|
||||
FROM tools, json_each(tools.tags) AS tag
|
||||
WHERE tools.category = ?
|
||||
WHERE tools.category = ? {vis_filter}
|
||||
GROUP BY tag.value
|
||||
ORDER BY count DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(category, limit),
|
||||
[category] + vis_params + [limit],
|
||||
)
|
||||
else:
|
||||
rows = query_all(
|
||||
g.db,
|
||||
"""
|
||||
f"""
|
||||
SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count
|
||||
FROM tools, json_each(tools.tags) AS tag
|
||||
WHERE 1=1 {vis_filter}
|
||||
GROUP BY tag.value
|
||||
ORDER BY count DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
vis_params + [limit],
|
||||
)
|
||||
|
||||
data = [{"name": row["name"], "count": row["count"]} for row in rows]
|
||||
@@ -1986,6 +1992,90 @@ def create_app() -> Flask:
|
||||
|
||||
return jsonify({"data": {"status": "updated"}})
|
||||
|
||||
@app.route("/api/v1/me", methods=["POST"])
|
||||
@require_token
|
||||
def update_profile() -> Response:
|
||||
"""Update current user's profile (POST version for web forms)."""
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Validate fields
|
||||
display_name = data.get("display_name", "").strip()
|
||||
bio = data.get("bio", "").strip() if data.get("bio") else None
|
||||
website = data.get("website", "").strip() if data.get("website") else None
|
||||
|
||||
if display_name and len(display_name) > 100:
|
||||
return error_response("VALIDATION_ERROR", "Display name too long (max 100)", 400)
|
||||
if bio and len(bio) > 500:
|
||||
return error_response("VALIDATION_ERROR", "Bio too long (max 500)", 400)
|
||||
if website and len(website) > 200:
|
||||
return error_response("VALIDATION_ERROR", "Website URL too long (max 200)", 400)
|
||||
|
||||
# Build update query
|
||||
updates = []
|
||||
params = []
|
||||
if display_name:
|
||||
updates.append("display_name = ?")
|
||||
params.append(display_name)
|
||||
if bio is not None:
|
||||
updates.append("bio = ?")
|
||||
params.append(bio)
|
||||
if website is not None:
|
||||
updates.append("website = ?")
|
||||
params.append(website)
|
||||
|
||||
if not updates:
|
||||
return error_response("VALIDATION_ERROR", "No valid fields to update", 400)
|
||||
|
||||
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||
params.append(g.current_publisher["id"])
|
||||
|
||||
g.db.execute(
|
||||
f"UPDATE publishers SET {', '.join(updates)} WHERE id = ?",
|
||||
params,
|
||||
)
|
||||
g.db.commit()
|
||||
|
||||
return jsonify({"data": {"status": "updated"}})
|
||||
|
||||
@app.route("/api/v1/me/password", methods=["POST"])
|
||||
@require_token
|
||||
def change_password() -> Response:
|
||||
"""Change current user's password."""
|
||||
data = request.get_json() or {}
|
||||
|
||||
current_password = data.get("current_password", "")
|
||||
new_password = data.get("new_password", "")
|
||||
|
||||
if not current_password or not new_password:
|
||||
return error_response("VALIDATION_ERROR", "Current and new password required", 400)
|
||||
|
||||
if len(new_password) < 8:
|
||||
return error_response("VALIDATION_ERROR", "New password must be at least 8 characters", 400)
|
||||
|
||||
# Verify current password
|
||||
publisher = query_one(
|
||||
g.db,
|
||||
"SELECT password_hash FROM publishers WHERE id = ?",
|
||||
[g.current_publisher["id"]],
|
||||
)
|
||||
if not publisher:
|
||||
return error_response("NOT_FOUND", "Publisher not found", 404)
|
||||
|
||||
try:
|
||||
password_hasher.verify(publisher["password_hash"], current_password)
|
||||
except VerifyMismatchError:
|
||||
return error_response("INVALID_PASSWORD", "Current password is incorrect", 400)
|
||||
|
||||
# Hash and save new password
|
||||
new_hash = password_hasher.hash(new_password)
|
||||
g.db.execute(
|
||||
"UPDATE publishers SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
[new_hash, g.current_publisher["id"]],
|
||||
)
|
||||
g.db.commit()
|
||||
|
||||
return jsonify({"data": {"status": "password_changed"}})
|
||||
|
||||
@app.route("/api/v1/featured/tools", methods=["GET"])
|
||||
def featured_tools() -> Response:
|
||||
"""Get featured tools for homepage/landing."""
|
||||
@@ -2309,6 +2399,14 @@ def create_app() -> Flask:
|
||||
|
||||
data = []
|
||||
for row in rows:
|
||||
# Parse scrutiny report if available
|
||||
scrutiny_report = None
|
||||
if row["scrutiny_report"]:
|
||||
try:
|
||||
scrutiny_report = json.loads(row["scrutiny_report"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
data.append({
|
||||
"id": row["id"],
|
||||
"owner": row["owner"],
|
||||
@@ -2319,6 +2417,8 @@ def create_app() -> Flask:
|
||||
"published_at": row["published_at"],
|
||||
"publisher_name": row["publisher_name"],
|
||||
"visibility": row["visibility"],
|
||||
"scrutiny_status": row["scrutiny_status"],
|
||||
"scrutiny_report": scrutiny_report,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
@@ -2388,6 +2488,92 @@ def create_app() -> Flask:
|
||||
|
||||
return jsonify({"data": {"status": "rejected", "tool_id": tool_id}})
|
||||
|
||||
@app.route("/api/v1/admin/scrutiny", methods=["GET"])
|
||||
@require_moderator
|
||||
def admin_scrutiny_audit() -> Response:
|
||||
"""List all tools with their scrutiny status for audit purposes."""
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = min(request.args.get("per_page", 50, type=int), 100)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
# Filter options
|
||||
scrutiny_filter = request.args.get("scrutiny_status") # approved, pending_review, pending
|
||||
moderation_filter = request.args.get("moderation_status") # approved, pending, rejected
|
||||
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if scrutiny_filter:
|
||||
where_clauses.append("t.scrutiny_status = ?")
|
||||
params.append(scrutiny_filter)
|
||||
if moderation_filter:
|
||||
where_clauses.append("t.moderation_status = ?")
|
||||
params.append(moderation_filter)
|
||||
|
||||
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||
|
||||
rows = query_all(
|
||||
g.db,
|
||||
f"""
|
||||
SELECT t.id, t.owner, t.name, t.version, t.description, t.category,
|
||||
t.scrutiny_status, t.scrutiny_report, t.moderation_status,
|
||||
t.moderation_note, t.published_at, p.display_name as publisher_name
|
||||
FROM tools t
|
||||
JOIN publishers p ON t.publisher_id = p.id
|
||||
WHERE {where_sql}
|
||||
ORDER BY t.published_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
params + [per_page, offset],
|
||||
)
|
||||
|
||||
count_row = query_one(
|
||||
g.db,
|
||||
f"SELECT COUNT(*) as total FROM tools t WHERE {where_sql}",
|
||||
params,
|
||||
)
|
||||
total = count_row["total"] if count_row else 0
|
||||
|
||||
data = []
|
||||
for row in rows:
|
||||
scrutiny_report = None
|
||||
if row["scrutiny_report"]:
|
||||
try:
|
||||
scrutiny_report = json.loads(row["scrutiny_report"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
data.append({
|
||||
"id": row["id"],
|
||||
"owner": row["owner"],
|
||||
"name": row["name"],
|
||||
"version": row["version"],
|
||||
"description": row["description"],
|
||||
"category": row["category"],
|
||||
"published_at": row["published_at"],
|
||||
"publisher_name": row["publisher_name"],
|
||||
"scrutiny_status": row["scrutiny_status"],
|
||||
"scrutiny_report": scrutiny_report,
|
||||
"moderation_status": row["moderation_status"],
|
||||
"moderation_note": row["moderation_note"],
|
||||
})
|
||||
|
||||
# Also get summary stats
|
||||
stats = query_all(
|
||||
g.db,
|
||||
"""
|
||||
SELECT scrutiny_status, moderation_status, COUNT(*) as count
|
||||
FROM tools
|
||||
GROUP BY scrutiny_status, moderation_status
|
||||
""",
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"data": data,
|
||||
"meta": paginate(page, per_page, total),
|
||||
"stats": [dict(s) for s in stats],
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/tools/<int:tool_id>/remove", methods=["POST"])
|
||||
@require_moderator
|
||||
def admin_remove_tool(tool_id: int) -> Response:
|
||||
|
||||
@@ -499,12 +499,13 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
||||
pass
|
||||
|
||||
# Grandfather existing tools: set moderation_status to 'approved' for tools that have NULL
|
||||
# This ensures existing tools remain visible after migration
|
||||
# This ensures existing tools remain visible after migration (one-time migration)
|
||||
# Note: Only applies to NULL, NOT to 'pending' - pending tools need manual review
|
||||
try:
|
||||
conn.execute("""
|
||||
UPDATE tools
|
||||
SET moderation_status = 'approved'
|
||||
WHERE moderation_status IS NULL OR moderation_status = 'pending'
|
||||
WHERE moderation_status IS NULL
|
||||
""")
|
||||
conn.commit()
|
||||
except sqlite3.OperationalError:
|
||||
|
||||
Reference in New Issue
Block a user