Cluster relianility

parent 37f579c8
......@@ -254,6 +254,12 @@ def make_auth_middleware(get_server_config, get_config, get_db, url_for_fn):
not (expires_at and int(time.time()) > expires_at)):
return await call_next(request)
if request.url.path.startswith("/api/market"):
expires_at = request.session.get('expires_at')
if (request.session.get('logged_in') and
not (expires_at and int(time.time()) > expires_at)):
return await call_next(request)
if request.method == "GET" and request.url.path in ["/api/models", "/api/v1/models"]:
return await call_next(request)
......
This diff is collapsed.
......@@ -1049,7 +1049,7 @@ class DatabaseManager:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT id, username, email, display_name, role, is_active, email_verified, created_at, last_verification_email_sent, profile_pic
SELECT id, username, email, display_name, role, is_active, email_verified, created_at, last_verification_email_sent, profile_pic, can_publish_market
FROM users
WHERE id = {placeholder}
''', (user_id,))
......@@ -1066,7 +1066,8 @@ class DatabaseManager:
'email_verified': row[6],
'created_at': row[7],
'last_verification_email_sent': row[8],
'profile_pic': row[9] or None
'profile_pic': row[9] or None,
'can_publish_market': bool(row[10]) if row[10] is not None else True
}
return None
......@@ -1628,7 +1629,8 @@ class DatabaseManager:
u.is_active,
u.tier_id,
u.display_name,
t.name as tier_name
t.name as tier_name,
u.can_publish_market
FROM users u
LEFT JOIN account_tiers t ON u.tier_id = t.id
{where_clause}
......@@ -1645,8 +1647,8 @@ class DatabaseManager:
users = []
for row in cursor.fetchall():
user = dict(zip(columns, row))
# Normalize boolean fields
user['is_active'] = bool(user['is_active']) if user['is_active'] is not None else True
user['can_publish_market'] = bool(user['can_publish_market']) if user.get('can_publish_market') is not None else True
users.append(user)
return {
......@@ -1720,6 +1722,16 @@ class DatabaseManager:
cursor.execute(query, params)
conn.commit()
def set_user_market_publish(self, user_id: int, can_publish: bool):
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(
f'UPDATE users SET can_publish_market = {placeholder} WHERE id = {placeholder}',
(1 if can_publish else 0, user_id)
)
conn.commit()
def create_notification(self, user_id: int, title: str, message: str, notification_type: str = 'message') -> int:
"""Create a notification for a user. Returns the new notification id."""
with self._get_connection() as conn:
......@@ -6117,6 +6129,7 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta
('reset_password_token', 'VARCHAR(255)'),
('reset_password_token_expires', 'TIMESTAMP NULL'),
('profile_pic', 'MEDIUMTEXT'),
('can_publish_market', f'{boolean_type} DEFAULT 1'),
]
if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(users)")
......
......@@ -4,6 +4,7 @@ from aisbf.routes.auth import require_dashboard_auth, require_api_auth, require_
from aisbf.database import DatabaseRegistry
from aisbf.analytics import get_analytics
from aisbf.coderai_broker import broker as coderai_broker
from aisbf.studio import serialize_studio_capability_choices
import json
import logging
......@@ -405,6 +406,41 @@ async def dashboard_market(request: Request):
await _attach_hardware_snapshot(listing_copy)
enriched.append(listing_copy)
is_admin = request.session.get('role') == 'admin'
user_resources = {'providers': [], 'rotations': [], 'autoselects': [], 'models_by_provider': {}}
def _add_provider(pid, cfg):
user_resources['providers'].append({'id': pid, 'label': cfg.get('name') or pid})
models = cfg.get('models') or []
if models:
user_resources['models_by_provider'][pid] = [
{'name': (m.get('name') if isinstance(m, dict) else str(m))}
for m in models if (m.get('name') if isinstance(m, dict) else m)
]
if current_user_id:
for p in db.get_user_providers(current_user_id):
_add_provider(p['provider_id'], p.get('config') or {})
for r in db.get_user_rotations(current_user_id):
cfg = r.get('config') or {}
rid = r['rotation_id']
user_resources['rotations'].append({'id': rid, 'label': cfg.get('name') or rid})
for a in db.get_user_autoselects(current_user_id):
cfg = a.get('config') or {}
aid = a['autoselect_id']
user_resources['autoselects'].append({'id': aid, 'label': cfg.get('name') or aid})
if is_admin and _config:
for pid, pobj in (getattr(_config, 'providers', None) or {}).items():
cfg = pobj.model_dump() if hasattr(pobj, 'model_dump') else (vars(pobj) if not isinstance(pobj, dict) else pobj)
_add_provider(pid, cfg)
for rid, robj in (getattr(_config, 'rotations', None) or {}).items():
cfg = robj.model_dump() if hasattr(robj, 'model_dump') else (vars(robj) if not isinstance(robj, dict) else robj)
user_resources['rotations'].append({'id': rid, 'label': cfg.get('name') or rid})
for aid, aobj in (getattr(_config, 'autoselects', None) or {}).items():
cfg = aobj.model_dump() if hasattr(aobj, 'model_dump') else (vars(aobj) if not isinstance(aobj, dict) else aobj)
user_resources['autoselects'].append({'id': aid, 'label': cfg.get('name') or aid})
return _templates.TemplateResponse(
request=request,
name='dashboard/market.html',
......@@ -415,6 +451,8 @@ async def dashboard_market(request: Request):
'currency_symbol': db.get_currency_settings().get('currency_symbol', '$'),
'current_user_id': current_user_id,
'market_settings_json': json.dumps(market_settings),
'user_resources_json': json.dumps(user_resources),
'all_capabilities_json': json.dumps(serialize_studio_capability_choices()),
},
)
......@@ -525,6 +563,8 @@ async def api_publish_market_listing(request: Request):
if not market_settings.get('allow_user_publish', True):
return JSONResponse({'error': 'User publishing is disabled'}, status_code=403)
user = db.get_user_by_id(user_id)
if not user.get('can_publish_market', True):
return JSONResponse({'error': 'Your account is not allowed to publish to the market'}, status_code=403)
owner_user_id = user_id
owner_username = user['username']
source_scope = 'user'
......
......@@ -985,6 +985,24 @@ async def dashboard_users_toggle(request: Request, user_id: int):
except Exception as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.post("/dashboard/users/{user_id}/toggle-market-publish")
async def dashboard_users_toggle_market_publish(request: Request, user_id: int):
"""Toggle a user's ability to publish to the market"""
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
try:
user = db.get_user_by_id(user_id)
if not user:
return JSONResponse({"success": False, "error": "User not found"}, status_code=404)
new_value = not user.get('can_publish_market', True)
db.set_user_market_publish(user_id, new_value)
return JSONResponse({"success": True, "can_publish_market": new_value})
except Exception as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@router.post("/dashboard/users/{user_id}/delete")
async def dashboard_users_delete(request: Request, user_id: int):
"""Delete a user"""
......
# MinIO Archive for Studio-Generated Files
## Overview
Replace the local `~/.aisbf/studio/archive/` filesystem writes with an optional MinIO-backed object store. When MinIO is configured, all instances in a cluster write to and read from the same bucket. Local filesystem remains the fallback when MinIO is not configured, so single-node installs need no changes.
---
## Step 1 — Dependency
Add `boto3` to requirements (the AWS SDK, which is the standard MinIO Python client since MinIO exposes an S3-compatible API):
```
boto3>=1.34
```
No `aioboto3` needed — archive writes happen in response to generation requests and a synchronous upload is acceptable; the generation itself already takes seconds.
---
## Step 2 — Admin Settings (DB + API + UI)
### Database
Add `get_archive_storage_settings` / `save_archive_storage_settings` in `database.py`, following the exact pattern of `get_market_settings` / `save_market_settings`. Stored under key `archive_storage` in the `admin_settings` table as a JSON object:
```json
{
"backend": "local",
"endpoint_url": "http://minio:9000",
"access_key": "",
"secret_key": "",
"bucket": "aisbf-archive",
"region": "us-east-1",
"presign_ttl_seconds": 3600,
"public_url_base": ""
}
```
`backend` is either `"local"` or `"minio"`. `public_url_base` is optional — if set (e.g. `https://cdn.example.com/aisbf-archive`), presigned URLs are skipped and a plain public URL is constructed instead (useful when the bucket is public behind a CDN).
### API
Two endpoints in `routes/dashboard/admin.py`:
- `GET /api/admin/settings/archive-storage` — returns current settings with `secret_key` masked
- `POST /api/admin/settings/archive-storage` — saves and immediately validates the connection by calling `list_buckets()` on the client; returns an error if unreachable
### UI
New section in `admin_payment_settings.html` (or a dedicated `admin_storage_settings.html` linked from the settings nav). Fields:
- Backend toggle: local / MinIO
- Endpoint URL
- Access key
- Secret key
- Bucket name
- Region
- Presign TTL (seconds)
- Public URL base (optional)
Save button calls the POST endpoint and shows a connection test result inline.
---
## Step 3 — Storage Abstraction (`aisbf/archive_storage.py`)
New module with a simple interface:
```python
class ArchiveStorage:
def put(self, object_key: str, data: bytes, content_type: str) -> str:
"""Store bytes, return a URL (presigned or public)."""
def get_url(self, object_key: str) -> str:
"""Return a URL for an existing object."""
def delete(self, object_key: str) -> None: ...
def list_prefix(self, prefix: str) -> list[dict]:
"""List objects under a prefix, return dicts with key/size/last_modified."""
```
### `LocalArchiveStorage`
Wraps the current `Path.write_bytes` / `iterdir` / `unlink` logic extracted from `studio_services.py`. Returns `/dashboard/static/studio-archive/{key}` URLs. No new logic — this is a refactor of what exists today.
### `MinioArchiveStorage`
Uses `boto3.client('s3', endpoint_url=..., aws_access_key_id=..., aws_secret_access_key=...)`.
- `put`: `client.put_object(Bucket=bucket, Key=object_key, Body=data, ContentType=content_type)`
- `get_url`: if `public_url_base` is set, returns `f"{public_url_base}/{object_key}"`, otherwise calls `client.generate_presigned_url('get_object', Params={...}, ExpiresIn=ttl)`
- `delete`: `client.delete_object(Bucket=bucket, Key=object_key)`
- `list_prefix`: `client.list_objects_v2(Bucket=bucket, Prefix=prefix)`
### Factory
`get_archive_storage() -> ArchiveStorage` reads settings from the DB and returns the right implementation. Result is cached in a module-level variable and invalidated when settings are saved via the admin POST endpoint.
### Object key convention
`{scope}/{filename}` where scope is `admin` or `user_{owner_id}` — matches the existing local directory structure exactly, so the scope-path logic in `studio_services.py` maps 1:1 to object key prefixes.
---
## Step 4 — Integration into `studio_services.py`
Three touch points:
### `_scope_dir`
Currently creates and returns a `Path`. Extract the scope name logic into a separate `_scope_prefix(scope, owner_id) -> str` method used by both backends:
```python
def _scope_prefix(self, scope: str, owner_id: Optional[int]) -> str:
return "admin" if scope == "admin" or owner_id is None else f"user_{owner_id}"
```
`_scope_dir` continues to use this internally for local storage; MinIO code uses `_scope_prefix` directly.
### Output file saving
Currently done inline wherever generation results are written (e.g. `target.write_bytes(base64.b64decode(encoded))`). These become:
```python
url = storage.put(f"{scope_prefix}/{filename}", data, content_type)
```
The returned URL is stored in the result metadata instead of constructing a static path string.
### `list_archive`
Replace `scoped.iterdir()` with `storage.list_prefix(scope_prefix)`. The returned dicts have `key`, `size`, `last_modified` — map to the existing response shape (`filename`, `url`, `size`, `created`, `type`). URL comes from `storage.get_url(key)`.
### File deletion
Replace `file_path.unlink()` with `storage.delete(object_key)`.
---
## Step 5 — Presigned URL Expiry Handling
Presigned URLs expire (default 1 hour, configurable in admin settings). The frontend calls the archive listing endpoint fresh on each page load, which regenerates presigned URLs at listing time — no special handling needed.
If a user keeps the archive tab open past the TTL and clicks a stale link, it will 403. This is acceptable standard S3 presigned URL behavior. The TTL can be set higher (e.g. 24h) for lower-churn archives.
---
## Step 6 — Migration Tool (optional)
A one-shot admin action:
`POST /api/admin/settings/archive-storage/migrate`
Iterates `~/.aisbf/studio/archive/` on the local node, uploads each file to MinIO using the same key convention, then optionally deletes local copies. Returns a progress count in the response.
Only needs to run once on one node after MinIO is configured. Not strictly required — new files go to MinIO immediately after the backend is switched; old local files simply stop being accessible via the new URLs. Whether to migrate historical files is the admin's choice.
---
## Step 7 — Bucket Bootstrapping
On `MinioArchiveStorage.__init__`, check if the configured bucket exists and create it if not:
```python
client.create_bucket(Bucket=bucket)
```
This means the admin only needs to provide credentials — the bucket is auto-provisioned on first use. If creation fails (permissions issue), surface the error in the connection test response from the admin POST endpoint.
---
## Files to Create / Modify
| File | Action | Notes |
|---|---|---|
| `aisbf/archive_storage.py` | **Create** | `ArchiveStorage` base, `LocalArchiveStorage`, `MinioArchiveStorage`, `get_archive_storage()` factory |
| `aisbf/database.py` | **Modify** | Add `get_archive_storage_settings`, `save_archive_storage_settings` |
| `aisbf/studio_services.py` | **Modify** | Extract `_scope_prefix`, replace all `Path` write/read/list/delete calls in archive methods with `get_archive_storage()` calls |
| `aisbf/routes/dashboard/admin.py` | **Modify** | Add GET/POST `/api/admin/settings/archive-storage` endpoints; optional `/migrate` action |
| `templates/dashboard/admin_payment_settings.html` | **Modify** | Add MinIO settings section with connection test feedback |
| `requirements.txt` / `pyproject.toml` | **Modify** | Add `boto3>=1.34` |
---
## Current State Reference
- Local archive path: `~/.aisbf/studio/archive/{scope}/`
- Current serving: FastAPI `StaticFiles` mounted at `/dashboard/static/studio-archive/`
- Current URL pattern: `/dashboard/static/studio-archive/admin/{filename}` or `/dashboard/static/studio-archive/user_{owner_id}/{filename}`
- Write entry point: `StudioService._store_uploads()` in `studio_services.py:566`
- List entry point: `StudioService.list_archive()` in `studio_services.py:696`
- Settings storage pattern: `admin_settings` table, key/JSON-value pairs — see `get_market_settings` / `save_market_settings` in `database.py` as the reference implementation
This diff is collapsed.
......@@ -211,6 +211,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<button onclick="toggleUserStatus({{ user.id }}, {{ user.is_active|lower }})" class="btn btn-warning" style="padding: 5px 10px; font-size: 12px; margin: 0;">
{% if user.is_active %}Disable{% else %}Enable{% endif %}
</button>
<button onclick="toggleMarketPublish({{ user.id }}, {{ (user.can_publish_market if user.can_publish_market is defined else true)|lower }})" class="btn" style="padding: 5px 10px; font-size: 12px; margin: 0; background: {% if user.can_publish_market is defined and not user.can_publish_market %}#6b7280{% else %}#0ea5e9{% endif %};">
{% if user.can_publish_market is defined and not user.can_publish_market %}Allow Publish{% else %}Block Publish{% endif %}
</button>
<button onclick="impersonateUser({{ user.id }}, '{{ user.username }}')" class="btn" style="padding: 5px 10px; font-size: 12px; margin: 0; background: #7c3aed;">Impersonate</button>
<button onclick="deleteUser({{ user.id }}, '{{ user.username }}')" class="btn btn-danger" style="padding: 5px 10px; font-size: 12px; margin: 0;" data-i18n="users_page.delete_btn">Delete</button>
</div>
......@@ -836,6 +839,28 @@ async function toggleUserStatus(userId, currentStatus) {
}
}
async function toggleMarketPublish(userId, canPublish) {
const action = canPublish ? 'block this user from publishing to the market' : 'allow this user to publish to the market';
const ok = await showConfirm('Are you sure you want to ' + action + '?', 'Confirm');
if (ok) {
fetch(baseUrl + userId + '/toggle-market-publish', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
showAlert(data.error || 'Failed to update market publish permission', 'Error', '❌', 'danger');
}
})
.catch(error => {
showAlert('Error: ' + error, 'Error', '❌', 'danger');
});
}
}
async function deleteUser(userId, username) {
const ok = await showDangerConfirm('Are you sure you want to delete user "' + username + '"? This action cannot be undone.', 'Delete User');
if (ok) {
......
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