diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py index 4cfb5aa..f433fc8 100644 --- a/src/cmdforge/registry/app.py +++ b/src/cmdforge/registry/app.py @@ -311,7 +311,7 @@ def create_app() -> Flask: row = query_one( g.db, """ - SELECT t.*, p.slug, p.display_name + SELECT t.*, p.slug, p.display_name, p.role, p.banned, p.ban_reason FROM api_tokens t JOIN publishers p ON t.publisher_id = p.id WHERE t.token_hash = ? AND t.revoked_at IS NULL @@ -321,6 +321,14 @@ def create_app() -> Flask: if not row: return error_response("UNAUTHORIZED", "Invalid or revoked token", 401) + # Check if publisher is banned + if row["banned"]: + return error_response( + "ACCOUNT_BANNED", + f"Your account has been banned: {row['ban_reason'] or 'No reason given'}", + 403, + ) + g.db.execute( "UPDATE api_tokens SET last_used_at = ? WHERE id = ?", [datetime.utcnow().isoformat(), row["id"]], @@ -329,6 +337,7 @@ def create_app() -> Flask: "id": row["publisher_id"], "slug": row["slug"], "display_name": row["display_name"], + "role": row["role"] or "user", } g.current_token = {"id": row["id"], "hash": token_hash} g.db.commit() @@ -336,6 +345,99 @@ def create_app() -> Flask: return decorated + def require_role(*roles): + """Decorator that requires the user to have one of the specified roles.""" + def decorator(f): + @wraps(f) + @require_token + def decorated(*args, **kwargs): + user_role = g.current_publisher.get("role", "user") + if user_role not in roles: + return error_response( + "FORBIDDEN", + f"This action requires one of these roles: {', '.join(roles)}", + 403, + ) + return f(*args, **kwargs) + return decorated + return decorator + + def require_admin(f): + """Decorator that requires admin role.""" + return require_role("admin")(f) + + def require_moderator(f): + """Decorator that requires moderator or admin role.""" + return require_role("moderator", "admin")(f) + + def log_audit(action: str, target_type: str, target_id: str, details: dict = None) -> None: + """Log a moderation action to the audit trail.""" + actor_id = g.current_publisher["id"] if hasattr(g, "current_publisher") and g.current_publisher else None + g.db.execute( + """ + INSERT INTO audit_log (action, target_type, target_id, actor_id, details) + VALUES (?, ?, ?, ?, ?) + """, + [action, target_type, str(target_id), str(actor_id) if actor_id else "system", + json.dumps(details) if details else None], + ) + g.db.commit() + + def get_current_user_context() -> Tuple[Optional[str], Optional[str]]: + """Get current user's slug and role from request context. + + Tries to authenticate via Bearer token without requiring it. + Returns (slug, role) tuple, both None if not authenticated. + """ + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return None, None + + token = auth_header[7:] + token_hash = hashlib.sha256(token.encode()).hexdigest() + row = query_one( + g.db, + """ + SELECT p.slug, p.role, p.banned + FROM api_tokens t + JOIN publishers p ON t.publisher_id = p.id + WHERE t.token_hash = ? AND t.revoked_at IS NULL + """, + [token_hash], + ) + if not row or row["banned"]: + return None, None + return row["slug"], row["role"] or "user" + + def build_visibility_filter(table_prefix: str = "") -> Tuple[str, List[Any]]: + """Build SQL WHERE clause for tool visibility filtering. + + Returns (sql_clause, params) tuple. + The clause includes leading AND or WHERE as appropriate. + """ + prefix = f"{table_prefix}." if table_prefix else "" + user_slug, user_role = get_current_user_context() + + # Moderators and admins see everything + if user_role in ("moderator", "admin"): + return "", [] + + # Regular users see: + # 1. Approved public tools + # 2. Their own tools (any status/visibility) + if user_slug: + return ( + f" AND (({prefix}visibility = 'public' AND {prefix}moderation_status = 'approved') " + f"OR {prefix}owner = ?)", + [user_slug], + ) + else: + # Unauthenticated users only see approved public tools + return ( + f" AND {prefix}visibility = 'public' AND {prefix}moderation_status = 'approved'", + [], + ) + def generate_token() -> Tuple[str, str]: alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" raw = secrets.token_bytes(32) @@ -415,11 +517,17 @@ def create_app() -> Flask: category = request.args.get("category") offset = (page - 1) * per_page + # Build visibility filter + vis_filter, vis_params = build_visibility_filter() + base_where = "WHERE 1=1" params: List[Any] = [] if category: base_where += " AND category = ?" params.append(category) + # Add visibility filter + base_where += vis_filter + params.extend(vis_params) count_row = query_one( g.db, @@ -543,6 +651,13 @@ def create_app() -> Flask: if not include_deprecated: where_clauses.append("tools.deprecated = 0") + # Add visibility filtering + vis_filter, vis_params = build_visibility_filter("tools") + if vis_filter: + # Remove leading " AND " since we're adding to a list + where_clauses.append(vis_filter.strip().lstrip("AND").strip()) + params.extend(vis_params) + where_clause = "WHERE " + " AND ".join(where_clauses) # Tag filtering CTE (AND logic - must have ALL specified tags) @@ -740,6 +855,22 @@ def create_app() -> Flask: if not row: return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' does not exist", 404) + # Check visibility permissions + user_slug, user_role = get_current_user_context() + is_elevated = user_role in ("moderator", "admin") + is_owner = user_slug == row["owner"] + visibility = row.get("visibility", "public") + moderation_status = row.get("moderation_status", "approved") + + # Public tools require approval, unlisted tools accessible by direct link, private only to owner + is_approved = moderation_status == "approved" + is_public_approved = visibility == "public" and is_approved + is_unlisted_approved = visibility == "unlisted" and is_approved + is_accessible = is_elevated or is_owner or is_public_approved or is_unlisted_approved + + if not is_accessible: + return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' does not exist", 404) + # Parse source attribution source_obj = None if row["source_json"]: @@ -819,6 +950,22 @@ def create_app() -> Flask: }, ) + # Check visibility permissions + user_slug, user_role = get_current_user_context() + is_elevated = user_role in ("moderator", "admin") + is_owner = user_slug == row["owner"] + visibility = row.get("visibility", "public") + moderation_status = row.get("moderation_status", "approved") + + # Public tools require approval, unlisted tools accessible by direct link, private only to owner + is_approved = moderation_status == "approved" + is_public_approved = visibility == "public" and is_approved + is_unlisted_approved = visibility == "unlisted" and is_approved + is_accessible = is_elevated or is_owner or is_public_approved or is_unlisted_approved + + if not is_accessible: + return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' does not exist", 404) + if install_flag: client_id = request.headers.get("X-Client-ID") if not client_id: @@ -1351,6 +1498,11 @@ def create_app() -> Flask: category = (data.get("category") or "").strip() or None tags = data.get("tags") or [] + # Parse visibility from payload or config YAML + visibility = (payload.get("visibility") or data.get("visibility") or "public").strip().lower() + if visibility not in ("public", "private", "unlisted"): + return error_response("VALIDATION_ERROR", "Invalid visibility. Must be: public, private, unlisted") + # Handle source attribution - can be a dict (full ToolSource) or string (legacy) source_data = data.get("source") source_json = None @@ -1479,13 +1631,22 @@ def create_app() -> Flask: scrutiny_json = json.dumps(scrutiny_report) if scrutiny_report else None + # Determine moderation_status based on visibility + # Private and unlisted tools are auto-approved (no moderation needed) + if visibility in ("private", "unlisted"): + moderation_status = "approved" + else: + # Public tools need moderation approval + moderation_status = "pending" + g.db.execute( """ INSERT INTO tools ( owner, name, version, description, category, tags, config_yaml, readme, publisher_id, deprecated, deprecated_message, replacement, downloads, - scrutiny_status, scrutiny_report, source, source_url, source_json, published_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + scrutiny_status, scrutiny_report, source, source_url, source_json, + visibility, moderation_status, published_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ owner, @@ -1506,6 +1667,8 @@ def create_app() -> Flask: source, source_url, source_json, + visibility, + moderation_status, datetime.utcnow().isoformat(), ], ) @@ -1517,7 +1680,8 @@ def create_app() -> Flask: "name": name, "version": version, "pr_url": "", - "status": scrutiny_status, + "status": moderation_status, + "visibility": visibility, "suggestions": suggestions, } }) @@ -1936,6 +2100,595 @@ def create_app() -> Flask: return jsonify({"data": {"deployed": True, "message": "Deploy triggered"}}) + # ─── Admin API Endpoints ───────────────────────────────────────────────────── + + @app.route("/api/v1/admin/tools/pending", methods=["GET"]) + @require_moderator + def admin_pending_tools() -> Response: + """List tools pending moderation.""" + page = request.args.get("page", 1, type=int) + per_page = min(request.args.get("per_page", 20, type=int), 100) + offset = (page - 1) * per_page + + rows = query_all( + g.db, + """ + SELECT t.*, p.display_name as publisher_name + FROM tools t + JOIN publishers p ON t.publisher_id = p.id + WHERE t.moderation_status = 'pending' + ORDER BY t.published_at ASC + LIMIT ? OFFSET ? + """, + [per_page, offset], + ) + + count_row = query_one( + g.db, + "SELECT COUNT(*) as total FROM tools WHERE moderation_status = 'pending'", + ) + total = count_row["total"] if count_row else 0 + + data = [] + for row in rows: + 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"], + "visibility": row["visibility"], + }) + + return jsonify({ + "data": data, + "meta": paginate(page, per_page, total), + }) + + @app.route("/api/v1/admin/tools//approve", methods=["POST"]) + @require_moderator + def admin_approve_tool(tool_id: int) -> Response: + """Approve a pending tool.""" + tool = query_one(g.db, "SELECT * FROM tools WHERE id = ?", [tool_id]) + if not tool: + return error_response("TOOL_NOT_FOUND", "Tool not found", 404) + + g.db.execute( + """ + UPDATE tools + SET moderation_status = 'approved', + moderated_by = ?, + moderated_at = ? + WHERE id = ? + """, + [g.current_publisher["slug"], datetime.utcnow().isoformat(), tool_id], + ) + g.db.commit() + + log_audit("approve_tool", "tool", str(tool_id), { + "tool": f"{tool['owner']}/{tool['name']}", + "version": tool["version"], + }) + + return jsonify({"data": {"status": "approved", "tool_id": tool_id}}) + + @app.route("/api/v1/admin/tools//reject", methods=["POST"]) + @require_moderator + def admin_reject_tool(tool_id: int) -> Response: + """Reject a pending tool with a reason.""" + data = request.get_json() or {} + reason = (data.get("reason") or "").strip() + + if not reason: + return error_response("VALIDATION_ERROR", "Rejection reason is required", 400) + + tool = query_one(g.db, "SELECT * FROM tools WHERE id = ?", [tool_id]) + if not tool: + return error_response("TOOL_NOT_FOUND", "Tool not found", 404) + + g.db.execute( + """ + UPDATE tools + SET moderation_status = 'rejected', + moderation_note = ?, + moderated_by = ?, + moderated_at = ? + WHERE id = ? + """, + [reason, g.current_publisher["slug"], datetime.utcnow().isoformat(), tool_id], + ) + g.db.commit() + + log_audit("reject_tool", "tool", str(tool_id), { + "tool": f"{tool['owner']}/{tool['name']}", + "version": tool["version"], + "reason": reason, + }) + + return jsonify({"data": {"status": "rejected", "tool_id": tool_id}}) + + @app.route("/api/v1/admin/tools//remove", methods=["POST"]) + @require_moderator + def admin_remove_tool(tool_id: int) -> Response: + """Remove an approved tool (soft delete).""" + data = request.get_json() or {} + reason = (data.get("reason") or "").strip() + + tool = query_one(g.db, "SELECT * FROM tools WHERE id = ?", [tool_id]) + if not tool: + return error_response("TOOL_NOT_FOUND", "Tool not found", 404) + + g.db.execute( + """ + UPDATE tools + SET moderation_status = 'removed', + moderation_note = ?, + moderated_by = ?, + moderated_at = ? + WHERE id = ? + """, + [reason or None, g.current_publisher["slug"], datetime.utcnow().isoformat(), tool_id], + ) + g.db.commit() + + log_audit("remove_tool", "tool", str(tool_id), { + "tool": f"{tool['owner']}/{tool['name']}", + "version": tool["version"], + "reason": reason, + }) + + return jsonify({"data": {"status": "removed", "tool_id": tool_id}}) + + @app.route("/api/v1/admin/tools/", methods=["DELETE"]) + @require_admin + def admin_delete_tool(tool_id: int) -> Response: + """Hard delete a tool (admin only).""" + tool = query_one(g.db, "SELECT * FROM tools WHERE id = ?", [tool_id]) + if not tool: + return error_response("TOOL_NOT_FOUND", "Tool not found", 404) + + # Delete associated records first + g.db.execute("DELETE FROM download_stats WHERE tool_id = ?", [tool_id]) + g.db.execute("DELETE FROM reports WHERE tool_id = ?", [tool_id]) + g.db.execute("DELETE FROM featured_tools WHERE tool_id = ?", [tool_id]) + g.db.execute("DELETE FROM tools WHERE id = ?", [tool_id]) + g.db.commit() + + log_audit("delete_tool", "tool", str(tool_id), { + "tool": f"{tool['owner']}/{tool['name']}", + "version": tool["version"], + }) + + return jsonify({"data": {"status": "deleted", "tool_id": tool_id}}) + + @app.route("/api/v1/admin/publishers", methods=["GET"]) + @require_moderator + def admin_list_publishers() -> Response: + """List all publishers with stats.""" + page = request.args.get("page", 1, type=int) + per_page = min(request.args.get("per_page", 20, type=int), 100) + offset = (page - 1) * per_page + + rows = query_all( + g.db, + """ + SELECT p.*, + (SELECT COUNT(*) FROM tools WHERE publisher_id = p.id) as tool_count, + (SELECT SUM(downloads) FROM tools WHERE publisher_id = p.id) as total_downloads + FROM publishers p + ORDER BY p.created_at DESC + LIMIT ? OFFSET ? + """, + [per_page, offset], + ) + + count_row = query_one(g.db, "SELECT COUNT(*) as total FROM publishers") + total = count_row["total"] if count_row else 0 + + data = [] + for row in rows: + data.append({ + "id": row["id"], + "slug": row["slug"], + "display_name": row["display_name"], + "email": row["email"], + "role": row["role"] or "user", + "banned": bool(row["banned"]), + "ban_reason": row["ban_reason"], + "verified": bool(row["verified"]), + "tool_count": row["tool_count"] or 0, + "total_downloads": row["total_downloads"] or 0, + "created_at": row["created_at"], + }) + + return jsonify({ + "data": data, + "meta": paginate(page, per_page, total), + }) + + @app.route("/api/v1/admin/publishers/", methods=["GET"]) + @require_moderator + def admin_get_publisher(publisher_id: int) -> Response: + """Get detailed publisher info.""" + publisher = query_one(g.db, "SELECT * FROM publishers WHERE id = ?", [publisher_id]) + if not publisher: + return error_response("PUBLISHER_NOT_FOUND", "Publisher not found", 404) + + # Get their tools + tools = query_all( + g.db, + """ + SELECT id, owner, name, version, moderation_status, visibility, downloads, published_at + FROM tools + WHERE publisher_id = ? + ORDER BY published_at DESC + """, + [publisher_id], + ) + + # Get their tokens (just counts, not the hashes) + token_count = query_one( + g.db, + "SELECT COUNT(*) as cnt FROM api_tokens WHERE publisher_id = ? AND revoked_at IS NULL", + [publisher_id], + ) + + return jsonify({ + "data": { + "id": publisher["id"], + "slug": publisher["slug"], + "display_name": publisher["display_name"], + "email": publisher["email"], + "bio": publisher["bio"], + "website": publisher["website"], + "role": publisher["role"] or "user", + "banned": bool(publisher["banned"]), + "banned_at": publisher["banned_at"], + "banned_by": publisher["banned_by"], + "ban_reason": publisher["ban_reason"], + "verified": bool(publisher["verified"]), + "created_at": publisher["created_at"], + "active_tokens": token_count["cnt"] if token_count else 0, + "tools": [ + { + "id": t["id"], + "owner": t["owner"], + "name": t["name"], + "version": t["version"], + "moderation_status": t["moderation_status"], + "visibility": t["visibility"], + "downloads": t["downloads"], + "published_at": t["published_at"], + } + for t in tools + ], + } + }) + + @app.route("/api/v1/admin/publishers//ban", methods=["POST"]) + @require_admin + def admin_ban_publisher(publisher_id: int) -> Response: + """Ban a publisher.""" + data = request.get_json() or {} + reason = (data.get("reason") or "").strip() + + if not reason: + return error_response("VALIDATION_ERROR", "Ban reason is required", 400) + + publisher = query_one(g.db, "SELECT * FROM publishers WHERE id = ?", [publisher_id]) + if not publisher: + return error_response("PUBLISHER_NOT_FOUND", "Publisher not found", 404) + + if publisher["role"] == "admin": + return error_response("FORBIDDEN", "Cannot ban an admin", 403) + + now = datetime.utcnow().isoformat() + + # Ban the publisher + g.db.execute( + """ + UPDATE publishers + SET banned = 1, + banned_at = ?, + banned_by = ?, + ban_reason = ? + WHERE id = ? + """, + [now, g.current_publisher["slug"], reason, publisher_id], + ) + + # Revoke all their tokens + g.db.execute( + "UPDATE api_tokens SET revoked_at = ? WHERE publisher_id = ? AND revoked_at IS NULL", + [now, publisher_id], + ) + + # Remove all their tools from public view + g.db.execute( + """ + UPDATE tools + SET moderation_status = 'removed', + moderation_note = 'Publisher banned', + moderated_by = ?, + moderated_at = ? + WHERE publisher_id = ? AND moderation_status != 'removed' + """, + [g.current_publisher["slug"], now, publisher_id], + ) + + g.db.commit() + + log_audit("ban_publisher", "publisher", str(publisher_id), { + "slug": publisher["slug"], + "reason": reason, + }) + + return jsonify({"data": {"status": "banned", "publisher_id": publisher_id}}) + + @app.route("/api/v1/admin/publishers//unban", methods=["POST"]) + @require_admin + def admin_unban_publisher(publisher_id: int) -> Response: + """Unban a publisher.""" + publisher = query_one(g.db, "SELECT * FROM publishers WHERE id = ?", [publisher_id]) + if not publisher: + return error_response("PUBLISHER_NOT_FOUND", "Publisher not found", 404) + + if not publisher["banned"]: + return error_response("VALIDATION_ERROR", "Publisher is not banned", 400) + + g.db.execute( + """ + UPDATE publishers + SET banned = 0, + banned_at = NULL, + banned_by = NULL, + ban_reason = NULL + WHERE id = ? + """, + [publisher_id], + ) + g.db.commit() + + log_audit("unban_publisher", "publisher", str(publisher_id), { + "slug": publisher["slug"], + }) + + return jsonify({"data": {"status": "unbanned", "publisher_id": publisher_id}}) + + @app.route("/api/v1/admin/publishers//role", methods=["POST"]) + @require_admin + def admin_change_role(publisher_id: int) -> Response: + """Change a publisher's role.""" + data = request.get_json() or {} + new_role = (data.get("role") or "").strip() + + if new_role not in ("user", "moderator", "admin"): + return error_response("VALIDATION_ERROR", "Invalid role. Must be: user, moderator, admin", 400) + + publisher = query_one(g.db, "SELECT * FROM publishers WHERE id = ?", [publisher_id]) + if not publisher: + return error_response("PUBLISHER_NOT_FOUND", "Publisher not found", 404) + + old_role = publisher["role"] or "user" + + g.db.execute( + "UPDATE publishers SET role = ? WHERE id = ?", + [new_role, publisher_id], + ) + g.db.commit() + + log_audit("change_role", "publisher", str(publisher_id), { + "slug": publisher["slug"], + "old_role": old_role, + "new_role": new_role, + }) + + return jsonify({"data": {"status": "updated", "publisher_id": publisher_id, "role": new_role}}) + + @app.route("/api/v1/admin/reports", methods=["GET"]) + @require_moderator + def admin_list_reports() -> Response: + """List unresolved reports.""" + page = request.args.get("page", 1, type=int) + per_page = min(request.args.get("per_page", 20, type=int), 100) + status_filter = request.args.get("status", "pending") + offset = (page - 1) * per_page + + where_clause = "WHERE r.status = ?" if status_filter else "WHERE 1=1" + params = [status_filter] if status_filter else [] + + rows = query_all( + g.db, + f""" + SELECT r.*, t.owner, t.name as tool_name, t.version, + p.display_name as reporter_name + FROM reports r + JOIN tools t ON r.tool_id = t.id + LEFT JOIN publishers p ON r.reporter_id = p.id + {where_clause} + ORDER BY r.created_at DESC + LIMIT ? OFFSET ? + """, + params + [per_page, offset], + ) + + count_row = query_one( + g.db, + f"SELECT COUNT(*) as total FROM reports r {where_clause}", + params, + ) + total = count_row["total"] if count_row else 0 + + data = [] + for row in rows: + data.append({ + "id": row["id"], + "tool": f"{row['owner']}/{row['tool_name']}", + "tool_id": row["tool_id"], + "version": row["version"], + "reason": row["reason"], + "details": row["details"], + "reporter_name": row["reporter_name"], + "status": row["status"], + "created_at": row["created_at"], + "resolved_at": row["resolved_at"], + "resolution_note": row["resolution_note"], + }) + + return jsonify({ + "data": data, + "meta": paginate(page, per_page, total), + }) + + @app.route("/api/v1/admin/reports//resolve", methods=["POST"]) + @require_moderator + def admin_resolve_report(report_id: int) -> Response: + """Resolve a report with an action.""" + data = request.get_json() or {} + action = (data.get("action") or "").strip() + note = (data.get("note") or "").strip() + + if action not in ("dismiss", "warn", "remove_tool", "ban_publisher"): + return error_response( + "VALIDATION_ERROR", + "Invalid action. Must be: dismiss, warn, remove_tool, ban_publisher", + 400, + ) + + report = query_one(g.db, "SELECT * FROM reports WHERE id = ?", [report_id]) + if not report: + return error_response("REPORT_NOT_FOUND", "Report not found", 404) + + if report["status"] != "pending": + return error_response("VALIDATION_ERROR", "Report already resolved", 400) + + now = datetime.utcnow().isoformat() + + # Mark report as resolved + g.db.execute( + """ + UPDATE reports + SET status = 'resolved', + resolved_by = ?, + resolved_at = ?, + resolution_note = ? + WHERE id = ? + """, + [g.current_publisher["id"], now, f"{action}: {note}" if note else action, report_id], + ) + + # Take action + if action == "remove_tool": + g.db.execute( + """ + UPDATE tools + SET moderation_status = 'removed', + moderation_note = ?, + moderated_by = ?, + moderated_at = ? + WHERE id = ? + """, + [f"Removed due to report: {note}" if note else "Removed due to report", + g.current_publisher["slug"], now, report["tool_id"]], + ) + elif action == "ban_publisher": + # Get tool's publisher + tool = query_one(g.db, "SELECT publisher_id FROM tools WHERE id = ?", [report["tool_id"]]) + if tool: + g.db.execute( + """ + UPDATE publishers + SET banned = 1, banned_at = ?, banned_by = ?, ban_reason = ? + WHERE id = ? + """, + [now, g.current_publisher["slug"], + f"Banned due to report: {note}" if note else "Banned due to report", + tool["publisher_id"]], + ) + # Revoke tokens + g.db.execute( + "UPDATE api_tokens SET revoked_at = ? WHERE publisher_id = ?", + [now, tool["publisher_id"]], + ) + + g.db.commit() + + log_audit("resolve_report", "report", str(report_id), { + "action": action, + "tool_id": report["tool_id"], + "note": note, + }) + + return jsonify({"data": {"status": "resolved", "report_id": report_id, "action": action}}) + + @app.route("/api/v1/admin/audit-log", methods=["GET"]) + @require_admin + def admin_audit_log() -> Response: + """View audit log entries.""" + page = request.args.get("page", 1, type=int) + per_page = min(request.args.get("per_page", 50, type=int), 200) + target_type = request.args.get("target_type") + target_id = request.args.get("target_id") + actor_id = request.args.get("actor_id") + since = request.args.get("since") + offset = (page - 1) * per_page + + where_clauses = [] + params: List[Any] = [] + + if target_type: + where_clauses.append("target_type = ?") + params.append(target_type) + if target_id: + where_clauses.append("target_id = ?") + params.append(target_id) + if actor_id: + where_clauses.append("actor_id = ?") + params.append(actor_id) + if since: + where_clauses.append("created_at >= ?") + params.append(since) + + where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" + + rows = query_all( + g.db, + f""" + SELECT * FROM audit_log + {where_sql} + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """, + params + [per_page, offset], + ) + + count_row = query_one( + g.db, + f"SELECT COUNT(*) as total FROM audit_log {where_sql}", + params, + ) + total = count_row["total"] if count_row else 0 + + data = [] + for row in rows: + data.append({ + "id": row["id"], + "action": row["action"], + "target_type": row["target_type"], + "target_id": row["target_id"], + "actor_id": row["actor_id"], + "details": json.loads(row["details"]) if row["details"] else None, + "created_at": row["created_at"], + }) + + return jsonify({ + "data": data, + "meta": paginate(page, per_page, total), + }) + return app diff --git a/src/cmdforge/registry/db.py b/src/cmdforge/registry/db.py index 374b19a..5bd9604 100644 --- a/src/cmdforge/registry/db.py +++ b/src/cmdforge/registry/db.py @@ -20,6 +20,11 @@ CREATE TABLE IF NOT EXISTS publishers ( verified BOOLEAN DEFAULT FALSE, locked_until TIMESTAMP, failed_login_attempts INTEGER DEFAULT 0, + role TEXT DEFAULT 'user', + banned INTEGER DEFAULT 0, + banned_at TIMESTAMP, + banned_by TEXT, + ban_reason TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -54,6 +59,11 @@ CREATE TABLE IF NOT EXISTS tools ( source TEXT, source_url TEXT, source_json TEXT, + visibility TEXT DEFAULT 'public', + moderation_status TEXT DEFAULT 'pending', + moderation_note TEXT, + moderated_by TEXT, + moderated_at TIMESTAMP, published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(owner, name, version) ); @@ -188,6 +198,16 @@ CREATE TABLE IF NOT EXISTS reports ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + actor_id TEXT NOT NULL, + details TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS consents ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_id TEXT, @@ -220,6 +240,12 @@ CREATE INDEX IF NOT EXISTS idx_featured_tools_placement ON featured_tools(placem CREATE INDEX IF NOT EXISTS idx_featured_contributors_placement ON featured_contributors(placement, status); CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_content_pages_type ON content_pages(content_type, published); +CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_type, target_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_tools_moderation ON tools(moderation_status, visibility); +CREATE INDEX IF NOT EXISTS idx_publishers_role ON publishers(role); +CREATE INDEX IF NOT EXISTS idx_publishers_banned ON publishers(banned); CREATE TABLE IF NOT EXISTS pageviews ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -289,28 +315,72 @@ def migrate_db(conn: sqlite3.Connection) -> None: """Add missing columns to existing tables (for schema updates).""" # Get existing columns in tools table cursor = conn.execute("PRAGMA table_info(tools)") - existing_cols = {row[1] for row in cursor.fetchall()} + tools_cols = {row[1] for row in cursor.fetchall()} - # Columns that may need to be added (column_name, type, default) - migrations = [ + # Get existing columns in publishers table + cursor = conn.execute("PRAGMA table_info(publishers)") + publishers_cols = {row[1] for row in cursor.fetchall()} + + # Tools table migrations (column_name, type, default) + tools_migrations = [ ("scrutiny_status", "TEXT", "'pending'"), ("scrutiny_report", "TEXT", "NULL"), ("source", "TEXT", "NULL"), ("source_url", "TEXT", "NULL"), ("source_json", "TEXT", "NULL"), + ("visibility", "TEXT", "'public'"), + ("moderation_status", "TEXT", "'pending'"), + ("moderation_note", "TEXT", "NULL"), + ("moderated_by", "TEXT", "NULL"), + ("moderated_at", "TIMESTAMP", "NULL"), ] - for col_name, col_type, default in migrations: - if col_name not in existing_cols: + for col_name, col_type, default in tools_migrations: + if col_name not in tools_cols: try: conn.execute(f"ALTER TABLE tools ADD COLUMN {col_name} {col_type} DEFAULT {default}") conn.commit() except sqlite3.OperationalError: pass # Column might already exist or other issue + # Publishers table migrations + publishers_migrations = [ + ("role", "TEXT", "'user'"), + ("banned", "INTEGER", "0"), + ("banned_at", "TIMESTAMP", "NULL"), + ("banned_by", "TEXT", "NULL"), + ("ban_reason", "TEXT", "NULL"), + ] + + for col_name, col_type, default in publishers_migrations: + if col_name not in publishers_cols: + try: + conn.execute(f"ALTER TABLE publishers ADD COLUMN {col_name} {col_type} DEFAULT {default}") + conn.commit() + except sqlite3.OperationalError: + pass + + # Grandfather existing tools: set moderation_status to 'approved' for tools that have NULL + # This ensures existing tools remain visible after migration + try: + conn.execute(""" + UPDATE tools + SET moderation_status = 'approved' + WHERE moderation_status IS NULL OR moderation_status = 'pending' + """) + conn.commit() + except sqlite3.OperationalError: + pass + # Ensure indexes exist try: conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_owner ON tools(owner)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_moderation ON tools(moderation_status, visibility)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_publishers_role ON publishers(role)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_publishers_banned ON publishers(banned)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_type, target_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)") conn.commit() except sqlite3.OperationalError: pass diff --git a/src/cmdforge/web/routes.py b/src/cmdforge/web/routes.py index 5f4d632..dcf3abd 100644 --- a/src/cmdforge/web/routes.py +++ b/src/cmdforge/web/routes.py @@ -159,13 +159,17 @@ def _load_current_publisher() -> Optional[Dict[str, Any]]: row = query_one( conn, """ - SELECT id, slug, display_name, email, verified, bio, website, created_at + SELECT id, slug, display_name, email, verified, bio, website, role, created_at FROM publishers WHERE slug = ? """, [slug], ) - return dict(row) if row else None + if row: + data = dict(row) + data["role"] = data.get("role") or "user" + return data + return None finally: conn.close() @@ -822,3 +826,124 @@ def collection_detail(name: str): "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 + + +@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, + ) diff --git a/src/cmdforge/web/templates/admin/index.html b/src/cmdforge/web/templates/admin/index.html new file mode 100644 index 0000000..988fa75 --- /dev/null +++ b/src/cmdforge/web/templates/admin/index.html @@ -0,0 +1,95 @@ +{% extends "dashboard/base.html" %} + +{% block dashboard_header %} +
+
+

Admin Panel

+

+ Registry moderation and management + + {{ user.role|capitalize }} + +

+
+
+{% endblock %} + +{% block dashboard_content %} + +{% endblock %} diff --git a/src/cmdforge/web/templates/admin/pending.html b/src/cmdforge/web/templates/admin/pending.html new file mode 100644 index 0000000..0c055b2 --- /dev/null +++ b/src/cmdforge/web/templates/admin/pending.html @@ -0,0 +1,164 @@ +{% extends "dashboard/base.html" %} + +{% block dashboard_header %} +
+
+

Pending Tools

+

Review and approve submitted tools

+
+ ← Back to Admin +
+{% endblock %} + +{% block dashboard_content %} +
+ {% if tools %} +
+ + + + + + + + + + + + {% for tool in tools %} + + + + + + + + {% endfor %} + +
ToolPublisherCategorySubmittedActions
+
+
+
{{ tool.owner }}/{{ tool.name }}
+
v{{ tool.version }}
+
+
+
+
{{ tool.publisher_name }}
+
+ + {{ tool.category or 'Uncategorized' }} + + + {{ tool.published_at[:10] if tool.published_at else 'Unknown' }} + + + +
+
+ + + {% if meta.total_pages > 1 %} +
+
+ Page {{ meta.page }} of {{ meta.total_pages }} ({{ meta.total }} total) +
+
+ {% if meta.page > 1 %} + Previous + {% endif %} + {% if meta.page < meta.total_pages %} + Next + {% endif %} +
+
+ {% endif %} + {% else %} +
+ + + +

No pending tools

+

All submitted tools have been reviewed.

+
+ {% endif %} +
+ + + + + +{% endblock %} diff --git a/src/cmdforge/web/templates/admin/publishers.html b/src/cmdforge/web/templates/admin/publishers.html new file mode 100644 index 0000000..289b58e --- /dev/null +++ b/src/cmdforge/web/templates/admin/publishers.html @@ -0,0 +1,237 @@ +{% extends "dashboard/base.html" %} + +{% block dashboard_header %} +
+
+

Publishers

+

Manage registry publishers

+
+ ← Back to Admin +
+{% endblock %} + +{% block dashboard_content %} +
+ {% if publishers %} +
+ + + + + + + + + + + + + {% for pub in publishers %} + + + + + + + + + {% endfor %} + +
PublisherRoleToolsDownloadsStatusActions
+
+
+
{{ pub.display_name }}
+
@{{ pub.slug }}
+
+
+
+ + {{ pub.role }} + + + {{ pub.tool_count }} + + {{ pub.total_downloads|default(0)|int }} + + {% if pub.banned %} + + Banned + + {% elif pub.verified %} + + Verified + + {% else %} + + Active + + {% endif %} + + {% if user.role == 'admin' and pub.role != 'admin' %} + {% if pub.banned %} + + {% else %} + + {% endif %} + + {% endif %} +
+
+ + + {% if meta.total_pages > 1 %} +
+
+ Page {{ meta.page }} of {{ meta.total_pages }} ({{ meta.total }} total) +
+
+ {% if meta.page > 1 %} + Previous + {% endif %} + {% if meta.page < meta.total_pages %} + Next + {% endif %} +
+
+ {% endif %} + {% else %} +
+

No publishers found

+
+ {% endif %} +
+ + + + + + + + +{% endblock %} diff --git a/src/cmdforge/web/templates/admin/reports.html b/src/cmdforge/web/templates/admin/reports.html new file mode 100644 index 0000000..00608ba --- /dev/null +++ b/src/cmdforge/web/templates/admin/reports.html @@ -0,0 +1,177 @@ +{% extends "dashboard/base.html" %} + +{% block dashboard_header %} +
+
+

Reports

+

Review and resolve tool reports

+
+ ← Back to Admin +
+{% endblock %} + +{% block dashboard_content %} +
+ + + +
+ {% if reports %} +
+ + + + + + + + + + + + + {% for report in reports %} + + + + + + + + + {% endfor %} + +
ToolReasonReporterDateStatusActions
+
{{ report.tool }}
+
v{{ report.version }}
+
+
{{ report.reason }}
+ {% if report.details %} +
{{ report.details }}
+ {% endif %} +
+ {{ report.reporter_name or 'Anonymous' }} + + {{ report.created_at[:10] if report.created_at else 'Unknown' }} + + + {{ report.status }} + + + {% if report.status == 'pending' %} + + {% else %} + {{ report.resolution_note or 'Resolved' }} + {% endif %} +
+
+ + + {% if meta.total_pages > 1 %} +
+
+ Page {{ meta.page }} of {{ meta.total_pages }} ({{ meta.total }} total) +
+
+ {% if meta.page > 1 %} + Previous + {% endif %} + {% if meta.page < meta.total_pages %} + Next + {% endif %} +
+
+ {% endif %} + {% else %} +
+ + + +

No reports

+

No reports matching this filter.

+
+ {% endif %} +
+
+ + + + + +{% endblock %} diff --git a/src/cmdforge/web/templates/dashboard/base.html b/src/cmdforge/web/templates/dashboard/base.html index d435467..896d76a 100644 --- a/src/cmdforge/web/templates/dashboard/base.html +++ b/src/cmdforge/web/templates/dashboard/base.html @@ -6,8 +6,19 @@
{% block dashboard_header %} -

Dashboard

-

Welcome back, {{ user.display_name }}

+
+
+

Dashboard

+

+ Welcome back, {{ user.display_name }} + {% if user.role in ('moderator', 'admin') %} + + {{ user.role|capitalize }} + + {% endif %} +

+
+
{% endblock %}
@@ -46,6 +57,17 @@ Settings + {% if user.role in ('moderator', 'admin') %} + + {% endif %} diff --git a/src/cmdforge/web/templates/errors/403.html b/src/cmdforge/web/templates/errors/403.html new file mode 100644 index 0000000..c022bcc --- /dev/null +++ b/src/cmdforge/web/templates/errors/403.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}Access Denied - CmdForge{% endblock %} + +{% block content %} +
+
+

403

+

Access Denied

+

+ Sorry, you don't have permission to access this page. This area is restricted to administrators and moderators. +

+ +
+
+{% endblock %}