Fix market

parent a8b5285c
......@@ -5,6 +5,7 @@ import time
import logging
import threading
import hmac as _hmac
import ipaddress
from typing import Optional
from fastapi import Request
from fastapi.responses import JSONResponse, RedirectResponse
......@@ -83,6 +84,23 @@ def _is_local_client(request: Request) -> bool:
return ip in _LOCAL_IPS if ip else False
def _is_private_or_local_ip(ip: Optional[str]) -> bool:
if not ip:
return False
if ip in _LOCAL_IPS:
return True
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
return any((
addr.is_private,
addr.is_loopback,
addr.is_link_local,
addr.is_reserved,
))
class GenocidalBlockingMiddleware(BaseHTTPMiddleware):
"""Block Israeli IPs/domains."""
......@@ -105,7 +123,7 @@ class GenocidalBlockingMiddleware(BaseHTTPMiddleware):
return True
from aisbf import geolocation
client_ip = _get_client_ip(request)
if client_ip:
if client_ip and not _is_private_or_local_ip(client_ip):
country = await geolocation.get_ip_country(client_ip)
if country == 'IL':
return True
......
......@@ -3534,6 +3534,77 @@ class DatabaseManager:
cursor.execute(query)
return [self._load_market_listing_row(row) for row in cursor.fetchall()]
def list_market_listings_paginated(
self,
page: int = 1,
limit: int = 25,
search: Optional[str] = None,
source_type: Optional[str] = None,
active_filter: Optional[str] = None,
online_filter: Optional[str] = None,
owner_username: Optional[str] = None,
) -> Dict[str, Any]:
page = max(int(page or 1), 1)
limit = max(1, min(int(limit or 25), 100))
offset = (page - 1) * limit
where_clauses = []
params: List[Any] = []
placeholder = self.placeholder
if search:
like = f"%{search.strip()}%"
where_clauses.append(
f"(title LIKE {placeholder} OR description LIKE {placeholder} OR source_id LIKE {placeholder} OR provider_id LIKE {placeholder} OR model_id LIKE {placeholder} OR owner_username LIKE {placeholder})"
)
params.extend([like, like, like, like, like, like])
if source_type:
where_clauses.append(f"source_type = {placeholder}")
params.append(source_type)
if owner_username:
where_clauses.append(f"owner_username = {placeholder}")
params.append(owner_username)
if active_filter == 'active':
where_clauses.append("is_active = 1")
elif active_filter == 'inactive':
where_clauses.append("is_active = 0")
if online_filter == 'online':
where_clauses.append("provider_id IS NOT NULL")
elif online_filter == 'offline':
where_clauses.append("provider_id IS NULL")
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"SELECT COUNT(*) FROM market_listings{where_sql}", tuple(params))
total_row = cursor.fetchone()
total = int(total_row[0] if total_row else 0)
query = f'''
SELECT id, owner_user_id, owner_username, source_scope, source_type, source_id, listing_key,
title, description, provider_id, model_id, endpoint, currency_code,
price_per_million_tokens, metadata, config_snapshot, price_per_1000_requests,
provider_price_per_million_tokens, provider_price_per_1000_requests, is_active,
created_at, updated_at
FROM market_listings
{where_sql}
ORDER BY created_at DESC
LIMIT {placeholder} OFFSET {placeholder}
'''
cursor.execute(query, tuple(params + [limit, offset]))
items = [self._load_market_listing_row(row) for row in cursor.fetchall()]
return {
'items': items,
'total': total,
'page': page,
'limit': limit,
}
def get_market_listing(self, listing_id: int) -> Optional[Dict[str, Any]]:
with self._get_connection() as conn:
cursor = conn.cursor()
......
......@@ -153,6 +153,19 @@ def _stamp_provider_models(provider_config: dict) -> dict:
return stamped
def _json_parse_bootstrap(payload) -> str:
raw_json = json.dumps(payload)
escaped_json = (
raw_json
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
)
quoted = escaped_json.replace("\\", "\\\\").replace('"', '\\"')
quoted = quoted.replace("\\\\u003c", "\\u003c").replace("\\\\u003e", "\\u003e").replace("\\\\u0026", "\\u0026")
return f'"{quoted}"'
def _ensure_coderai_token(provider_config: dict) -> dict:
stamped = dict(provider_config or {})
if stamped.get('type') != 'coderai':
......@@ -249,18 +262,22 @@ def _augment_provider_broker_status(provider_id: str, provider_config: dict, bro
return stamped
status = broker_status_map.get(provider_id)
coderai_config = dict(stamped.get('coderai_config') or {})
status_metadata = dict((status or {}).get('metadata') or {})
status_performance = dict((status or {}).get('performance') or {})
coderai_config['broker_session'] = {
'connected': bool(status),
'client_id': status.get('client_id') if status else None,
'session_id': status.get('session_id') if status else None,
'connected_at': status.get('connected_at') if status else None,
'last_seen': status.get('last_seen') if status else None,
'owner_user_id': ((status or {}).get('metadata') or {}).get('owner_user_id'),
'transport': ((status or {}).get('metadata') or {}).get('transport'),
'endpoint': ((status or {}).get('metadata') or {}).get('endpoint'),
'studio_endpoints': ((status or {}).get('metadata') or {}).get('studio_endpoints') or [],
'owner_user_id': status_metadata.get('owner_user_id'),
'transport': status_metadata.get('transport'),
'endpoint': status_metadata.get('endpoint'),
'studio_endpoints': status_metadata.get('studio_endpoints') or [],
'capabilities': (status or {}).get('capabilities') or {},
'connection_state': ((status or {}).get('metadata') or {}).get('connection_state') or ('connected' if status else 'disconnected'),
'connection_state': status_metadata.get('connection_state') or ('connected' if status else 'disconnected'),
'metadata': status_metadata,
'performance': status_performance,
}
stamped['coderai_config'] = coderai_config
return stamped
......@@ -272,6 +289,14 @@ def init(config, templates, server_config=None):
_server_config = server_config
def _get_templates():
global _templates
if _templates is None:
from main import templates as main_templates
_templates = main_templates
return _templates
def get_user_auth_files_dir(user_id) -> Path:
auth_files_dir = Path.home() / '.aisbf' / 'user_auth_files' / str(user_id)
auth_files_dir.mkdir(parents=True, exist_ok=True)
......@@ -333,7 +358,7 @@ async def dashboard_index(request: Request):
# Admin dashboard
db = DatabaseRegistry.get_config_database()
users_count = len(db.get_users())
return _templates.TemplateResponse(
return _get_templates().TemplateResponse(
request=request,
name="dashboard/index.html",
context={
......@@ -403,7 +428,7 @@ async def dashboard_index(request: Request):
elif all_tiers:
upgrade_tiers = [t for t in all_tiers if not t.get('is_default')]
return _templates.TemplateResponse(
return _get_templates().TemplateResponse(
request=request,
name="dashboard/user_index.html",
context={
......@@ -438,7 +463,17 @@ async def dashboard_providers(request: Request):
broker_status_map = await _load_coderai_broker_status_map()
if is_config_admin:
# Config admin: load from JSON files
# Config admin: prefer live in-memory config when available
live_config = _config
if live_config is None:
from aisbf.config import config as global_config
live_config = global_config
if live_config and getattr(live_config, 'providers', None):
providers_data = {
provider_id: (provider.model_dump() if hasattr(provider, 'model_dump') else dict(provider))
for provider_id, provider in live_config.providers.items()
}
else:
config_path = Path.home() / '.aisbf' / 'providers.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'providers.json'
......@@ -503,10 +538,10 @@ async def dashboard_providers(request: Request):
"request": request,
"session": request.session,
"__version__": __version__,
"providers_json": json.dumps(providers_data),
"studio_capability_choices_json": json.dumps(serialize_studio_capability_choices()),
"studio_adapter_choices_json": json.dumps(serialize_studio_adapter_choices()),
"studio_adapter_profile_choices_json": json.dumps(serialize_studio_adapter_profile_choices()),
"providers_data": providers_data,
"studio_capability_choices": serialize_studio_capability_choices(),
"studio_adapter_choices": serialize_studio_adapter_choices(),
"studio_adapter_profile_choices": serialize_studio_adapter_profile_choices(),
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"success": "Configuration saved successfully!" if success else None
......@@ -521,10 +556,11 @@ async def dashboard_providers(request: Request):
"request": request,
"session": request.session,
"__version__": __version__,
"user_providers_json": json.dumps(providers_data),
"studio_capability_choices_json": json.dumps(serialize_studio_capability_choices()),
"studio_adapter_choices_json": json.dumps(serialize_studio_adapter_choices()),
"studio_adapter_profile_choices_json": json.dumps(serialize_studio_adapter_profile_choices()),
"user_providers_data": providers_data,
"user_providers_bootstrap_json": _json_parse_bootstrap(providers_data),
"studio_capability_choices": serialize_studio_capability_choices(),
"studio_adapter_choices": serialize_studio_adapter_choices(),
"studio_adapter_profile_choices": serialize_studio_adapter_profile_choices(),
"user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
......@@ -551,7 +587,7 @@ async def dashboard_studio(request: Request):
db=db,
)
return _templates.TemplateResponse(
return _get_templates().TemplateResponse(
request=request,
name="dashboard/studio.html",
context={
......@@ -814,17 +850,19 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
if is_config_admin:
success_msg = "Configuration saved successfully!"
return _templates.TemplateResponse(
if _templates is None:
return JSONResponse({"success": True, "message": success_msg, "providers_data": providers_data})
return _get_templates().TemplateResponse(
request=request,
name="dashboard/providers.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"providers_json": json.dumps(providers_data),
"studio_capability_choices_json": json.dumps(serialize_studio_capability_choices()),
"studio_adapter_choices_json": json.dumps(serialize_studio_adapter_choices()),
"studio_adapter_profile_choices_json": json.dumps(serialize_studio_adapter_profile_choices()),
"providers_data": providers_data,
"studio_capability_choices": serialize_studio_capability_choices(),
"studio_adapter_choices": serialize_studio_adapter_choices(),
"studio_adapter_profile_choices": serialize_studio_adapter_profile_choices(),
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"success": success_msg
......@@ -832,18 +870,21 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
)
else:
success_msg = "Configuration saved successfully!"
if _templates is None:
return JSONResponse({"success": True, "message": success_msg, "providers_data": providers_data})
return _templates.TemplateResponse(
return _get_templates().TemplateResponse(
request=request,
name="dashboard/user_providers.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_providers_json": json.dumps(providers_data),
"studio_capability_choices_json": json.dumps(serialize_studio_capability_choices()),
"studio_adapter_choices_json": json.dumps(serialize_studio_adapter_choices()),
"studio_adapter_profile_choices_json": json.dumps(serialize_studio_adapter_profile_choices()),
"user_providers_data": providers_data,
"user_providers_bootstrap_json": _json_parse_bootstrap(providers_data),
"studio_capability_choices": serialize_studio_capability_choices(),
"studio_adapter_choices": serialize_studio_adapter_choices(),
"studio_adapter_profile_choices": serialize_studio_adapter_profile_choices(),
"user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
......@@ -868,17 +909,17 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
else:
providers_data = {k: v for k, v in full_config.items() if k != 'condensation'}
return _templates.TemplateResponse(
return _get_templates().TemplateResponse(
request=request,
name="dashboard/providers.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"providers_json": json.dumps(providers_data),
"studio_capability_choices_json": json.dumps(serialize_studio_capability_choices()),
"studio_adapter_choices_json": json.dumps(serialize_studio_adapter_choices()),
"studio_adapter_profile_choices_json": json.dumps(serialize_studio_adapter_profile_choices()),
"providers_data": providers_data,
"studio_capability_choices": serialize_studio_capability_choices(),
"studio_adapter_choices": serialize_studio_adapter_choices(),
"studio_adapter_profile_choices": serialize_studio_adapter_profile_choices(),
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"error": f"Invalid JSON: {str(e)}"
......@@ -888,17 +929,18 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
return _templates.TemplateResponse(
return _get_templates().TemplateResponse(
request=request,
name="dashboard/user_providers.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_providers_json": json.dumps(user_providers),
"studio_capability_choices_json": json.dumps(serialize_studio_capability_choices()),
"studio_adapter_choices_json": json.dumps(serialize_studio_adapter_choices()),
"studio_adapter_profile_choices_json": json.dumps(serialize_studio_adapter_profile_choices()),
"user_providers_data": user_providers,
"user_providers_bootstrap_json": _json_parse_bootstrap(user_providers),
"studio_capability_choices": serialize_studio_capability_choices(),
"studio_adapter_choices": serialize_studio_adapter_choices(),
"studio_adapter_profile_choices": serialize_studio_adapter_profile_choices(),
"user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
......
......@@ -2,6 +2,19 @@
* Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
*
* AISBF - AI Service Broker Framework || AI Should Be Free
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
.studio {
......
......@@ -2,6 +2,19 @@
* Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
*
* AISBF - AI Service Broker Framework || AI Should Be Free
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// ─────────────────────────────────────────────────────────────────
......
......@@ -98,7 +98,9 @@
{% if pagination.has_prev %}
<a class="btn" href="{{ url_for(request, '/dashboard/admin/market') }}?q={{ filters.q|urlencode }}&source_type={{ filters.source_type|urlencode }}&active_filter={{ filters.active_filter|urlencode }}&online_filter={{ filters.online_filter|urlencode }}&owner_username={{ filters.owner_username|urlencode }}&limit={{ filters.limit }}&page={{ pagination.prev_page }}" style="background: var(--bg-accent); color: var(--color-text);">Previous</a>
{% endif %}
<a class="btn" href="{{ url_for(request, '/dashboard/admin/market') }}?q={{ filters.q|urlencode }}&source_type={{ filters.source_type|urlencode }}&active_filter={{ filters.active_filter|urlencode }}&online_filter={{ filters.online_filter|urlencode }}&owner_username={{ filters.owner_username|urlencode }}&limit={{ filters.limit }}&page={{ pagination.page }}" style="background: var(--color-link); color: white;">Current Page</a>
{% if pagination.total_pages > 1 %}
<a class="btn" href="{{ url_for(request, '/dashboard/admin/market') }}?q={{ filters.q|urlencode }}&source_type={{ filters.source_type|urlencode }}&active_filter={{ filters.active_filter|urlencode }}&online_filter={{ filters.online_filter|urlencode }}&owner_username={{ filters.owner_username|urlencode }}&limit={{ filters.limit }}&page={{ pagination.page }}" style="background: var(--color-link); color: white;">{{ pagination.page }}</a>
{% endif %}
{% if pagination.has_next %}
<a class="btn" href="{{ url_for(request, '/dashboard/admin/market') }}?q={{ filters.q|urlencode }}&source_type={{ filters.source_type|urlencode }}&active_filter={{ filters.active_filter|urlencode }}&online_filter={{ filters.online_filter|urlencode }}&owner_username={{ filters.owner_username|urlencode }}&limit={{ filters.limit }}&page={{ pagination.next_page }}" style="background: var(--bg-accent); color: var(--color-text);">Next</a>
{% endif %}
......
......@@ -19,6 +19,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% block title %}Providers - AISBF Dashboard{% endblock %}
{% block content %}
<script id="providers-bootstrap" type="application/json">{{ providers_data | tojson }}</script>
<h2 style="margin-bottom: 30px;">Providers Configuration</h2>
{% if success %}
......@@ -306,10 +307,10 @@ const IS_LOCAL_CLIENT = {{ 'true' if is_local_client else 'false' }};
const BASE_PATH = {{ (request.scope.get('root_path', '') or '') | tojson }};
// Marker used by the AISBF Chrome Extension to auto-detect this page and configure itself.
window.AISBF_PROVIDERS_PAGE = { serverUrl: window.location.origin + BASE_PATH };
let providersData = JSON.parse({{ providers_json | tojson }});
const STUDIO_CAPABILITY_CHOICES = JSON.parse({{ studio_capability_choices_json | tojson }});
const STUDIO_ADAPTER_CHOICES = JSON.parse({{ studio_adapter_choices_json | tojson }});
const STUDIO_ADAPTER_PROFILE_CHOICES = JSON.parse({{ studio_adapter_profile_choices_json | tojson }});
let providersData = {{ providers_data | tojson }};
const STUDIO_CAPABILITY_CHOICES = {{ studio_capability_choices | tojson }};
const STUDIO_ADAPTER_CHOICES = {{ studio_adapter_choices | tojson }};
const STUDIO_ADAPTER_PROFILE_CHOICES = {{ studio_adapter_profile_choices | tojson }};
let expandedProviders = new Set();
let currentProviderPage = 0;
const PROVIDERS_PAGE_SIZE = 10;
......@@ -1208,11 +1209,11 @@ function renderProviderDetails(key) {
<div><strong>Connected At:</strong> ${escHtmlAttr(formatBrokerTimestamp(brokerSession.connected_at))}</div>
<div><strong>Remote Endpoint:</strong> ${escHtmlAttr(brokerSession.endpoint || 'Unknown')}</div>
<div><strong>Transport:</strong> ${escHtmlAttr(brokerSession.transport || 'broker')}</div>
<div><strong>GPU Count:</strong> ${escHtmlAttr(String(brokerMetadata.gpu_count ?? (Array.isArray(brokerMetadata.gpus) ? brokerMetadata.gpus.length : 0) || '0'))}</div>
<div><strong>GPU Count:</strong> ${escHtmlAttr(String((brokerMetadata.gpu_count ?? (Array.isArray(brokerMetadata.gpus) ? brokerMetadata.gpus.length : 0)) || '0'))}</div>
<div><strong>VRAM:</strong> ${escHtmlAttr(formatVramMb(brokerMetadata.available_vram_mb))} free / ${escHtmlAttr(formatVramMb(brokerMetadata.total_vram_mb))} total</div>
<div><strong>GPUs:</strong> ${escHtmlAttr(renderGpuSummary(brokerMetadata))}</div>
<div><strong>Avg Latency:</strong> ${escHtmlAttr(formatPerfNumber(performance.avg_latency_ms, 1, ' ms'))}</div>
<div><strong>Avg Throughput:</strong> ${escHtmlAttr(formatPerfNumber(performance.avg_tokens_per_second, 2, ' tok/s'))}</div>
<div><strong>Avg Throughput:</strong> ${escHtmlAttr(formatPerfNumber(performance.avg_tokens_per_second, 1, ' tok/s'))}</div>
<div><strong>Avg Tokens:</strong> ${escHtmlAttr(formatPerfNumber(performance.avg_total_tokens, 1))}</div>
<div><strong>Success Rate:</strong> ${escHtmlAttr(formatPerfNumber((performance.success_rate ?? 0) * 100, 1, '%'))}</div>
<div><strong>Samples:</strong> ${escHtmlAttr(String(performance.sample_count ?? 0))} / ${escHtmlAttr(String(performance.window_size ?? 100))}</div>
......
......@@ -27,7 +27,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% block content %}
<script id="studio-bootstrap" type="application/json">{{ studio_bootstrap_json|safe }}</script>
<div class="studio">
<div class="studio" data-studio-shell="dashboard">
<!-- Sidebar -->
<aside class="sidebar">
......@@ -1039,9 +1039,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<!-- ═══════════════ PIPELINES ═══════════════ -->
<div class="panel" id="panel-pipe">
<div class="pipe-panel">
<div class="diag-card" id="studio-diagnostics">
<div class="diag-card" id="studio-diagnostics" data-empty-message="No diagnostics yet.">
<div class="diag-title">Diagnostics</div>
<div class="diag-sub">Evaluated from the current model type, declared capabilities, and existing Studio fallback rules.</div>
<span data-i18n="studio.diagnostics_empty">No diagnostics yet.</span>
<div class="diag-groups" id="diag-groups"></div>
</div>
<div class="hist-card" id="studio-history">
......
......@@ -311,14 +311,14 @@ async function apiCall(method, url, body) {
}
let providersData = {};
const STUDIO_CAPABILITY_CHOICES = JSON.parse({{ studio_capability_choices_json | tojson }});
const STUDIO_ADAPTER_CHOICES = JSON.parse({{ studio_adapter_choices_json | tojson }});
const STUDIO_ADAPTER_PROFILE_CHOICES = JSON.parse({{ studio_adapter_profile_choices_json | tojson }});
const STUDIO_CAPABILITY_CHOICES = {{ studio_capability_choices | tojson }};
const STUDIO_ADAPTER_CHOICES = {{ studio_adapter_choices | tojson }};
const STUDIO_ADAPTER_PROFILE_CHOICES = {{ studio_adapter_profile_choices | tojson }};
let expandedProviders = new Set();
let currentProviderPage = 0;
const PROVIDERS_PAGE_SIZE = 10;
let providerSearchFilter = '';
let rawProviders = JSON.parse({{ user_providers_json | tojson }});
let rawProviders = JSON.parse({{ user_providers_bootstrap_json | safe }});
let providerMasterOrder = [];
let _providerDS = null;
......@@ -1125,7 +1125,7 @@ function renderProviderDetails(key) {
<div><strong>Connected At:</strong> ${escHtmlAttr(formatBrokerTimestamp(brokerSession.connected_at))}</div>
<div><strong>Remote Endpoint:</strong> ${escHtmlAttr(brokerSession.endpoint || 'Unknown')}</div>
<div><strong>Transport:</strong> ${escHtmlAttr(brokerSession.transport || 'broker')}</div>
<div><strong>GPU Count:</strong> ${escHtmlAttr(String(brokerMetadata.gpu_count ?? (Array.isArray(brokerMetadata.gpus) ? brokerMetadata.gpus.length : 0) || '0'))}</div>
<div><strong>GPU Count:</strong> ${escHtmlAttr(String((brokerMetadata.gpu_count ?? (Array.isArray(brokerMetadata.gpus) ? brokerMetadata.gpus.length : 0)) || '0'))}</div>
<div><strong>VRAM:</strong> ${escHtmlAttr(formatVramMb(brokerMetadata.available_vram_mb))} free / ${escHtmlAttr(formatVramMb(brokerMetadata.total_vram_mb))} total</div>
<div><strong>GPUs:</strong> ${escHtmlAttr(renderGpuSummary(brokerMetadata))}</div>
<div><strong>Avg Latency:</strong> ${escHtmlAttr(formatPerfNumber(performance.avg_latency_ms, 1, ' ms'))}</div>
......
......@@ -337,6 +337,17 @@ def _load_json_parse_bootstrap(response_text: str, marker: str):
return json.loads(json.loads(js_string_literal))
def test_json_parse_bootstrap_escapes_inline_script_sequences():
from aisbf.routes.dashboard.providers import _json_parse_bootstrap
payload = [{"config": {"name": 'Alice\'s "Provider" </script><script>alert(1)</script>'}}]
bootstrap = _json_parse_bootstrap(payload)
assert '</script><script>alert(1)</script>' not in bootstrap
assert '\\u003c/script\\u003e\\u003cscript\\u003ealert(1)\\u003c/script\\u003e' in bootstrap
assert json.loads(json.loads(bootstrap))[0]["config"]["name"] == 'Alice\'s "Provider" </script><script>alert(1)</script>'
@pytest.fixture
def runtime_fixture(monkeypatch):
db = MarketReferenceRuntimeDbStub()
......@@ -624,10 +635,10 @@ def test_dashboard_admin_providers_bootstrap_uses_json_parse(monkeypatch):
"request": request,
"session": {},
"__version__": "test",
"providers_json": json.dumps(providers_payload),
"studio_capability_choices_json": "[]",
"studio_adapter_choices_json": "[]",
"studio_adapter_profile_choices_json": "[]",
"providers_data": providers_payload,
"studio_capability_choices": [],
"studio_adapter_choices": [],
"studio_adapter_profile_choices": [],
"claude_cli_mode": False,
"is_local_client": True,
"success": None,
......@@ -637,9 +648,9 @@ def test_dashboard_admin_providers_bootstrap_uses_json_parse(monkeypatch):
response_text = response.body.decode()
assert response.status_code == 200
assert "let providersData = JSON.parse(" in response_text
providers_bootstrap = _load_json_parse_bootstrap(response_text, "let providersData")
bootstrap_fragment = response_text.split("let providersData = JSON.parse(", 1)[1].split("\n", 1)[0]
assert "let providersData = {" in response_text
bootstrap_fragment = response_text.split("let providersData = ", 1)[1].split(";\n", 1)[0]
providers_bootstrap = json.loads(bootstrap_fragment)
assert '</script><script>alert(2)</script>' not in bootstrap_fragment
assert '\\u003c/script\\u003e\\u003cscript\\u003ealert(2)\\u003c/script\\u003e' in bootstrap_fragment
assert providers_bootstrap["danger-provider"]["name"] == 'Admin "Provider" </script><script>alert(2)</script>'
......
......@@ -2,14 +2,20 @@ import pytest
import time
import ipaddress
from unittest.mock import patch, AsyncMock, Mock
from aisbf.geolocation import get_ip_country, _subnet_cache, _find_in_cache, _fallback_prefix
from fastapi import FastAPI
from fastapi.testclient import TestClient
from aisbf.geolocation import get_ip_country, _subnet_cache, _failure_cache, _find_in_cache, _fallback_prefix
from aisbf.app.middleware import GenocidalBlockingMiddleware
@pytest.fixture(autouse=True)
def clear_cache():
_subnet_cache.clear()
_failure_cache.clear()
yield
_subnet_cache.clear()
_failure_cache.clear()
def _mock_json_response(country: str, network: str, status: int = 200):
......@@ -47,6 +53,47 @@ def test_find_in_cache_expired_entry_removed():
assert "10.0.0.0/8" not in _subnet_cache
def _build_geo_test_client():
app = FastAPI()
@app.get("/")
async def root():
return {"ok": True}
app.add_middleware(GenocidalBlockingMiddleware, server_ip_blocked_ref=lambda: False)
return TestClient(app)
def test_localhost_ip_skips_geolocation_lookup():
client = _build_geo_test_client()
with patch("aisbf.geolocation.get_ip_country", new_callable=AsyncMock) as mock_geo:
response = client.get("/", headers={"X-Forwarded-For": "127.0.0.1"})
assert response.status_code == 200
mock_geo.assert_not_called()
def test_private_rfc1918_ip_skips_geolocation_lookup():
client = _build_geo_test_client()
with patch("aisbf.geolocation.get_ip_country", new_callable=AsyncMock) as mock_geo:
response = client.get("/", headers={"X-Forwarded-For": "192.168.1.25"})
assert response.status_code == 200
mock_geo.assert_not_called()
def test_public_ip_still_checks_geolocation():
client = _build_geo_test_client()
with patch("aisbf.geolocation.get_ip_country", new_callable=AsyncMock, return_value=None) as mock_geo:
response = client.get("/", headers={"X-Forwarded-For": "8.8.8.8"})
assert response.status_code == 200
mock_geo.assert_awaited_once_with("8.8.8.8")
# --- invalid IP ---
@pytest.mark.asyncio
......@@ -136,7 +183,7 @@ async def test_non_200_not_cached():
@pytest.mark.asyncio
async def test_failure_then_success_retries():
async def test_failure_then_success_uses_failure_backoff():
fail = Mock(status_code=500)
ok = _mock_json_response("IT", "1.2.0.0/16")
with patch('httpx.AsyncClient.get', new_callable=AsyncMock) as mock_get:
......@@ -146,8 +193,9 @@ async def test_failure_then_success_retries():
mock_get.return_value = ok
result = await get_ip_country("1.2.3.4")
assert result == "IT"
assert "1.2.0.0/16" in _subnet_cache
assert result is None
mock_get.assert_called_once()
assert "1.2.0.0/16" not in _subnet_cache
# --- TTL expiry ---
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment