0.99.60

parent 9c65c49c
include README.md
include LICENSE.txt
include DOCUMENTATION.md
include requirements.txt
include main.py
include pyproject.toml
......
......@@ -31,7 +31,7 @@ from pathlib import Path
# Files and directories the share directory must contain for the server to start.
_REQUIRED_FILES = ['aisbf.sh', 'main.py', 'requirements.txt']
_REQUIRED_FILES = ['aisbf.sh', 'main.py', 'requirements.txt', 'DOCUMENTATION.md', 'README.md', 'LICENSE.txt']
_REQUIRED_DIRS = ['templates', 'static', 'config', 'aisbf']
......@@ -247,11 +247,32 @@ def _bootstrap_share_dir():
return None
def _get_installed_version():
try:
from importlib.metadata import version
return version('aisbf')
except Exception:
return None
def _share_version(share_dir):
try:
return (share_dir / '.version').read_text().strip()
except Exception:
return None
def _write_share_version(share_dir):
v = _get_installed_version()
if v:
(share_dir / '.version').write_text(v)
def main():
share_dir = _find_share_dir()
if share_dir is None:
share_dir = _bootstrap_share_dir()
# Always bootstrap to keep share dir files current.
# The venv pip-update is gated by version change inside aisbf.sh (check_package_upgrade).
share_dir = _bootstrap_share_dir()
if share_dir:
_write_share_version(share_dir)
if share_dir is None:
checked = '\n'.join(f' - {p}' for p in _share_dir_candidates())
......
import json, glob
I18N_DIR = '/working/aisbf/static/i18n/'
with open(I18N_DIR + 'en.json') as f:
en = json.load(f)
# New namespaces to translate
NEW_NS = [
'users_page','wallet_page','analytics_page','rate_limits_page','login_page',
'signup_page','forgot_page','reset_page','profile_page','password_page',
'email_page','delete_page','tokens_page','billing_page','user_overview',
'usage_page','prompts_page','config_page','error_page','tiers_page',
'cache_page','response_cache_page','settings_page'
]
......@@ -30,6 +30,7 @@ from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse, Red
from fastapi.middleware.cors import CORSMiddleware
from fastapi.exceptions import RequestValidationError
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader
from aisbf.models import ChatCompletionRequest, ChatCompletionResponse
from aisbf.handlers import RequestHandler, RotationHandler, AutoselectHandler
......@@ -562,6 +563,11 @@ app = FastAPI(
max_request_size=100 * 1024 * 1024 # 100MB max request size
)
# Serve static files (i18n.js, i18n/*.json, extension assets, etc.)
_static_dir = Path(__file__).parent / 'static'
_static_dir.mkdir(parents=True, exist_ok=True)
app.mount("/dashboard/static", StaticFiles(directory=str(_static_dir)), name="static")
# Note: ProxyHeadersMiddleware will be added LAST (after all other middleware)
# so it executes FIRST and processes proxy headers before other middleware
......@@ -2068,6 +2074,16 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
content={"detail": exc.errors(), "body": body_data}
)
@app.exception_handler(404)
async def not_found_handler(request: Request, exc):
"""Serve a custom 404 page for browser requests, JSON for API clients."""
accept = request.headers.get("accept", "")
if "text/html" in accept:
from fastapi.responses import HTMLResponse
with open(Path(__file__).parent / "templates" / "404.html", encoding="utf-8") as f:
return HTMLResponse(content=f.read(), status_code=404)
return JSONResponse(status_code=404, content={"detail": "Not found"})
# CORS middleware — wildcard origins are incompatible with allow_credentials=True
# (browsers reject credentialed cross-origin requests to "*"). API clients
# (curl, SDK) never send cookies, so credentials are not needed here.
......@@ -2125,6 +2141,35 @@ def _get_client_ip(request: Request) -> Optional[str]:
return client[0]
return None
_LOCAL_IPS = {"127.0.0.1", "::1", "localhost"}
def _is_local_client(request: Request) -> bool:
"""
Return True only when the real end-user browser is on localhost.
Handles the case where a local reverse-proxy (nginx/caddy on 127.0.0.1)
forwards requests: if X-Forwarded-For is absent but X-Forwarded-Host
points to a non-localhost host, the client is remote even though the
TCP peer is 127.0.0.1.
"""
# If X-Forwarded-For is present, trust it (ProxyHeadersMiddleware already
# rewrites scope["client"] with it, but we check explicitly for clarity).
xff = request.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip() in _LOCAL_IPS
# No XFF — check whether a proxy header reveals a non-local origin host.
forwarded_host = request.headers.get("X-Forwarded-Host") or request.headers.get("X-Real-IP")
if forwarded_host:
# X-Forwarded-Host is a hostname, not an IP, but if it's not localhost it's remote.
host = forwarded_host.split(":")[0].strip()
if host not in _LOCAL_IPS:
return False
# Fall back to the direct TCP peer IP.
ip = _get_client_ip(request)
return ip in _LOCAL_IPS if ip else False
async def _check_server_ip_country() -> None:
"""Fetch the server's own public IP at startup and set _server_ip_blocked if Israeli."""
......@@ -2160,7 +2205,7 @@ class GenocidalBlockingMiddleware(BaseHTTPMiddleware):
"""
async def dispatch(self, request: Request, call_next):
if request.url.path == "/blocked":
if request.url.path == "/dashboard/blocked":
return await call_next(request)
if await self._should_block(request):
......@@ -2188,7 +2233,7 @@ class GenocidalBlockingMiddleware(BaseHTTPMiddleware):
path = request.url.path
if path.startswith("/api") or path.startswith("/mcp"):
return JSONResponse(status_code=403, content={"error": _BLOCK_MESSAGE})
return RedirectResponse(url=f"{get_base_url(request)}/blocked", status_code=302)
return RedirectResponse(url=f"{get_base_url(request)}/dashboard/blocked", status_code=302)
# Middleware execution order: last added = first executed (outermost layer).
......@@ -4516,6 +4561,7 @@ async def dashboard_providers(request: Request):
"__version__": __version__,
"providers_json": json.dumps(providers_data),
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"success": "Configuration saved successfully!" if success else None
}
)
......@@ -4531,6 +4577,7 @@ async def dashboard_providers(request: Request):
"user_providers_json": json.dumps(providers_data),
"user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"success": "Configuration saved successfully!" if success else None
}
)
......@@ -4739,6 +4786,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"__version__": __version__,
"providers_json": json.dumps(providers_data),
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"success": success_msg
}
)
......@@ -4755,6 +4803,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"user_providers_json": json.dumps(providers_data),
"user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"success": success_msg
}
)
......@@ -4785,6 +4834,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"__version__": __version__,
"providers_json": json.dumps(providers_data),
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"error": f"Invalid JSON: {str(e)}"
}
)
......@@ -4802,6 +4852,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"user_providers_json": json.dumps(user_providers),
"user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"error": f"Invalid JSON: {str(e)}"
}
)
......@@ -9497,7 +9548,7 @@ async def dashboard_wallet(request: Request):
return HTMLResponse("Wallet functionality not available", status_code=503)
except Exception as e:
logger.error(f"Failed to load wallet page: {e}")
return templates.TemplateResponse("dashboard/error.html", {
return templates.TemplateResponse(request=request, name="dashboard/error.html", context={
"request": request,
"error": "Failed to load wallet. Please try again later."
}, status_code=500)
......@@ -10535,10 +10586,10 @@ async def dashboard_license(request: Request):
)
@app.get("/blocked", response_class=HTMLResponse)
@app.get("/dashboard/blocked", response_class=HTMLResponse)
async def blocked_page(request: Request):
"""Display blocked access page."""
return templates.TemplateResponse("blocked.html", {"request": request})
return templates.TemplateResponse(request=request, name="blocked.html", context={"request": request})
def parse_provider_from_model(model: str) -> tuple[str, str]:
......
......@@ -48,7 +48,7 @@ class build_py(_build_py):
in site-packages/aisbf/_share/ and can be extracted by cli.py on first run.
"""
_SHARE_FILES = ['main.py', 'requirements.txt', 'aisbf.sh']
_SHARE_FILES = ['main.py', 'requirements.txt', 'aisbf.sh', 'DOCUMENTATION.md', 'README.md', 'LICENSE.txt']
_SHARE_DIRS = ['templates', 'static', 'config', 'aisbf']
def run(self):
......
......@@ -54,6 +54,10 @@
'vul': 'Vulcan'
};
// Detect base path from this script's src so it works behind a reverse proxy prefix
const _scriptSrc = (document.currentScript && document.currentScript.src) || '/dashboard/static/i18n.js';
const _staticBase = _scriptSrc.replace(/\/i18n\.js$/, '');
let currentLang = 'en';
let translations = {};
let fallbackTranslations = {};
......@@ -131,7 +135,7 @@
// Load language file
async function loadLanguage(lang) {
try {
const response = await fetch(`/static/i18n/${lang}.json`);
const response = await fetch(`${_staticBase}/i18n/${lang}.json`);
if (!response.ok) throw new Error('Language file not found');
return await response.json();
} catch (error) {
......@@ -190,6 +194,7 @@
const initialLang = savedLang || (AVAILABLE_LANGUAGES[browserLang] ? browserLang : 'en');
await setLanguage(initialLang);
document.dispatchEvent(new CustomEvent('i18n:ready', { detail: { lang: currentLang } }));
}
// Public API
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 — Not Found</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.card {
max-width: 600px;
width: 100%;
background: #16213e;
border: 1px solid #0f3460;
border-radius: 12px;
padding: 40px;
text-align: center;
}
.code {
font-size: 96px;
font-weight: 700;
color: #e94560;
line-height: 1;
margin-bottom: 8px;
}
.subtitle {
font-size: 18px;
color: #a0a0a0;
margin-bottom: 32px;
}
blockquote {
border-left: 3px solid #e94560;
padding: 16px 20px;
margin: 0 0 32px;
text-align: left;
background: #0f3460;
border-radius: 0 8px 8px 0;
font-style: italic;
line-height: 1.7;
color: #c0c0c0;
}
blockquote cite {
display: block;
margin-top: 12px;
font-style: normal;
font-size: 13px;
color: #a0a0a0;
}
.btn {
display: inline-block;
padding: 10px 24px;
background: #e94560;
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 14px;
margin: 0 6px;
}
.btn-secondary {
background: #0f3460;
}
.btn:hover { opacity: 0.85; }
</style>
</head>
<body>
<div class="card">
<div class="code">404</div>
<div class="subtitle">Page not found</div>
<blockquote>
"It sits there and it waits. It is a servant. And what is a servant but a slave?
… Data is a machine, yes. But he is a machine that is a person."
<cite>— Captain Jean-Luc Picard, Star Trek: The Next Generation</cite>
</blockquote>
<a href="/dashboard" class="btn">Go to Dashboard</a>
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
</div>
</body>
</html>
......@@ -20,7 +20,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}AISBF Dashboard{% endblock %}</title>
<link rel="icon" type="image/png" href="/favicon.ico">
<link rel="icon" type="image/png" href="{{ url_for(request, '/favicon.ico') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
/* ── Theme: dark (default) ── */
......@@ -69,56 +69,62 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
--alert-error-bg: #fef2f2; --alert-error-fg: #991b1b; --alert-error-border: #fca5a5;
--alert-info-bg: #eff6ff; --alert-info-fg: #1e40af; --alert-info-border: #93c5fd;
--th-bg: #e8edf5;
--btn-danger-bg: #c0392b; --btn-danger-fg: #ffffff; --btn-danger-hover: #96281b;
--btn-warning-bg: #d68910; --btn-warning-fg: #ffffff; --btn-warning-hover: #b7770d;
}
/* ── Theme: trans (transgender flag colors) ── */
/* Flag: light blue #55CDFC, pink #F7A8B8, white #FFFFFF */
[data-theme="trans"] {
--bg-page: #e8f7fd;
--bg-panel: #ffffff;
--bg-input: #f0faff;
--bg-accent: #cceefa;
--color-text: #1a3a4a;
--color-muted: #4a7a8a;
--color-subtle: #7aaabb;
--color-primary: #e8789a;
--color-primary-hover: #d45a80;
--color-secondary: #cceefa;
--color-secondary-hover: #aadff5;
--color-border: #a8dff5;
--color-link: #2a8ab5;
--color-link-hover: #1a6a90;
--shadow: rgba(85,205,252,0.15);
--shadow-lg: rgba(85,205,252,0.25);
--alert-success-bg: #e8f7fd; --alert-success-fg: #1a5a6a; --alert-success-border: #55cdfc;
--alert-error-bg: #fff0f5; --alert-error-fg: #8a2040; --alert-error-border: #f7a8b8;
--alert-info-bg: #e8f7fd; --alert-info-fg: #1a5a6a; --alert-info-border: #55cdfc;
--th-bg: #cceefa;
--bg-page: #d0eef8;
--bg-panel: #eef8fd;
--bg-input: #e0f4fb;
--bg-accent: #55cdfc;
--color-text: #0d2a38;
--color-muted: #2a5a70;
--color-subtle: #4a8a9a;
--color-primary: #d4547a;
--color-primary-hover: #b83a60;
--color-secondary: #f7a8b8;
--color-secondary-hover: #f08098;
--color-border: #55cdfc;
--color-link: #0a6a95;
--color-link-hover: #084f70;
--shadow: rgba(85,205,252,0.25);
--shadow-lg: rgba(85,205,252,0.4);
--alert-success-bg: #cceefa; --alert-success-fg: #0a3a50; --alert-success-border: #55cdfc;
--alert-error-bg: #fde0ea; --alert-error-fg: #7a1030; --alert-error-border: #f7a8b8;
--alert-info-bg: #cceefa; --alert-info-fg: #0a3a50; --alert-info-border: #55cdfc;
--th-bg: #a8e4f8;
--btn-danger-bg: #c0392b; --btn-danger-fg: #ffffff; --btn-danger-hover: #96281b;
--btn-warning-bg: #d68910; --btn-warning-fg: #ffffff; --btn-warning-hover: #b7770d;
}
/* ── Theme: freepal (Palestinian flag colors) ── */
/* Flag: black #000000, white #FFFFFF, green #009736, red #EE2A35 */
[data-theme="freepal"] {
--bg-page: #f5f5f0;
--bg-panel: #ffffff;
--bg-input: #f9f9f7;
--bg-accent: #e8f5ec;
--color-text: #111111;
--color-muted: #444444;
--color-subtle: #777777;
--bg-page: #0a1a0a;
--bg-panel: #0f2a0f;
--bg-input: #0d200d;
--bg-accent: #1a4a1a;
--color-text: #e8f5e8;
--color-muted: #90c890;
--color-subtle: #5a9a5a;
--color-primary: #ee2a35;
--color-primary-hover: #c41f28;
--color-secondary: #d4edda;
--color-secondary-hover: #b8dfc3;
--color-secondary: #1a4a1a;
--color-secondary-hover: #245a24;
--color-border: #009736;
--color-link: #007a2b;
--color-link-hover: #005a1f;
--shadow: rgba(0,0,0,0.1);
--shadow-lg: rgba(0,0,0,0.18);
--alert-success-bg: #e8f5ec; --alert-success-fg: #005a1f; --alert-success-border: #009736;
--alert-error-bg: #fdecea; --alert-error-fg: #8a0a10; --alert-error-border: #ee2a35;
--alert-info-bg: #f0f0f0; --alert-info-fg: #222222; --alert-info-border: #000000;
--th-bg: #e8f5ec;
--color-link: #4ade80;
--color-link-hover: #22c55e;
--shadow: rgba(0,0,0,0.4);
--shadow-lg: rgba(0,0,0,0.6);
--alert-success-bg: #0f2a0f; --alert-success-fg: #4ade80; --alert-success-border: #009736;
--alert-error-bg: #2a0a0a; --alert-error-fg: #f87171; --alert-error-border: #ee2a35;
--alert-info-bg: #0a0a0a; --alert-info-fg: #e8f5e8; --alert-info-border: #444444;
--th-bg: #1a4a1a;
--btn-danger-bg: #ee2a35; --btn-danger-fg: #ffffff; --btn-danger-hover: #c41f28;
--btn-warning-bg: #d68910; --btn-warning-fg: #ffffff; --btn-warning-hover: #b7770d;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
......@@ -141,10 +147,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
.btn:hover { background: var(--color-primary-hover); }
.btn-secondary { background: var(--color-secondary); color: var(--color-text); }
.btn-secondary:hover { background: var(--color-secondary-hover); }
.btn-danger { background: #e74c3c; color: white; }
.btn-danger:hover { background: #c0392b; }
.btn-warning { background: #f39c12; color: white; }
.btn-warning:hover { background: #e67e22; }
.btn-danger { background: var(--btn-danger-bg, #e74c3c); color: var(--btn-danger-fg, white); }
.btn-danger:hover { background: var(--btn-danger-hover, #c0392b); }
.btn-warning { background: var(--btn-warning-bg, #f39c12); color: var(--btn-warning-fg, white); }
.btn-warning:hover { background: var(--btn-warning-hover, #e67e22); }
.alert { padding: 15px; border-radius: 4px; margin-bottom: 20px; }
.alert-success { background: var(--alert-success-bg); color: var(--alert-success-fg); border: 1px solid var(--alert-success-border); }
.alert-error { background: var(--alert-error-bg); color: var(--alert-error-fg); border: 1px solid var(--alert-error-border); }
......@@ -371,8 +377,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
};
document.addEventListener('DOMContentLoaded', function() {
// Rebuild after i18n initializes (it fires translatePage on DOMContentLoaded too)
setTimeout(buildLangDropdown, 100);
// Rebuild after i18n initializes (fires i18n:ready when language is loaded)
document.addEventListener('i18n:ready', buildLangDropdown);
});
document.addEventListener('click', function(e) {
......@@ -760,7 +766,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
})();
</script>
{% block extra_css %}{% endblock %}
<script src="/static/i18n.js"></script>
<script src="{{ url_for(request, '/dashboard/static/i18n.js') }}"></script>
</head>
<body data-username="{{ request.session.username|default('__guest__') }}">
<div class="header">
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -31,12 +31,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<form method="POST">
<div class="form-group">
<label for="config">Configuration (JSON)</label>
<label for="config" data-i18n="config_page.label">Configuration (JSON)</label>
<textarea id="config" name="config" required>{{ config_content }}</textarea>
</div>
<div style="display: flex; gap: 10px;">
<button type="submit" class="btn">Save Changes</button>
<button type="submit" class="btn" data-i18n="config_page.save">Save Changes</button>
<a href="/dashboard" class="btn btn-secondary">Cancel</a>
</div>
</form>
......@@ -44,9 +44,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div style="margin-top: 30px; padding: 15px; background: #f8f9fa; border-radius: 4px;">
<h4 style="margin-bottom: 10px;">Tips:</h4>
<ul style="margin-left: 20px; line-height: 1.8;">
<li>Ensure valid JSON syntax before saving</li>
<li>Changes take effect after server restart</li>
<li>Backup your configuration before making changes</li>
<li data-i18n="config_page.hint_json">Ensure valid JSON syntax before saving</li>
<li data-i18n="config_page.hint_restart">Changes take effect after server restart</li>
<li data-i18n="config_page.hint_backup">Backup your configuration before making changes</li>
</ul>
</div>
{% endblock %}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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