b4n1 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- b4n1/__init__.py +133 -0
- b4n1/admin/__init__.py +11 -0
- b4n1/admin/inline_edit.py +82 -0
- b4n1/admin/menu.py +34 -0
- b4n1/admin/site.py +40 -0
- b4n1/admin/views.py +280 -0
- b4n1/admin.py +258 -0
- b4n1/api_keys.py +84 -0
- b4n1/app.py +446 -0
- b4n1/audit_live.py +370 -0
- b4n1/auth/__init__.py +29 -0
- b4n1/auth/backend.py +55 -0
- b4n1/auth/core.py +207 -0
- b4n1/auth/decorators.py +62 -0
- b4n1/boost/__init__.py +19 -0
- b4n1/boost/config.py +32 -0
- b4n1/boost/installer.py +145 -0
- b4n1/boost/plans.py +87 -0
- b4n1/cli.py +448 -0
- b4n1/config.py +35 -0
- b4n1/db.py +454 -0
- b4n1/django_middleware.py +31 -0
- b4n1/docs.py +205 -0
- b4n1/image_gen.py +109 -0
- b4n1/mail.py +65 -0
- b4n1/middleware.py +141 -0
- b4n1/models.py +339 -0
- b4n1/orm_interceptor.py +460 -0
- b4n1/pwa.py +92 -0
- b4n1/queue.py +207 -0
- b4n1/queue_admin.py +166 -0
- b4n1/rate_limiter.py +107 -0
- b4n1/responses.py +23 -0
- b4n1/swagger.py +81 -0
- b4n1/templates.py +296 -0
- b4n1/tests/__init__.py +0 -0
- b4n1/tests/conftest.py +91 -0
- b4n1/tests/test_api_keys.py +224 -0
- b4n1/tests/test_auth.py +136 -0
- b4n1/tests/test_config.py +50 -0
- b4n1/tests/test_models.py +170 -0
- b4n1/tests/test_rate_limiter.py +78 -0
- b4n1/tests/test_validation.py +84 -0
- b4n1/validation.py +32 -0
- b4n1-0.0.2.dist-info/METADATA +26 -0
- b4n1-0.0.2.dist-info/RECORD +49 -0
- b4n1-0.0.2.dist-info/WHEEL +5 -0
- b4n1-0.0.2.dist-info/entry_points.txt +2 -0
- b4n1-0.0.2.dist-info/top_level.txt +1 -0
b4n1/__init__.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
b4n1-fast Python SDK — Write Python, Run Rust
|
|
3
|
+
Complete web framework with ORM, validation, middleware, admin, swagger, PWA,
|
|
4
|
+
queue, auth, email, config, API keys, and native database (b4n1-db).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from b4n1.app import App, Request, Router
|
|
8
|
+
from b4n1.models import Model, QuerySet
|
|
9
|
+
from b4n1.responses import JsonResponse
|
|
10
|
+
from b4n1.validation import ValidationError, validate_email, validate_password
|
|
11
|
+
from b4n1.admin import (
|
|
12
|
+
AdminSite, AdminConfig, register_admin_routes,
|
|
13
|
+
register_module, render_sidebar_html, get_modules, clear_modules,
|
|
14
|
+
inject_edit_toolbar, register_edit_api,
|
|
15
|
+
)
|
|
16
|
+
from b4n1.swagger import SwaggerGenerator, RouteInfo
|
|
17
|
+
from b4n1.pwa import PwaManifest, ServiceWorker, generate_pwa_html
|
|
18
|
+
from b4n1.templates import (
|
|
19
|
+
ReactiveTemplateEngine,
|
|
20
|
+
render_template,
|
|
21
|
+
render_string,
|
|
22
|
+
render_window,
|
|
23
|
+
render_window_str,
|
|
24
|
+
send_html,
|
|
25
|
+
send_redirect,
|
|
26
|
+
)
|
|
27
|
+
from b4n1.queue import B4N1Queue, Task, Worker, Scheduler, TaskBroker
|
|
28
|
+
from b4n1.queue_admin import QueueAdminPanel
|
|
29
|
+
from b4n1.auth import (
|
|
30
|
+
ROLES,
|
|
31
|
+
User,
|
|
32
|
+
AuthBackend,
|
|
33
|
+
hash_password,
|
|
34
|
+
verify_password,
|
|
35
|
+
init_role_system,
|
|
36
|
+
set_user_role,
|
|
37
|
+
is_admin,
|
|
38
|
+
is_superadmin,
|
|
39
|
+
create_session,
|
|
40
|
+
get_user_from_session,
|
|
41
|
+
get_session_cookie,
|
|
42
|
+
require_auth,
|
|
43
|
+
require_admin,
|
|
44
|
+
require_superadmin,
|
|
45
|
+
log_login,
|
|
46
|
+
get_client_ip,
|
|
47
|
+
create_superuser,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
from b4n1.db import NativeBackend, SQLiteAdapter, get_db, _QueryResult
|
|
51
|
+
from b4n1.config import Settings, settings
|
|
52
|
+
from b4n1.mail import (
|
|
53
|
+
send_email,
|
|
54
|
+
generate_verification_email,
|
|
55
|
+
generate_reset_email,
|
|
56
|
+
)
|
|
57
|
+
from b4n1.api_keys import (
|
|
58
|
+
generate_key,
|
|
59
|
+
list_keys,
|
|
60
|
+
create_key,
|
|
61
|
+
revoke_key,
|
|
62
|
+
update_fingerprint,
|
|
63
|
+
lookup_key,
|
|
64
|
+
MAX_KEYS_PER_USER,
|
|
65
|
+
)
|
|
66
|
+
from b4n1.image_gen import generate_image, register_image_api
|
|
67
|
+
from b4n1.rate_limiter import SlidingWindowRateLimiter, RateLimitMiddleware
|
|
68
|
+
from b4n1.middleware import (
|
|
69
|
+
native_dumps,
|
|
70
|
+
native_loads,
|
|
71
|
+
DjangoNativeMiddleware,
|
|
72
|
+
FlaskNativeMiddleware,
|
|
73
|
+
FastAPINativeMiddleware,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"App",
|
|
78
|
+
"Request",
|
|
79
|
+
"Router",
|
|
80
|
+
"Model",
|
|
81
|
+
"QuerySet",
|
|
82
|
+
"JsonResponse",
|
|
83
|
+
"ValidationError",
|
|
84
|
+
"validate_email",
|
|
85
|
+
"validate_password",
|
|
86
|
+
"AdminSite",
|
|
87
|
+
"AdminConfig",
|
|
88
|
+
"register_module",
|
|
89
|
+
"render_sidebar_html",
|
|
90
|
+
"get_modules",
|
|
91
|
+
"clear_modules",
|
|
92
|
+
"inject_edit_toolbar",
|
|
93
|
+
"register_edit_api",
|
|
94
|
+
"SwaggerGenerator",
|
|
95
|
+
"RouteInfo",
|
|
96
|
+
"PwaManifest",
|
|
97
|
+
"ServiceWorker",
|
|
98
|
+
"generate_pwa_html",
|
|
99
|
+
"ReactiveTemplateEngine",
|
|
100
|
+
"B4N1Queue",
|
|
101
|
+
"Task",
|
|
102
|
+
"Worker",
|
|
103
|
+
"Scheduler",
|
|
104
|
+
"TaskBroker",
|
|
105
|
+
"QueueAdminPanel",
|
|
106
|
+
"AuthBackend",
|
|
107
|
+
"User",
|
|
108
|
+
"NativeBackend",
|
|
109
|
+
"SQLiteAdapter",
|
|
110
|
+
"get_db",
|
|
111
|
+
"_QueryResult",
|
|
112
|
+
"Settings",
|
|
113
|
+
"settings",
|
|
114
|
+
"send_email",
|
|
115
|
+
"generate_verification_email",
|
|
116
|
+
"generate_reset_email",
|
|
117
|
+
"generate_key",
|
|
118
|
+
"list_keys",
|
|
119
|
+
"create_key",
|
|
120
|
+
"revoke_key",
|
|
121
|
+
"update_fingerprint",
|
|
122
|
+
"lookup_key",
|
|
123
|
+
"MAX_KEYS_PER_USER",
|
|
124
|
+
"generate_image",
|
|
125
|
+
"register_image_api",
|
|
126
|
+
"SlidingWindowRateLimiter",
|
|
127
|
+
"RateLimitMiddleware",
|
|
128
|
+
"native_dumps",
|
|
129
|
+
"native_loads",
|
|
130
|
+
"DjangoNativeMiddleware",
|
|
131
|
+
"FlaskNativeMiddleware",
|
|
132
|
+
"FastAPINativeMiddleware",
|
|
133
|
+
]
|
b4n1/admin/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from b4n1.admin.site import AdminConfig, AdminSite
|
|
2
|
+
from b4n1.admin.views import register_admin_routes
|
|
3
|
+
from b4n1.admin.menu import register_module, render_sidebar_html, get_modules, clear_modules
|
|
4
|
+
from b4n1.admin.inline_edit import inject_edit_toolbar, register_edit_api
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AdminConfig", "AdminSite",
|
|
8
|
+
"register_admin_routes",
|
|
9
|
+
"register_module", "render_sidebar_html", "get_modules", "clear_modules",
|
|
10
|
+
"inject_edit_toolbar", "register_edit_api",
|
|
11
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Inline Visual Editor — admin edit-in-place on public pages.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
1. Add `data-edit` + `data-field` + `data-type` to any DOM element
|
|
5
|
+
2. Call `inject_edit_toolbar(html, user)` in your page render
|
|
6
|
+
3. Register save handlers with `register_edit_api(app, save_callback)`
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
INLINE_CSS = """
|
|
10
|
+
.editable{position:relative;cursor:pointer;transition:outline .2s}
|
|
11
|
+
.editable:hover{outline:2px dashed #16a34a;outline-offset:2px}
|
|
12
|
+
.editable[data-type=image]:hover::after{content:'\\270F\\FE0F';position:absolute;top:4px;right:4px;background:#16a34a;color:#fff;border-radius:4px;padding:2px 6px;font-size:12px;z-index:10;line-height:1.4}
|
|
13
|
+
.editable[data-type=text]:hover::after{content:'\\270F\\FE0F';position:absolute;top:-8px;right:-8px;background:#16a34a;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;z-index:10}
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
INLINE_JS = """
|
|
17
|
+
(function(){
|
|
18
|
+
if(window.__b4n1_inline_editor)return;window.__b4n1_inline_editor=true;
|
|
19
|
+
var s=document.createElement('style');
|
|
20
|
+
s.textContent=INLINE_CSS_PLACEHOLDER;
|
|
21
|
+
document.head.appendChild(s);
|
|
22
|
+
document.querySelectorAll('[data-edit]').forEach(function(el){
|
|
23
|
+
el.classList.add('editable');
|
|
24
|
+
el.addEventListener('click',function(e){
|
|
25
|
+
e.preventDefault();e.stopPropagation();
|
|
26
|
+
var type=this.dataset.type||'text';
|
|
27
|
+
var field=this.dataset.field||'';
|
|
28
|
+
var val=type==='image'?this.src:this.textContent;
|
|
29
|
+
var p=prompt('Editar '+field+':',val);
|
|
30
|
+
if(p&&p!==val){
|
|
31
|
+
if(type==='image')this.src=p;
|
|
32
|
+
else this.textContent=p;
|
|
33
|
+
var fd=new FormData();
|
|
34
|
+
fd.append('field',field);
|
|
35
|
+
fd.append('value',p);
|
|
36
|
+
fetch('/api/admin/edit',{method:'POST',body:fd});
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
})();
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def inject_edit_toolbar(html: str, user: dict | None) -> str:
|
|
45
|
+
if not user:
|
|
46
|
+
return html
|
|
47
|
+
role = user.get("role", "")
|
|
48
|
+
if role not in ("admin", "superadmin"):
|
|
49
|
+
return html
|
|
50
|
+
script = INLINE_JS.replace("INLINE_CSS_PLACEHOLDER", INLINE_CSS.replace("'", "\\'").replace("\n", " "))
|
|
51
|
+
return html.replace("</body>", f"<script>{script}</script>\n</body>")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def register_edit_api(app, save_callback):
|
|
55
|
+
"""Register the /api/admin/edit endpoint.
|
|
56
|
+
|
|
57
|
+
save_callback(field: str, value: str) -> None is called on each edit.
|
|
58
|
+
"""
|
|
59
|
+
import json
|
|
60
|
+
|
|
61
|
+
@app.post("/api/admin/edit")
|
|
62
|
+
def _inline_edit_handler(handler, body, **kw):
|
|
63
|
+
try:
|
|
64
|
+
if body and body.startswith("{"):
|
|
65
|
+
data = json.loads(body)
|
|
66
|
+
else:
|
|
67
|
+
import urllib.parse
|
|
68
|
+
data = urllib.parse.parse_qs(body)
|
|
69
|
+
data = {k: v[0] if isinstance(v, list) and len(v) == 1 else v for k, v in data.items()}
|
|
70
|
+
field = data.get("field", "")
|
|
71
|
+
value = data.get("value", "")
|
|
72
|
+
if field:
|
|
73
|
+
save_callback(field, value)
|
|
74
|
+
handler.send_response(200)
|
|
75
|
+
handler.send_header("Content-Type", "application/json")
|
|
76
|
+
handler.end_headers()
|
|
77
|
+
handler.wfile.write(b'{"ok":true}')
|
|
78
|
+
except Exception as e:
|
|
79
|
+
handler.send_response(500)
|
|
80
|
+
handler.end_headers()
|
|
81
|
+
handler.wfile.write(str(e).encode())
|
|
82
|
+
return None
|
b4n1/admin/menu.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Admin Super Menu — Drupal-like module system.
|
|
2
|
+
|
|
3
|
+
Apps register their modules with icon, route, and order.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
_MODULES: list[dict] = []
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register_module(name: str, icon: str, route: str, order: int = 50, badge: str = ""):
|
|
10
|
+
"""Register a module in the admin sidebar menu."""
|
|
11
|
+
_MODULES.append(dict(name=name, icon=icon, route=route, order=order, badge=badge))
|
|
12
|
+
_MODULES.sort(key=lambda m: m["order"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def render_sidebar_html(current_route: str = "/admin") -> str:
|
|
16
|
+
items = ""
|
|
17
|
+
for m in _MODULES:
|
|
18
|
+
active = "active" if m["route"] == current_route else ""
|
|
19
|
+
badge_html = f'<span class="menu-badge">{m["badge"]}</span>' if m.get("badge") else ""
|
|
20
|
+
items += (
|
|
21
|
+
f'<a href="{m["route"]}" class="{active}">'
|
|
22
|
+
f'<span class="menu-icon">{m["icon"]}</span>'
|
|
23
|
+
f'<span class="menu-label">{m["name"]}</span>'
|
|
24
|
+
f'{badge_html}</a>'
|
|
25
|
+
)
|
|
26
|
+
return items
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_modules() -> list[dict]:
|
|
30
|
+
return list(_MODULES)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def clear_modules():
|
|
34
|
+
_MODULES.clear()
|
b4n1/admin/site.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
class AdminConfig:
|
|
2
|
+
def __init__(self, model_name):
|
|
3
|
+
self.model_name = model_name
|
|
4
|
+
self.list_display = []
|
|
5
|
+
self.search_fields = []
|
|
6
|
+
self.list_filter = []
|
|
7
|
+
self.ordering = None
|
|
8
|
+
self.list_per_page = 100
|
|
9
|
+
|
|
10
|
+
def with_list_display(self, fields):
|
|
11
|
+
self.list_display = list(fields)
|
|
12
|
+
return self
|
|
13
|
+
|
|
14
|
+
def with_search_fields(self, fields):
|
|
15
|
+
self.search_fields = list(fields)
|
|
16
|
+
return self
|
|
17
|
+
|
|
18
|
+
def with_list_filter(self, fields):
|
|
19
|
+
self.list_filter = list(fields)
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
def with_ordering(self, field):
|
|
23
|
+
self.ordering = field
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AdminSite:
|
|
28
|
+
def __init__(self, title="B4N1-FAST Admin"):
|
|
29
|
+
self.title = title
|
|
30
|
+
self.configs = {}
|
|
31
|
+
|
|
32
|
+
def register(self, config):
|
|
33
|
+
self.configs[config.model_name] = config
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
def generate_dashboard_url(self):
|
|
37
|
+
return "/admin/"
|
|
38
|
+
|
|
39
|
+
def generate_list_url(self, model_name):
|
|
40
|
+
return f"/admin/{model_name}/"
|
b4n1/admin/views.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Admin CRUD views: login, dashboard, user CRUD, API keys, telemetry, inline edit API."""
|
|
2
|
+
|
|
3
|
+
import secrets, urllib.parse, json, time
|
|
4
|
+
from b4n1.auth import (
|
|
5
|
+
ROLES, hash_password, verify_password, require_admin, require_superadmin,
|
|
6
|
+
create_session, get_session_cookie, get_user_from_session, log_login,
|
|
7
|
+
get_client_ip,
|
|
8
|
+
)
|
|
9
|
+
from b4n1.templates import render_window, send_html, send_redirect
|
|
10
|
+
from b4n1.admin.menu import render_sidebar_html, register_module
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _role_opt(selected_role):
|
|
14
|
+
return "".join(
|
|
15
|
+
f'<option value="{r}" {"selected" if r == selected_role else ""}>{r}</option>'
|
|
16
|
+
for r in ROLES
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _user_rows(users):
|
|
21
|
+
rows = ""
|
|
22
|
+
for u in users:
|
|
23
|
+
role_cls = {
|
|
24
|
+
"superadmin": "bg-purple-600/30 text-purple-400",
|
|
25
|
+
"admin": "bg-red-600/30 text-red-400",
|
|
26
|
+
"staff": "bg-blue-600/30 text-blue-400",
|
|
27
|
+
"user": "bg-green-600/20 text-green-400",
|
|
28
|
+
}.get(u.get("role", "user"), "bg-gray-600/20 text-gray-400")
|
|
29
|
+
status_text = "Active" if u.get("is_active") else "Inactive"
|
|
30
|
+
st_cls = "text-green-400" if u.get("is_active") else "text-red-400"
|
|
31
|
+
uid = u["id"]
|
|
32
|
+
uname = u["username"]
|
|
33
|
+
email = u.get("email") or "-"
|
|
34
|
+
role_val = u.get("role") or "user"
|
|
35
|
+
rows += ("<tr class='border-b border-white/5 hover:bg-white/5'>"
|
|
36
|
+
+ f"<td class='py-3 px-4 text-sm'>{uid}</td>"
|
|
37
|
+
+ f"<td class='py-3 px-4 text-sm font-medium'>{uname}</td>"
|
|
38
|
+
+ f"<td class='py-3 px-4 text-sm text-gray-400'>{email}</td>"
|
|
39
|
+
+ f"<td class='py-3 px-4'><span class='text-[10px] px-2 py-0.5 rounded-full border {role_cls}'>{role_val}</span></td>"
|
|
40
|
+
+ f"<td class='py-3 px-4 text-sm {st_cls}'>{status_text}</td>"
|
|
41
|
+
+ f"<td class='py-3 px-4 text-right'>"
|
|
42
|
+
+ f"<button hx-get='/admin/users/edit/{uid}' hx-target='#desktop' hx-swap='beforeend' class='text-blue-400 hover:text-blue-300 text-xs font-medium cursor-pointer'>Edit</button>"
|
|
43
|
+
+ f"<button hx-delete='/admin/users/{uid}' hx-confirm='Delete {uname}?' hx-target='closest tr' hx-swap='delete' class='text-red-400 hover:text-red-300 text-xs font-medium ml-2'>Delete</button>"
|
|
44
|
+
+ "</td></tr>")
|
|
45
|
+
return rows
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _api_key_rows(db):
|
|
49
|
+
rows = db.execute(
|
|
50
|
+
"SELECT ak.id, ak.user_id, ak.key, ak.name, ak.is_active, "
|
|
51
|
+
"ak.created_at, u.username "
|
|
52
|
+
"FROM api_keys ak JOIN users u ON ak.user_id = u.id ORDER BY ak.created_at DESC"
|
|
53
|
+
).fetchall()
|
|
54
|
+
html = ""
|
|
55
|
+
for r in rows:
|
|
56
|
+
sc = "text-green-400" if r["is_active"] else "text-red-400"
|
|
57
|
+
ks = r["key"][:12] + "..." if len(r["key"]) > 15 else r["key"]
|
|
58
|
+
html += (
|
|
59
|
+
f"<tr class='border-b border-white/5 hover:bg-white/5'>"
|
|
60
|
+
f"<td class='py-2 px-3 text-xs text-gray-400'>{r['id']}</td>"
|
|
61
|
+
f"<td class='py-2 px-3 text-xs'>{r['username']}</td>"
|
|
62
|
+
f"<td class='py-2 px-3 text-xs font-mono text-gray-300'>{ks}</td>"
|
|
63
|
+
f"<td class='py-2 px-3 text-xs text-gray-400'>{r.get('name') or '-'}</td>"
|
|
64
|
+
f"<td class='py-2 px-3 text-xs {sc}'>{'Active' if r['is_active'] else 'Inactive'}</td></tr>"
|
|
65
|
+
)
|
|
66
|
+
return html, len(rows)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _r(ctx=None):
|
|
70
|
+
return str(secrets.randbits(32))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_LOGIN_HTML = """<!DOCTYPE html>
|
|
74
|
+
<html lang="es"><head>
|
|
75
|
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
76
|
+
<title>Ingresar — B4N1-FAST Admin</title>
|
|
77
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
78
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
79
|
+
<style>
|
|
80
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
81
|
+
body{font-family:'Inter',sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
|
82
|
+
.login-card{background:#1e293b;border-radius:16px;padding:40px;width:400px;border:1px solid #334155;box-shadow:0 25px 50px -12px rgba(0,0,0,.5)}
|
|
83
|
+
.login-card h1{font-size:1.5rem;font-weight:700;margin-bottom:4px}
|
|
84
|
+
.login-card p{color:#64748b;font-size:.9rem;margin-bottom:24px}
|
|
85
|
+
.form-group{margin-bottom:16px}
|
|
86
|
+
.form-group label{display:block;font-size:.8rem;font-weight:600;color:#94a3b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
|
|
87
|
+
.form-group input{width:100%;padding:10px 14px;background:#0f172a;border:1px solid #334155;border-radius:8px;color:#e2e8f0;font-size:.9rem;outline:none;transition:border .2s}
|
|
88
|
+
.form-group input:focus{border-color:#16a34a}
|
|
89
|
+
.btn-login{width:100%;padding:12px;background:#16a34a;color:#fff;border:none;border-radius:8px;font-size:.95rem;font-weight:600;cursor:pointer;transition:background .2s}
|
|
90
|
+
.btn-login:hover{background:#15803d}
|
|
91
|
+
.error{background:#7f1d1d;color:#fca5a5;padding:10px 14px;border-radius:8px;font-size:.85rem;margin-bottom:16px;border:1px solid #991b1b}
|
|
92
|
+
.footer{text-align:center;margin-top:20px;font-size:.8rem;color:#475569}
|
|
93
|
+
</style></head><body>
|
|
94
|
+
<div class="login-card">
|
|
95
|
+
<h1>🔐 Ingresar</h1>
|
|
96
|
+
<p>Administración B4N1-FAST</p>
|
|
97
|
+
{ERROR_BLOCK}
|
|
98
|
+
<form method="post" action="{LOGIN_ACTION}">
|
|
99
|
+
<div class="form-group"><label>Usuario</label><input type="text" name="username" required autofocus></div>
|
|
100
|
+
<div class="form-group"><label>Contraseña</label><input type="password" name="password" required></div>
|
|
101
|
+
<button type="submit" class="btn-login">Ingresar</button>
|
|
102
|
+
</form>
|
|
103
|
+
<div class="footer">B4N1-FAST v1.0</div>
|
|
104
|
+
</div></body></html>"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _login_page_html(error="", login_route="/login"):
|
|
108
|
+
err = f'<div class="error">{error}</div>' if error else ""
|
|
109
|
+
return _LOGIN_HTML.replace("{ERROR_BLOCK}", err).replace("{LOGIN_ACTION}", login_route)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def register_admin_routes(app, login_route="/login", logout_route="/logout"):
|
|
113
|
+
register_module("Dashboard", "📊", "/admin", order=10)
|
|
114
|
+
register_module("Usuarios", "👥", "/admin", order=20)
|
|
115
|
+
register_module("API Keys", "🔑", "/api-keys", order=80)
|
|
116
|
+
|
|
117
|
+
@app.get(login_route)
|
|
118
|
+
def login_get(h, body):
|
|
119
|
+
return send_html(h, _login_page_html(login_route=login_route))
|
|
120
|
+
|
|
121
|
+
@app.post(login_route)
|
|
122
|
+
def login_post(h, body):
|
|
123
|
+
data = urllib.parse.parse_qs(body)
|
|
124
|
+
uname = data.get("username", [""])[0]
|
|
125
|
+
pw = data.get("password", [""])[0]
|
|
126
|
+
db = app._db
|
|
127
|
+
user = db.execute(
|
|
128
|
+
"SELECT * FROM users WHERE username=? AND is_active=1", (uname,)
|
|
129
|
+
).fetchone()
|
|
130
|
+
if not user or not verify_password(pw, user["password_hash"]):
|
|
131
|
+
log_login(0, uname, get_client_ip(h), h.headers.get("User-Agent", ""), success=False)
|
|
132
|
+
return send_html(h, _login_page_html("Usuario o contraseña incorrectos", login_route))
|
|
133
|
+
log_login(user["id"], uname, get_client_ip(h), h.headers.get("User-Agent", ""), success=True)
|
|
134
|
+
key = create_session(user["id"], get_client_ip(h), h.headers.get("User-Agent", ""))
|
|
135
|
+
h.send_response(302)
|
|
136
|
+
h.send_header("Set-Cookie", f"sessionid={key}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400")
|
|
137
|
+
h.send_header("Location", "/admin")
|
|
138
|
+
h.send_header("Content-Length", "0")
|
|
139
|
+
h.end_headers()
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
@app.get(logout_route)
|
|
143
|
+
def logout_get(h, body):
|
|
144
|
+
cookie = get_session_cookie(h.headers)
|
|
145
|
+
if cookie:
|
|
146
|
+
db = app._db
|
|
147
|
+
db.execute("DELETE FROM sessions WHERE session_key=?", (cookie,))
|
|
148
|
+
h.send_response(302)
|
|
149
|
+
h.send_header("Set-Cookie", "sessionid=; Path=/; HttpOnly; Max-Age=0")
|
|
150
|
+
h.send_header("Location", login_route)
|
|
151
|
+
h.send_header("Content-Length", "0")
|
|
152
|
+
h.end_headers()
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
@app.get("/admin")
|
|
156
|
+
@require_admin
|
|
157
|
+
def admin_dashboard(h, body, user):
|
|
158
|
+
db = app._db
|
|
159
|
+
t = db.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"]
|
|
160
|
+
a = db.execute("SELECT COUNT(*) as c FROM users WHERE is_active = 1").fetchone()["c"]
|
|
161
|
+
ac = db.execute("SELECT COUNT(*) as c FROM users WHERE role IN ('superadmin','admin')").fetchone()["c"]
|
|
162
|
+
sc = db.execute("SELECT COUNT(*) as c FROM users WHERE role = 'staff'").fetchone()["c"]
|
|
163
|
+
l24 = db.execute("SELECT COUNT(*) as c FROM login_logs WHERE created_at > datetime('now','-24 hours') AND success=1").fetchone()["c"]
|
|
164
|
+
f24 = db.execute("SELECT COUNT(*) as c FROM login_logs WHERE created_at > datetime('now','-24 hours') AND success=0").fetchone()["c"]
|
|
165
|
+
r24 = db.execute("SELECT COUNT(*) as c FROM users WHERE created_at > datetime('now','-24 hours')").fetchone()["c"]
|
|
166
|
+
recent = db.execute("SELECT username,ip_address,success,created_at FROM login_logs ORDER BY created_at DESC LIMIT 20").fetchall()
|
|
167
|
+
users = db.execute("SELECT id,username,email,role,is_active FROM users ORDER BY created_at DESC").fetchall()
|
|
168
|
+
rh = "".join(
|
|
169
|
+
f"<tr class='text-[10px] border-b border-white/5'>"
|
|
170
|
+
f"<td class='py-1 px-2'>{'🟢' if r['success'] else '🔴'}</td>"
|
|
171
|
+
f"<td class='py-1 px-2'>{r.get('username') or '-'}</td>"
|
|
172
|
+
f"<td class='py-1 px-2 text-gray-400'>{r.get('ip_address') or '-'}</td>"
|
|
173
|
+
f"<td class='py-1 px-2 text-gray-500'>{r['created_at']}</td></tr>"
|
|
174
|
+
for r in recent
|
|
175
|
+
)
|
|
176
|
+
sidebar = render_sidebar_html("/admin")
|
|
177
|
+
ctx = dict(total_users=t, active_users=a, admin_users=ac, staff_users=sc,
|
|
178
|
+
logins_24h=l24, failed_24h=f24, registrations_24h=r24,
|
|
179
|
+
recent_logins=rh, users_rows=_user_rows(users),
|
|
180
|
+
sidebar=sidebar, random=_r(), user=user)
|
|
181
|
+
return render_window(h, "admin", "Admin Dashboard", "fa-shield-halved",
|
|
182
|
+
"admin/dashboard.html", ctx, "900px", "600px")
|
|
183
|
+
|
|
184
|
+
@app.get("/admin/users/create")
|
|
185
|
+
@require_superadmin
|
|
186
|
+
def admin_create_user_get(h, body, user):
|
|
187
|
+
return render_window(h, "admin-create-user", "Create User", "fa-user-plus",
|
|
188
|
+
"admin/create_user.html", dict(random=_r(), user=user, sidebar=render_sidebar_html()), "450px", "500px")
|
|
189
|
+
|
|
190
|
+
@app.post("/admin/users/create")
|
|
191
|
+
@require_superadmin
|
|
192
|
+
def admin_create_user_post(h, body, user):
|
|
193
|
+
data = urllib.parse.parse_qs(body)
|
|
194
|
+
uname = data.get("username", [""])[0]
|
|
195
|
+
email = data.get("email", [""])[0]
|
|
196
|
+
pw = data.get("password", [""])[0]
|
|
197
|
+
role = data.get("role", ["user"])[0]
|
|
198
|
+
if not uname or not email or not pw:
|
|
199
|
+
return render_window(h, "admin-create-user", "Create User", "fa-user-plus",
|
|
200
|
+
"admin/create_user.html", dict(error="All fields required", random=_r(), user=user, sidebar=render_sidebar_html()), "450px", "500px")
|
|
201
|
+
db = app._db
|
|
202
|
+
if db.execute("SELECT id FROM users WHERE username=? OR email=?", (uname, email)).fetchone():
|
|
203
|
+
return render_window(h, "admin-create-user", "Create User", "fa-user-plus",
|
|
204
|
+
"admin/create_user.html", dict(error="Username or email exists", random=_r(), user=user, sidebar=render_sidebar_html()), "450px", "500px")
|
|
205
|
+
role = role if role in ROLES else "user"
|
|
206
|
+
db.execute(
|
|
207
|
+
"INSERT INTO users (username,email,password_hash,is_active,is_staff,is_superuser,role) VALUES (?,?,?,1,?,?,?)",
|
|
208
|
+
(uname, email, hash_password(pw), 1 if role in ("superadmin", "admin", "staff") else 0,
|
|
209
|
+
1 if role in ("superadmin", "admin") else 0, role)
|
|
210
|
+
)
|
|
211
|
+
new_id = db.execute("SELECT id FROM users WHERE username=?", (uname,)).fetchone()["id"]
|
|
212
|
+
db.execute("INSERT INTO api_keys (user_id,key,name) VALUES (?,?,'Default Key')",
|
|
213
|
+
(new_id, f"b4n1_sk_{secrets.token_urlsafe(24)}"))
|
|
214
|
+
return send_redirect(h, "/admin")
|
|
215
|
+
|
|
216
|
+
@app.get("/admin/users/edit/{user_id}")
|
|
217
|
+
@require_admin
|
|
218
|
+
def admin_edit_user_get(h, body, user, user_id=None):
|
|
219
|
+
db = app._db
|
|
220
|
+
eu = db.execute("SELECT id,username,email,role,is_active FROM users WHERE id=?", (user_id,)).fetchone()
|
|
221
|
+
if not eu:
|
|
222
|
+
h.send_response(404); h.end_headers(); h.wfile.write(b"User not found"); return None
|
|
223
|
+
ctx = dict(user_id=eu["id"], username=eu["username"], email=eu.get("email") or "",
|
|
224
|
+
role_options=_role_opt(eu.get("role") or "user"), is_active=eu["is_active"],
|
|
225
|
+
random=_r(), user=user, sidebar=render_sidebar_html())
|
|
226
|
+
return render_window(h, "admin-edit-user", "Edit User", "fa-user-edit",
|
|
227
|
+
"admin/edit_user.html", ctx, "450px", "450px")
|
|
228
|
+
|
|
229
|
+
@app.post("/admin/users/edit/{user_id}")
|
|
230
|
+
@require_admin
|
|
231
|
+
def admin_edit_user_post(h, body, user, user_id=None):
|
|
232
|
+
data = urllib.parse.parse_qs(body)
|
|
233
|
+
uname = data.get("username", [""])[0]
|
|
234
|
+
email = data.get("email", [""])[0]
|
|
235
|
+
role = data.get("role", [""])[0]
|
|
236
|
+
active = 1 if data.get("is_active", [""])[0] == "1" else 0
|
|
237
|
+
npw = data.get("new_password", [""])[0]
|
|
238
|
+
db = app._db
|
|
239
|
+
if role in ROLES:
|
|
240
|
+
db.execute(
|
|
241
|
+
"UPDATE users SET username=?,email=?,role=?,is_active=?,is_superuser=?,is_staff=? WHERE id=?",
|
|
242
|
+
(uname, email, role, active, 1 if role in ("superadmin", "admin") else 0,
|
|
243
|
+
1 if role in ("superadmin", "admin", "staff") else 0, user_id)
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
db.execute("UPDATE users SET username=?,email=?,is_active=? WHERE id=?", (uname, email, active, user_id))
|
|
247
|
+
if npw:
|
|
248
|
+
db.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(npw), user_id))
|
|
249
|
+
return send_redirect(h, "/admin")
|
|
250
|
+
|
|
251
|
+
@app.delete("/admin/users/{user_id}")
|
|
252
|
+
@require_admin
|
|
253
|
+
def admin_delete_user(h, body, user, user_id=None):
|
|
254
|
+
if str(user["id"]) == str(user_id):
|
|
255
|
+
return {"status": "error", "message": "Cannot delete yourself"}
|
|
256
|
+
db = app._db
|
|
257
|
+
for tbl in ("sessions", "api_keys", "email_tokens", "password_resets"):
|
|
258
|
+
db.execute(f"DELETE FROM {tbl} WHERE user_id=?", (user_id,))
|
|
259
|
+
db.execute("DELETE FROM users WHERE id=?", (user_id,))
|
|
260
|
+
return {"status": "deleted"}
|
|
261
|
+
|
|
262
|
+
@app.get("/api-keys")
|
|
263
|
+
@require_admin
|
|
264
|
+
def admin_api_keys(h, body, user):
|
|
265
|
+
kh, total = _api_key_rows(app._db)
|
|
266
|
+
return render_window(h, "api-keys", "API Keys", "fa-key",
|
|
267
|
+
"admin/api_keys.html", dict(rows=kh, total=total, random=_r(), user=user, sidebar=render_sidebar_html()), "800px", "450px")
|
|
268
|
+
|
|
269
|
+
@app.get("/api/admin/telemetry")
|
|
270
|
+
@require_admin
|
|
271
|
+
def admin_telemetry(h, body, user):
|
|
272
|
+
db = app._db
|
|
273
|
+
return dict(
|
|
274
|
+
total_users=db.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"],
|
|
275
|
+
active_users=db.execute("SELECT COUNT(*) as c FROM users WHERE is_active=1").fetchone()["c"],
|
|
276
|
+
logins_24h=db.execute("SELECT COUNT(*) as c FROM login_logs WHERE created_at>datetime('now','-24 hours') AND success=1").fetchone()["c"],
|
|
277
|
+
failed_logins_24h=db.execute("SELECT COUNT(*) as c FROM login_logs WHERE created_at>datetime('now','-24 hours') AND success=0").fetchone()["c"],
|
|
278
|
+
registrations_7d=db.execute("SELECT COUNT(*) as c FROM users WHERE created_at>datetime('now','-7 days')").fetchone()["c"],
|
|
279
|
+
active_api_keys=db.execute("SELECT COUNT(*) as c FROM api_keys WHERE is_active=1").fetchone()["c"],
|
|
280
|
+
)
|