diff --git a/.gitignore b/.gitignore index 0d42e33..e4248cf 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ env/ # Logs *.log + +db.sqlite3 +:memory: \ No newline at end of file diff --git a/README.md b/README.md index 157ef04..809b589 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ admin = Admin(app) - ⛓️ **Dynamic Routes** - Parameters via `[id]`, `[slug:slug]` patterns ### Database & ORM -- 🗄️ **Built-in ORM** - SQLite with relations, migrations, soft deletes -- 🔍 **Full-Text Search** - FTS5 integration for lightning-fast search +- 🗄️ **Built-in ORM** - SQLite (default), PostgreSQL, and MySQL support with relations, migrations, soft deletes +- 🔍 **Full-Text Search** - FTS5/FULLTEXT integration for lightning-fast search - 🔐 **Auto Password Hashing** - PBKDF2-SHA256 with **600,000 iterations** - 📊 **Query Builder** - Fluent API with eager loading @@ -117,12 +117,26 @@ Asok doesn't aim to replace existing frameworks—it offers a different approach ## 🛠️ Installation & Setup ### 1. Installation -You can install Asok via pip: +By default, Asok has zero external dependencies and works out of the box with SQLite: ```bash pip install asok ``` +If you wish to use optional database engines or the Redis backend (for caching and sessions), install the corresponding extra(s): + +```bash +# Optional database engines & capabilities +pip install "asok[postgres]" +pip install "asok[mysql]" +pip install "asok[redis]" +pip install "asok[async]" + +# Combined extras (e.g. Postgres + Redis) +pip install "asok[postgres,redis]" + +``` + or clone the repo and use the `asok/` folder. ### 2. Create a project @@ -264,9 +278,14 @@ The admin automatically detects the source of resources: --- ## 🚀 Towards Production -Asok is WSGI compatible. You can use Gunicorn or any other WSGI server: +Asok supports both **WSGI** and **ASGI**. Use Gunicorn for WSGI or Uvicorn for ASGI: + ```bash +# WSGI (Gunicorn) gunicorn wsgi:app + +# ASGI (Uvicorn) — for async/await support +uvicorn asgi:app ``` --- @@ -373,18 +392,26 @@ Thanks to all our amazing contributors! 🎉 Asok is actively developed with exciting features planned: -**v0.2.0** - Enterprise Features -- PostgreSQL & MySQL support -- Advanced ORM relationships (many-to-many improvements) -- WebSocket rooms for real-time collaboration -- Background job queue system -- Plugin ecosystem & CLI enhancements - -**v0.3.0** - Modern Stack -- GraphQL API support with auto-generated schemas -- Server-side rendering (SSR) & static site generation -- Built-in monitoring & observability tools -- Full async/await support (ASGI) +**v0.3.0** - Enterprise Ready ✅ **Released June 2026** +- **Async/ASGI**: Full async/await support with ASGI/WSGI dual engine +- **Multi-DB**: PostgreSQL & MySQL with connection pooling, vector search +- **Advanced ORM**: Polymorphic relations, self-referencing, nested eager loading, N+1 detection +- **WebSocket Rooms**: Multi-user collaboration with room broadcasting +- **Redis**: Caching, sessions, cache warming, fragment caching +- **Cloud**: AWS S3 storage integration +- **Background Jobs**: `asok worker` for async task processing +- **Admin Enhancements**: Inline editing, advanced filtering, saved presets, column customization +- **VSCode Extension**: Syntax highlighting, IntelliSense, snippets, route navigation +- **Localization**: Translation management UI and automatic string extraction +- **Query Optimization**: N+1 detection, query analysis, index suggestions, slow query logging + +**v0.4.0** - GraphQL & Scale (Planned Q4 2026) +- GraphQL API with auto-generated schemas and subscriptions +- Advanced WebSocket features (presence, permissions, private messages) +- Multi-database scaling (read replicas, sharding, load balancing) +- Plugin ecosystem for third-party extensions +- Built-in monitoring & observability (Prometheus/Grafana) +- Advanced SSR & hydration (islands architecture, SSG, ISR) **Note:** Timelines are subject to change based on community feedback and development priorities. @@ -392,22 +419,22 @@ Asok is actively developed with exciting features planned: ## 🏭 Production Status -Asok v0.1.x is **early-stage software** under active development. It's suitable for: +Asok v0.3.0 is **actively developed software** with growing production adoption. It's suitable for: **✅ Recommended for:** -- Personal projects and MVPs +- Production web applications and APIs - Internal tools and admin dashboards +- Personal projects and MVPs - Rapid prototyping and experimentation - Learning full-stack Python development -- Projects where dependency auditing is critical +- Projects requiring zero runtime dependencies +- Applications where dependency auditing is critical **⚠️ Current Limitations:** -- **Database**: SQLite only (PostgreSQL/MySQL planned for v0.2.0) -- **Concurrency**: WSGI only, no async/await yet (ASGI planned for v0.3.0) -- **Ecosystem**: Early-stage community, limited third-party plugins -- **Maturity**: v0.1.x - APIs may evolve before v1.0 +- **Ecosystem**: Growing community, limited third-party plugins +- **Maturity**: v0.3.x - APIs are stabilizing but may evolve before v1.0 -**For mission-critical production applications**, consider your specific requirements and evaluate if Asok's current feature set meets your needs. +**For mission-critical production applications**, Asok v0.3.0 provides enterprise features (async, multi-DB, Redis, S3) suitable for production workloads. Evaluate if the current feature set meets your specific requirements. --- diff --git a/ROADMAP.md b/ROADMAP.md index 051cfe7..1c157e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,6 +6,47 @@ This roadmap outlines the planned features and improvements for upcoming Asok re ## Current Stable Release +### v0.3.0 (Released: June 2026) + +**Status**: ✅ Stable + +Modern async stack, enterprise database support, and developer tooling: + +**Core Framework:** +- **Async/ASGI Support**: Full async/await support with ASGI/WSGI dual engine, async middlewares, and non-blocking database queries +- **Multi-Database Support**: PostgreSQL and MySQL backends with connection pooling, cross-engine migrations, and config-driven DB binds +- **Redis Integration**: Native Redis support for caching, session persistence, cache warming, and fragment caching +- **Cloud Storage**: AWS S3 file storage with automatic mime-type detection +- **Background Jobs**: `asok worker` command for background task processing with Redis resilience +- **Database Fixtures**: New `asok dumpdata` and `asok loaddata` CLI commands for data seeding + +**Advanced ORM:** +- **Polymorphic Relationships**: MorphTo/MorphMany for flexible model associations +- **Self-Referencing Relationships**: Models can reference themselves (parent/child hierarchies) +- **Nested Eager Loading**: Prevent N+1 queries with deep relation loading +- **Custom Relationship Types**: Extensible relationship system +- **Vector Similarity Search**: Built-in support for embedding-based search (PostgreSQL pgvector) +- **Query Optimization Tools**: N+1 detection in development, query plan analysis, automatic index suggestions, slow query logging + +**Real-Time Features:** +- **WebSocket Rooms**: Room-based broadcasting with join/leave for multi-user collaboration + +**Admin Panel Enhancements:** +- **Inline Editing**: Quick updates without full page navigation +- **Advanced Filtering**: Date ranges, multi-field filters, saved filter presets +- **Column Customization**: Toggle column visibility for personalized views + +**Developer Experience:** +- **VSCode Extension**: Official IDE integration with syntax highlighting, IntelliSense, template autocompletion, route navigation, and snippets +- **Localization Tools**: Translation management UI and automatic string extraction for i18n +- **Query Debugging**: Built-in tools for identifying and fixing performance issues + +[View Full Changelog](https://github.com/asok-framework/asok-docs/blob/main/CHANGELOG.md) + +--- + +## Previous Releases + ### v0.1.7 (Released: May 2026) **Status**: ✅ Stable @@ -15,47 +56,42 @@ Framework refactoring and architecture overhaul for long-term maintainability: - **Asset Compilation**: Pre-compiled minified assets for admin, API, and developer toolbar. Added official Python 3.13 support. - **Enhanced Test Coverage**: Added dedicated suites for AJAX CSRF rotation, SPA reactivity fixes, developer toolbar, and API static files. - -[View Full Changelog](https://github.com/asok-framework/asok-docs/blob/main/CHANGELOG.md) - --- ## Upcoming Releases -### v0.2.0 - Enterprise Features (Q2 2026) +### v0.4.0 - GraphQL & Enterprise Scale (Q4 2026) -**Status**: 🚧 In Progress +**Status**: 📋 Planned -#### Database & ORM +#### API & GraphQL -- **PostgreSQL & MySQL Support** - Multi-database backend support beyond SQLite - - PostgreSQL support with JSONB, Arrays, and advanced types - - MySQL/MariaDB support with full compatibility - - Connection pooling and transaction management - - Migration compatibility layer - - Database switching via configuration - - Unified query builder across all databases +- **GraphQL Support** - Modern API development + - Built-in GraphQL server + - Auto-generated schema from models + - Query complexity analysis + - GraphQL playground in development + - Subscriptions via WebSockets -- **Advanced Relationships** - Enhanced ORM capabilities - - Polymorphic relationships (morphTo/morphMany) - - Self-referencing relationships - - Nested eager loading optimization - - Query scopes and global scopes +- **API Versioning** - Professional API management + - URL-based versioning (/api/v1/, /api/v2/) + - Header-based versioning + - API deprecation warnings and sunset headers + - Version negotiation and content-type versioning -#### Real-time & Background Jobs +#### Enterprise & Scalability -- **WebSocket Rooms** - Multi-user real-time collaboration - - Room-based message broadcasting - - User presence tracking +- **Advanced WebSocket Features** - Enhanced real-time capabilities + - User presence tracking and status updates - Room permissions and authentication - - Private messaging support + - Private messaging and direct messages + - Typing indicators and read receipts -- **Job Queue System** - Background task processing - - Redis/SQLite-based queue backends - - Delayed job execution - - Job retry with exponential backoff - - Job status monitoring and logging - - Priority queues +- **Multi-Database Scaling** - Horizontal scaling + - Read replicas configuration + - Sharding strategies for large datasets + - Multi-region database support + - Automatic read/write load balancing #### Developer Experience @@ -65,53 +101,19 @@ Framework refactoring and architecture overhaul for long-term maintainability: - Plugin configuration API - Official plugin registry -- **CLI Enhancements** - Improved developer tools +- **CLI Enhancements** - Advanced developer tools - Performance profiling tools (flame graphs, memory usage) - Database introspection commands (show schema, explain queries) - Asset pipeline optimization (automatic sprite generation) - Environment management (config validation, secrets vault) -- **VSCode Extension** - Official IDE integration - - Syntax highlighting for Asok templates - - IntelliSense for template tags and filters - - Model field autocompletion - - Route navigation and URL reverse lookup - - Built-in snippets for common patterns +- **VSCode Extension Enhancements** - Advanced IDE features - Debug configuration templates - Live preview for templates + - Integrated test runner + - Visual database schema browser -#### Admin Interface - -- **Admin UI Improvements** - Enhanced administration experience - - Inline editing for quick updates - - Drag-and-drop file uploads with progress - - Advanced filtering with date ranges - - Column visibility customization - - Saved filter presets - - Dashboard widgets and statistics - ---- - -### v0.3.0 - Modern Stack (Q3 2026) - -**Status**: 📋 Planned - -#### API & GraphQL - -- **GraphQL Support** - Modern API development - - Built-in GraphQL server - - Auto-generated schema from models - - Query complexity analysis - - GraphQL playground in development - - Subscriptions via WebSockets - -- **API Versioning** - Professional API management - - URL-based versioning (/api/v1/, /api/v2/) - - Header-based versioning - - API deprecation warnings and sunset headers - - Version negotiation and content-type versioning - -#### Performance & Scalability +#### Performance & Rendering - **Advanced SSR & Hydration** - Enhanced rendering strategies - Hybrid rendering (SSR + Client-side hydration) @@ -119,18 +121,6 @@ Framework refactoring and architecture overhaul for long-term maintainability: - Static site generation (SSG) for marketing pages - Incremental static regeneration (ISR) -- **Multi-Database Support** - Horizontal scaling - - Read replicas configuration - - Sharding strategies - - Connection pooling per database - - Load balancing - -- **Async/Await Support** - ASGI compatibility - - Full async request handling - - Async ORM queries - - Async middleware support - - WebSocket async handlers - #### Monitoring & Observability - **Built-in Monitoring** - Production-ready observability @@ -140,17 +130,21 @@ Framework refactoring and architecture overhaul for long-term maintainability: - Health check endpoints - Integration with Prometheus/Grafana -- **Query Optimization** - Automatic performance tuning - - N+1 query detection in development - - Query plan analysis - - Automatic index suggestions - - Slow query logging +#### Admin Interface + +- **Admin Dashboard Enhancements** - Advanced administration features + - Drag-and-drop file uploads with progress + - Dashboard widgets and statistics + - Batch operations interface + - Advanced export/import tools + +--- --- ## Long-term Vision (2027+) -### v0.4.0 and Beyond +### v0.5.0 and Beyond These features are under consideration based on community feedback: @@ -193,9 +187,9 @@ Check [GitHub Discussions](https://github.com/asok-framework/asok/discussions) f | v0.1.4 | May 9, 2026 | ✅ Released | DX & Advanced UI | | v0.1.6 | May 15, 2026 | ✅ Released | Security & UI Transitions | | v0.1.7 | May 25, 2026 | ✅ Released | Architecture Overhaul | -| v0.2.0 | June 2026 | 🚧 In Progress | Enterprise Features | -| v0.3.0 | September 2026 | 📋 Planned | Modern Stack | -| v0.4.0 | Q1 2027 | 💭 Conceptual | Advanced Features | +| v0.3.0 | June 1, 2026 | ✅ Released | Async & Multi-DB Support | +| v0.4.0 | Q4 2026 | 📋 Planned | Advanced Features | +| v0.5.0 | Q2 2027 | 💭 Conceptual | Enterprise Scale | **Note**: Dates are approximate and subject to change based on community priorities and development capacity. @@ -223,6 +217,6 @@ We maintain backward compatibility within major versions and provide clear upgra --- -**Last Updated**: May 25, 2026 +**Last Updated**: June 1, 2026 For the most up-to-date information, check the [GitHub Projects board](https://github.com/asok-framework/asok/projects). diff --git a/asok/__init__.py b/asok/__init__.py index e6d5e79..e32c9de 100644 --- a/asok/__init__.py +++ b/asok/__init__.py @@ -1,7 +1,7 @@ import os import sys -__version__ = "0.1.7" +__version__ = "0.3.0" # Disable bytecode generation (__pycache__) by default to keep the file-system based routing clean. # Can be overridden by setting ASOK_WRITE_BYTECODE=true in the environment. @@ -15,6 +15,7 @@ from .cache import Cache as Cache from .cache import cache_page as cache_page from .component import Component as Component +from .context import current_request as current_request from .core import Asok as Asok from .exceptions import ( AbortException as AbortException, diff --git a/asok/__main__.py b/asok/__main__.py new file mode 100644 index 0000000..ab2d5cf --- /dev/null +++ b/asok/__main__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .cli.main import main + +if __name__ == "__main__": + main() diff --git a/asok/admin/constants.py b/asok/admin/constants.py index f825043..c32a266 100644 --- a/asok/admin/constants.py +++ b/asok/admin/constants.py @@ -20,7 +20,7 @@ "image/png", "image/gif", "image/webp", - # "image/svg+xml", # SECURITY: REMOVED - SVG can contain JavaScript and cause XSS + "image/svg+xml", # SECURITY: Safe with automatic sanitization in UploadedFile.save() "image/bmp", "image/x-icon", # Favicon # Documents @@ -92,7 +92,7 @@ ".png", ".gif", ".webp", - # ".svg", # SECURITY: REMOVED - SVG can contain JavaScript + ".svg", # SECURITY: Safe with automatic sanitization in UploadedFile.save() ".avif", ".bmp", ".ico", diff --git a/asok/admin/core.py b/asok/admin/core.py index bdd1ef9..5f191a3 100644 --- a/asok/admin/core.py +++ b/asok/admin/core.py @@ -183,6 +183,7 @@ def _inject_user_methods(self) -> None: def _discover(self) -> None: import logging + logger = logging.getLogger(__name__) for model in self.app.models: @@ -225,9 +226,7 @@ def _discover(self) -> None: except Exception as e: # Skip malformed models instead of crashing the entire admin model_name = getattr(model, "__name__", str(model)) - logger.warning( - f"Failed to register model {model_name} in admin: {e}" - ) + logger.warning(f"Failed to register model {model_name} in admin: {e}") continue def _default_columns(self, model: Any) -> list[str]: @@ -402,9 +401,7 @@ def _resolve_locale(self, request: Any) -> str: 1. Explicit ?lang=xx 2. Session 'admin_locale' 3. Cookie 'asok_lang' (persists across logout) - 4. Request.user's preferred language (not yet implemented) - 5. Accept-Language header - 6. Fallback to default_locale + 4. Fallback to default_locale """ # 1. Query param lang = request.args.get("lang") @@ -421,16 +418,7 @@ def _resolve_locale(self, request: Any) -> str: if lang in MESSAGES: return lang - # 4. Accept-Language - header = request.environ.get("HTTP_ACCEPT_LANGUAGE", "") - if header: - # e.g. "fr-CH, fr;q=0.9, en;q=0.8, *;q=0.5" - for part in header.split(","): - code = part.split(";")[0].split("-")[0].strip().lower() - if code in MESSAGES: - return code - - # 5. Fallback + # 4. Fallback return self.default_locale def _set_locale(self, request: Any) -> str: @@ -469,7 +457,11 @@ def _render_error(self, request: Any, code: int, title: str, message: str) -> An request.environ["HTTP_X_BLOCK"] = "page-body" result = self._render( - request, "error.html", error_code=code, error_title=title, error_message=message + request, + "error.html", + error_code=code, + error_title=title, + error_message=message, ) # Restore original X-Block header for any subsequent processing @@ -789,7 +781,9 @@ def dispatch(self, request: Any) -> Any: request.session.pop("impersonator_id", None) request.session.pop("impersonate_started_at", None) request.session["user_id"] = impersonator_id - request.flash("info", self.t(request, "Impersonation expired (1 h max.)")) + request.flash( + "info", self.t(request, "Impersonation expired (1 h max.)") + ) else: auth_name = self.app.config.get("AUTH_MODEL", "User") User = MODELS_REGISTRY.get(auth_name) @@ -811,7 +805,9 @@ def dispatch(self, request: Any) -> Any: request.session.pop("impersonator_id", None) request.session.pop("impersonate_started_at", None) request.session["user_id"] = impersonator_id - request.flash("error", self.t(request, "Unauthorized impersonation.")) + request.flash( + "error", self.t(request, "Unauthorized impersonation.") + ) except Exception: pass diff --git a/asok/admin/rbac.py b/asok/admin/rbac.py index 265579d..28ff5e0 100644 --- a/asok/admin/rbac.py +++ b/asok/admin/rbac.py @@ -22,9 +22,9 @@ def _user_roles_accessor(self: Any) -> ModelList: f"JOIN role_user p ON p.role_id = r.id " f"WHERE p.user_id = ?" ) - with self._get_conn() as conn: - rows = conn.execute(sql, (self.id,)).fetchall() - return ModelList(Role(**dict(row)) for row in rows) + engine = self.get_engine() + rows = engine.execute(sql, (self.id,)) + return ModelList(Role(**row) for row in rows) def _user_role_ids(self: Any) -> list[int]: @@ -45,6 +45,12 @@ def _user_can(self: Any, perm: str) -> bool: to fully trusted administrators. """ if getattr(self, "is_admin", False): + # SECURITY: Audit log for superadmin actions to detect privilege misuse + user_id = getattr(self, "id", "unknown") + user_email = getattr(self, "email", None) or getattr(self, "username", f"ID:{user_id}") + logger.info( + f"ADMIN ACCESS: User {user_email} (superadmin) granted permission '{perm}'" + ) return True for r in self.roles: raw = (getattr(r, "permissions", "") or "").strip() @@ -102,14 +108,18 @@ def _can(self, request: Any, slug: str, verb: str) -> bool: return True can_fn = getattr(u, "can", None) if not callable(can_fn): - user_email = getattr(u, "email", None) or getattr(u, "username", f"ID:{u.id}") + user_email = getattr(u, "email", None) or getattr( + u, "username", f"ID:{u.id}" + ) logger.debug( f"Permission check: {user_email} lacks can() method for {slug}.{verb}" ) return False result = bool(can_fn(f"{slug}.{verb}")) if not result: - user_email = getattr(u, "email", None) or getattr(u, "username", f"ID:{u.id}") + user_email = getattr(u, "email", None) or getattr( + u, "username", f"ID:{u.id}" + ) # DEBUG: Routine permission checks for UI (not actual blocked access attempts) # Actual HTTP access denials will log at WARNING level in the view layer logger.debug( diff --git a/asok/admin/static/admin.css b/asok/admin/static/admin.css index 247d764..e83a900 100644 --- a/asok/admin/static/admin.css +++ b/asok/admin/static/admin.css @@ -1,5 +1,5 @@ /* - * ASOK ADMIN CSS v0.1.6 + * ASOK ADMIN CSS v0.3.0 */ :root { diff --git a/asok/admin/static/admin.js b/asok/admin/static/admin.js index 84fe715..bbbfea8 100644 --- a/asok/admin/static/admin.js +++ b/asok/admin/static/admin.js @@ -1,5 +1,5 @@ /** - * ASOK Reactive Runtime v0.1.7 + * ASOK Reactive Runtime v0.3.0 * - Full implementation of the Asok SPA spec * - Event-driven, attribute-based reactivity * - Support for OOB swaps, SSE, and complex triggers diff --git a/asok/admin/translations.py b/asok/admin/translations.py index a33cac1..1f3885f 100644 --- a/asok/admin/translations.py +++ b/asok/admin/translations.py @@ -175,7 +175,6 @@ "Stopped impersonation": "Impersonnalisation arrêtée", "Impersonation expired (1 h max.)": "Impersonnalisation expirée (1 h max.).", "Unauthorized impersonation.": "Impersonnalisation non autorisée.", - "File deleted": "Fichier supprimé", "File not found": "Fichier introuvable", "No files selected": "Aucun fichier sélectionné", @@ -447,7 +446,6 @@ "Stopped impersonation": "Suplantación detenida", "Impersonation expired (1 h max.)": "Suplantación expirada (1 h máx.).", "Unauthorized impersonation.": "Suplantación no autorizada.", - "File deleted": "Archivo eliminado", "File not found": "Archivo no encontrado", "No files selected": "No se seleccionaron archivos", diff --git a/asok/admin/views/auth.py b/asok/admin/views/auth.py index 740507d..5091c3e 100644 --- a/asok/admin/views/auth.py +++ b/asok/admin/views/auth.py @@ -98,7 +98,9 @@ def _login(self, request: Any) -> Any: request.flash("error", self.t(request, "Invalid credentials")) except (AbortException, SecurityError) as e: # Special handling for CSRF failure in login form to avoid 403 pages - if isinstance(e, SecurityError) or (isinstance(e, AbortException) and e.status == 403): + if isinstance(e, SecurityError) or ( + isinstance(e, AbortException) and e.status == 403 + ): request.flash( "error", self.t(request, "Security session expired. Please try again."), @@ -248,11 +250,10 @@ def _twofa_setup(self, request: Any) -> Any: # CRITICAL: Activate 2FA BEFORE showing backup codes (atomic SQL update) User = MODELS_REGISTRY.get(self.app.config.get("AUTH_MODEL", "User")) - with User._get_conn() as conn: - conn.execute( - f"UPDATE {User._table} SET totp_secret = ?, totp_enabled = ?, backup_codes = ? WHERE id = ?", - (encrypted_secret, 1, json.dumps(backup_codes_hashed), u.id), - ) + User.get_engine().execute( + f"UPDATE {User._table} SET totp_secret = ?, totp_enabled = ?, backup_codes = ? WHERE id = ?", + (encrypted_secret, 1, json.dumps(backup_codes_hashed), u.id), + ) try: request.session.pop("pending_2fa_secret", None) @@ -305,11 +306,10 @@ def _twofa_disable(self, request: Any) -> Any: # Disable 2FA and clear backup codes (atomic SQL update) User = MODELS_REGISTRY.get(self.app.config.get("AUTH_MODEL", "User")) - with User._get_conn() as conn: - conn.execute( - f"UPDATE {User._table} SET totp_secret = NULL, totp_enabled = 0, backup_codes = NULL WHERE id = ?", - (u.id,), - ) + User.get_engine().execute( + f"UPDATE {User._table} SET totp_secret = NULL, totp_enabled = 0, backup_codes = NULL WHERE id = ?", + (u.id,), + ) self._log(request, "2fa_disabled", "User", entity_id=u.id) request.flash("success", self.t(request, "Two-factor authentication disabled.")) diff --git a/asok/admin/views/crud.py b/asok/admin/views/crud.py index b7874c7..2b5a244 100644 --- a/asok/admin/views/crud.py +++ b/asok/admin/views/crud.py @@ -646,9 +646,9 @@ def _edit_form( self._build_permission_matrix(request, item) if is_role else None ) - # SECURITY FIX: Vérifier les permissions RBAC pour le bouton delete - # Ne pas seulement vérifier entry["can_delete"] (option statique) - # mais aussi self._can() qui vérifie les permissions de l'utilisateur + # SECURITY FIX: Check RBAC permissions for the delete button + # Do not only check entry["can_delete"] (static option) + # but also self._can() which checks user permissions can_delete_permission = ( entry["can_delete"] and not editing_self @@ -674,9 +674,7 @@ def _edit_form( editing_self=editing_self, ) - def _detail( - self, request: Any, entry: dict[str, Any], item: Any - ) -> Any: + def _detail(self, request: Any, entry: dict[str, Any], item: Any) -> Any: """Render detail view (read-only) for an item.""" name = entry["label"][:-1] if entry["label"].endswith("s") else entry["label"] title = _display(item) if item else self.t(request, name) @@ -857,7 +855,9 @@ def _apply_form( # Save the file try: - upload.save(os.path.join(field.upload_to or "", upload.filename)) + upload.save( + os.path.join(field.upload_to or "", upload.filename) + ) setattr(item, name, upload.filename) except ValueError as e: # Capture validation errors (invalid magic bytes, etc.) @@ -868,6 +868,7 @@ def _apply_form( # SECURITY: Sanitize WYSIWYG content to prevent Stored XSS if getattr(field, "wysiwyg", False) and raw: from ...utils.html_sanitizer import sanitize_html + raw = sanitize_html(raw) if field.sql_type == "INTEGER": @@ -917,8 +918,8 @@ def _sync_m2m(self, request: Any, model: Any, item: Any) -> None: "error", self.t( request, - "You cannot remove all your roles. Keep at least one role to maintain access." - ) + "You cannot remove all your roles. Keep at least one role to maintain access.", + ), ) continue # Skip this sync, keep existing roles diff --git a/asok/admin/views/helpers.py b/asok/admin/views/helpers.py index 7607239..ee675b0 100644 --- a/asok/admin/views/helpers.py +++ b/asok/admin/views/helpers.py @@ -138,11 +138,15 @@ def _build_filters( if not field: continue try: - with model._get_conn() as conn: - rows = conn.execute( - f"SELECT DISTINCT {f} FROM {model._table} ORDER BY {f}" - ).fetchall() - values = [r[0] for r in rows if r[0] is not None] + engine = model.get_engine() + q_f = engine.quote_identifier(f) + q_table = engine.quote_identifier(model._table) + rows = engine.execute( + f"SELECT DISTINCT {q_f} FROM {q_table} ORDER BY {q_f}" + ) + values = [ + list(r.values())[0] for r in rows if list(r.values())[0] is not None + ] except Exception: values = [] current = request.args.get(f"filter_{f}", "") diff --git a/asok/admin/views/media.py b/asok/admin/views/media.py index b7c1ca0..59a1882 100644 --- a/asok/admin/views/media.py +++ b/asok/admin/views/media.py @@ -69,7 +69,11 @@ def _delete_media(self, request: Any, rel_path: str) -> None: normalized = os.path.normpath(rel_path) # Check for path traversal sequences in normalized path - if ".." in normalized or normalized.startswith("/") or normalized.startswith("\\"): + if ( + ".." in normalized + or normalized.startswith("/") + or normalized.startswith("\\") + ): return self._forbid(request) base_dir = os.path.abspath( diff --git a/asok/api/openapi.py b/asok/api/openapi.py index 10a772d..0735ab3 100644 --- a/asok/api/openapi.py +++ b/asok/api/openapi.py @@ -23,7 +23,7 @@ def __init__(self, app): "title": app.config.get( "API_TITLE", app.config.get("PROJECT_NAME", "Asok API") ), - "version": app.config.get("VERSION", "0.1.7"), + "version": app.config.get("VERSION", "0.3.0"), "description": app.config.get( "API_DESCRIPTION", "A sleek, automatically generated reference for your Asok API endpoints.", @@ -48,7 +48,7 @@ def generate(self): # SECURITY: Limit directory traversal depth to prevent DoS for root, _, files in os.walk(pages_dir): # Calculate depth relative to pages_dir - depth = root[len(pages_dir):].count(os.sep) + depth = root[len(pages_dir) :].count(os.sep) if depth >= self._MAX_DEPTH: continue diff --git a/asok/background.py b/asok/background.py index bef8988..110b242 100644 --- a/asok/background.py +++ b/asok/background.py @@ -28,21 +28,63 @@ def background( executor: Optional[ThreadPoolExecutor] = None, **kwargs: Any, ) -> Future: - """Run a function in a background thread pool (fire-and-forget). + """Run a function in a background thread pool or Redis task queue (fire-and-forget). Args: fn: The function to execute. *args: Positional arguments for the function. - executor: Optional executor to use (defaults to shared pool). + executor: Optional executor to use (defaults to shared pool, local only). **kwargs: Keyword arguments for the function. Returns: A concurrent.futures.Future object. """ + import os - def wrapper() -> None: + backend = os.environ.get("ASOK_QUEUE_BACKEND", "local").lower() + if backend == "redis": try: - fn(*args, **kwargs) + import redis + except ImportError: + raise ImportError( + "The 'redis' library is required to use the Redis queue backend. " + "Install it using 'pip install asok[redis]'." + ) + + module_name = fn.__module__ + func_name = fn.__name__ + + if func_name == "" or "" in fn.__qualname__: + raise ValueError("Only module-level functions can be queued on Redis.") + + job = { + "module": module_name, + "function": func_name, + "args": args, + "kwargs": kwargs, + } + + import json + + redis_url = ( + os.environ.get("ASOK_REDIS_URL") + or os.environ.get("REDIS_URL") + or "redis://localhost:6379/0" + ) + client = redis.Redis.from_url(redis_url) + client.lpush("asok:queue", json.dumps(job)) + + f = Future() + f.set_result(None) + return f + + import contextvars + + ctx = contextvars.copy_context() + + def wrapper() -> Any: + try: + return ctx.run(fn, *args, **kwargs) except Exception as e: logger.error("Background task %s failed: %s", fn.__name__, e) diff --git a/asok/cache.py b/asok/cache.py index 6f27768..269e43c 100644 --- a/asok/cache.py +++ b/asok/cache.py @@ -36,11 +36,35 @@ def __init__( if backend == "file": os.makedirs(path, exist_ok=True) + elif backend == "redis": + self._init_redis() + + def _init_redis(self) -> None: + try: + import redis + except ImportError: + raise ImportError( + "The 'redis' library is required to use the Redis cache backend. " + "Install it using 'pip install asok[redis]'." + ) + redis_url = ( + os.environ.get("ASOK_REDIS_URL") + or os.environ.get("REDIS_URL") + or "redis://localhost:6379/0" + ) + self._redis = redis.Redis.from_url(redis_url) + + def _get_redis_client(self): + if not hasattr(self, "_redis") or self._redis is None: + self._init_redis() + return self._redis def get(self, key: str, default: Any = None) -> Any: """Retrieve an item from the cache. Returns the default if not found or expired.""" if self.backend == "file": return self._file_get(key, default) + elif self.backend == "redis": + return self._redis_get(key, default) with self._lock: entry = self._store.get(key) @@ -57,6 +81,8 @@ def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None: if self.backend == "file": return self._file_set(key, value, expires) + elif self.backend == "redis": + return self._redis_set(key, value, ttl) with self._lock: self._store[key] = {"value": value, "expires": expires} @@ -65,6 +91,8 @@ def forget(self, key: str) -> None: """Remove a specific key from the cache.""" if self.backend == "file": return self._file_forget(key) + elif self.backend == "redis": + return self._redis_forget(key) with self._lock: self._store.pop(key, None) @@ -95,10 +123,60 @@ def flush(self) -> None: """Clear all items from the cache.""" if self.backend == "file": return self._file_flush() + elif self.backend == "redis": + return self._redis_flush() with self._lock: self._store.clear() + # --- Redis backend --- + + def _redis_key(self, key: str) -> str: + return f"{self.namespace}:{self.prefix}:{key}" + + def _redis_get(self, key: str, default: Any = None) -> Any: + client = self._get_redis_client() + rkey = self._redis_key(key) + try: + val = client.get(rkey) + if val is None: + return default + if isinstance(val, bytes): + val = val.decode("utf-8") + return json.loads(val) + except Exception: + return default + + def _redis_set(self, key: str, value: Any, ttl: Optional[int] = None) -> None: + client = self._get_redis_client() + rkey = self._redis_key(key) + try: + val_str = json.dumps(value) + if ttl: + client.setex(rkey, ttl, val_str) + else: + client.set(rkey, val_str) + except Exception: + pass + + def _redis_forget(self, key: str) -> None: + client = self._get_redis_client() + rkey = self._redis_key(key) + try: + client.delete(rkey) + except Exception: + pass + + def _redis_flush(self) -> None: + client = self._get_redis_client() + pattern = self._redis_key("*") + try: + keys = client.keys(pattern) + if keys: + client.delete(*keys) + except Exception: + pass + # --- File backend --- def _key_path(self, key: str) -> str: @@ -181,6 +259,9 @@ def wrapper(request, *args, **kwargs): cached = cache.get(cache_key) if cached is not None: + token = getattr(request, "csrf_token_value", None) + if isinstance(cached, str) and token: + return cached.replace("__ASOK_CSRF_TOKEN_PLACEHOLDER__", token) return cached response = func(request, *args, **kwargs) @@ -189,7 +270,12 @@ def wrapper(request, *args, **kwargs): # In Asok, view functions often return a Response object or just a string. status_code = getattr(response, "status", "200") if str(status_code).startswith("200"): - cache.set(cache_key, response, ttl=ttl) + token = getattr(request, "csrf_token_value", None) + if isinstance(response, str) and token: + cached_response = response.replace(token, "__ASOK_CSRF_TOKEN_PLACEHOLDER__") + else: + cached_response = response + cache.set(cache_key, cached_response, ttl=ttl) return response diff --git a/asok/cli/database.py b/asok/cli/database.py index 69873c6..783e0aa 100644 --- a/asok/cli/database.py +++ b/asok/cli/database.py @@ -3,7 +3,6 @@ import getpass import importlib.util as _ilu import os -import sqlite3 import sys import traceback @@ -12,8 +11,35 @@ from .style import Style +class MigrationConnectionWrapper: + """Wrapper to make db connections look like sqlite3.Connection with execute/commit/rollback/close.""" + + def __init__(self, engine): + self.engine = engine + self.conn = engine.get_connection() + + def execute(self, sql, *args, **kwargs): + # Flatten arguments if passed as a tuple inside a tuple + params = args[0] if args and isinstance(args[0], (tuple, list)) else args + return self.engine.execute(sql, params) + + def commit(self): + if hasattr(self.conn, "commit"): + self.conn.commit() + + def rollback(self): + if hasattr(self.conn, "rollback"): + self.conn.rollback() + + def close(self): + pass + + def run_migrate( - rollback: bool = False, status: bool = False, fake: bool = False + rollback: bool = False, + status: bool = False, + fake: bool = False, + database: str | None = None, ) -> None: """Apply or rollback versioned database migrations.""" root = _find_project_root() @@ -35,8 +61,8 @@ def run_migrate( spec = _ilu.spec_from_file_location("_wsgi_mig", wsgi_path) mod = _ilu.module_from_spec(spec) spec.loader.exec_module(mod) - except Exception: - pass + except Exception as e: + Style.warn(f"Failed to load wsgi.py: {e}") model_dir = os.path.join(root, "src/models") if os.path.isdir(model_dir): @@ -55,10 +81,18 @@ def run_migrate( spec = _ilu.spec_from_file_location(mod_name, filepath) mod = _ilu.module_from_spec(spec) spec.loader.exec_module(mod) - except Exception: - pass + except Exception as e: + Style.warn(f"Failed to load model file {f}: {e}") + + # Determine target engine + if database: + from ..orm.engines import get_engine - Migrations.ensure_table() + engine = get_engine(database) + else: + engine = Model.get_engine() + + Migrations.ensure_table(engine) if MODELS_REGISTRY: Style.info(f"Registered models: {', '.join(MODELS_REGISTRY.keys())}") @@ -81,7 +115,7 @@ def run_migrate( if f.endswith(".py") and f[:4].isdigit(): mig_files.append(f) mig_files = sorted(mig_files) - applied = Migrations.get_applied() + applied = Migrations.get_applied(engine) if status: Style.heading("MIGRATION STATUS") @@ -100,13 +134,13 @@ def run_migrate( return if rollback: - last_batch_names = Migrations.get_last_batch() + last_batch_names = Migrations.get_last_batch(engine) if not last_batch_names: Style.info("Nothing to rollback.") return - Style.heading(f"ROLLBACK (Batch {Migrations.get_last_batch_number()})") - conn = sqlite3.connect(Model._db_path) + Style.heading(f"ROLLBACK (Batch {Migrations.get_last_batch_number(engine)})") + conn = MigrationConnectionWrapper(engine) try: for name in last_batch_names: filename = f"{name}.py" @@ -124,7 +158,7 @@ def run_migrate( if not fake: mod.down(conn) conn.commit() - Migrations.remove(name) + Migrations.remove(name, engine) Style.success(f"Rolled back {name}") else: Style.warn(f"Migration {name} has no down() method.") @@ -139,8 +173,8 @@ def run_migrate( return Style.heading("RUNNING MIGRATIONS") - batch = Migrations.get_last_batch_number() + 1 - conn = sqlite3.connect(Model._db_path) + batch = Migrations.get_last_batch_number(engine) + 1 + conn = MigrationConnectionWrapper(engine) try: for name in pending: @@ -155,7 +189,7 @@ def run_migrate( if not fake: mod.up(conn) conn.commit() - Migrations.log(name, batch) + Migrations.log(name, batch, engine) Style.success(f"Applied {name}") else: Style.warn(f"Migration {name} has no up() method.") @@ -271,10 +305,357 @@ def run_createsuperuser(email: str | None = None, password: str | None = None) - name="admin", label="Administrator", permissions="*" ) Style.success("Created 'admin' role with full permissions.") - with User._get_conn() as conn: - conn.execute( - "INSERT OR IGNORE INTO role_user (role_id, user_id) VALUES (?, ?)", + engine = User.get_engine() + q_role_user = engine.quote_identifier("role_user") + q_role_id = engine.quote_identifier("role_id") + q_user_id = engine.quote_identifier("user_id") + + exists = engine.execute( + f"SELECT 1 FROM {q_role_user} WHERE {q_role_id} = ? AND {q_user_id} = ?", + (admin_role.id, user.id), + ) + if not exists: + engine.execute( + f"INSERT INTO {q_role_user} ({q_role_id}, {q_user_id}) VALUES (?, ?)", (admin_role.id, user.id), ) except Exception as e: print(f" ⚠ Could not attach admin role: {e}") + + +def _load_models(root: str) -> None: + """Load models dynamically to register them in MODELS_REGISTRY.""" + os.chdir(root) + if "src" not in sys.path: + sys.path.insert(0, os.path.join(root, "src")) + + if root not in sys.path: + sys.path.insert(0, root) + + wsgi_path = os.path.join(root, "wsgi.py") + if not os.path.isfile(wsgi_path): + wsgi_path = os.path.join(root, "wsgi.pyc") + if os.path.isfile(wsgi_path): + try: + spec = _ilu.spec_from_file_location("_wsgi_models", wsgi_path) + mod = _ilu.module_from_spec(spec) + spec.loader.exec_module(mod) + except Exception as e: + Style.warn(f"Failed to load wsgi.py: {e}") + + model_dir = os.path.join(root, "src/models") + if os.path.isdir(model_dir): + for f in sorted(os.listdir(model_dir)): + if ".." in f or "/" in f or "\\" in f: + continue + if (f.endswith(".py") or f.endswith(".pyc")) and not f.startswith("__"): + filepath = os.path.join(model_dir, f) + if not os.path.abspath(filepath).startswith(os.path.abspath(model_dir)): + continue + ext_len = 4 if f.endswith(".pyc") else 3 + mod_name = f"_model_load_{f[:-ext_len]}" + try: + spec = _ilu.spec_from_file_location(mod_name, filepath) + mod = _ilu.module_from_spec(spec) + spec.loader.exec_module(mod) + except Exception as e: + Style.warn(f"Failed to load model file {f}: {e}") + + +def run_dumpdata(model_name: str | None = None, output_file: str | None = None) -> None: + """Export database records to a JSON fixture file. + + This command serializes database table records into a JSON fixture format. + If no model_name is specified, all registered models will be serialized. + Special database field types (e.g. datetimes, decimals, enums, files) are converted + to serializable formats. Binary BLOB fields (bytes) are base64-encoded with a + special 'base64:' prefix to prevent encoding issues. + + Args: + model_name: The name of the specific model to dump (case-insensitive). + output_file: The target file path to write the JSON data to. If not provided, + the JSON string will be printed to stdout. + """ + import base64 + import datetime + import decimal + import enum + import json + + from ..orm import FileRef + + # Ensure we are in a valid project root + root = _find_project_root() + if not root: + Style.error("Not inside an Asok project.") + sys.exit(1) + + # Load project models + _load_models(root) + + # Sync database path with config + if "DATABASE_URL" in os.environ: + Model._db_path = (os.environ["DATABASE_URL"] or "").strip() or None + + if not MODELS_REGISTRY: + Style.warn("No registered models found to dump.") + return + + # Select target models + target_models = {} + if model_name: + matched = None + for name, cls in MODELS_REGISTRY.items(): + if name.lower() == model_name.lower(): + matched = (name, cls) + break + if not matched: + Style.error(f"Model '{model_name}' not found in registered models.") + sys.exit(1) + target_models[matched[0]] = matched[1] + else: + target_models = MODELS_REGISTRY + + fixtures = [] + # Dump records for each target model class + for name in sorted(target_models.keys()): + model_cls = target_models[name] + records = model_cls.all() + for record in records: + pk = record.id + fields_data = {} + for field_name in model_cls._fields: + val = getattr(record, field_name) + # Convert special object types to serializable formats + if isinstance(val, (datetime.date, datetime.datetime)): + val = val.isoformat() + elif isinstance(val, decimal.Decimal): + val = str(val) + elif isinstance(val, bytes): + # Base64-encode binary bytes to keep JSON valid + val = "base64:" + base64.b64encode(val).decode("utf-8") + elif isinstance(val, enum.Enum): + val = val.value + elif isinstance(val, FileRef): + val = val.name + fields_data[field_name] = val + + fixtures.append({"model": name, "pk": pk, "fields": fields_data}) + + # Output formatted JSON + json_data = json.dumps(fixtures, indent=2, ensure_ascii=False) + if output_file: + try: + with open(output_file, "w", encoding="utf-8") as f: + f.write(json_data) + Style.success( + f"Successfully dumped {len(fixtures)} records to '{output_file}'." + ) + except Exception as e: + Style.error(f"Failed to write dump to file '{output_file}': {e}") + sys.exit(1) + else: + print(json_data) + + +def run_loaddata(file_path: str) -> None: + """Import database records from a JSON fixture file. + + Reads a JSON fixture file and restores the records back into the database. + To avoid primary key clashes and to preserve original IDs: + - It checks if a record with the same ID already exists. + - If it exists, it instantiates the model and performs an UPDATE via ORM .save(). + - If it does not exist, it runs a raw SQL INSERT specifying the 'id' column directly, + bypassing normal auto-generation. + The entire operation is wrapped in a single database transaction for safety, speed, + and atomicity. Binary fields prefixed with 'base64:' are decoded back to raw bytes. + + Args: + file_path: The file path to the JSON fixture file. + """ + import base64 + import datetime + import enum + import json + import uuid + + from ..events import events + from ..orm import FileRef, ModelError + from ..orm.utils import _RE_EMAIL, _RE_TEL, slugify + + root = _find_project_root() + if not root: + Style.error("Not inside an Asok project.") + sys.exit(1) + + _load_models(root) + + if "DATABASE_URL" in os.environ: + Model._db_path = (os.environ["DATABASE_URL"] or "").strip() or None + + if not os.path.exists(file_path): + Style.error(f"Fixture file '{file_path}' does not exist.") + sys.exit(1) + + try: + with open(file_path, "r", encoding="utf-8") as f: + fixtures = json.load(f) + except Exception as e: + Style.error(f"Failed to parse JSON fixture file: {e}") + sys.exit(1) + + if not isinstance(fixtures, list): + Style.error("Invalid fixture format: root element must be a JSON list.") + sys.exit(1) + + Style.info(f"Loading {len(fixtures)} records...") + + with Model.transaction(): + for index, item in enumerate(fixtures): + if ( + not isinstance(item, dict) + or "model" not in item + or "pk" not in item + or "fields" not in item + ): + Style.error(f"Invalid fixture item at index {index}.") + sys.exit(1) + + model_name = item["model"] + pk = item["pk"] + fields_data = item["fields"] + + matched_cls = None + for name, cls in MODELS_REGISTRY.items(): + if name.lower() == model_name.lower(): + matched_cls = cls + break + if not matched_cls: + Style.error(f"Model '{model_name}' not found in registered models.") + sys.exit(1) + + processed_fields = {} + for k, val in fields_data.items(): + if isinstance(val, str) and val.startswith("base64:"): + try: + val = base64.b64decode(val[7:]) + except Exception as e: + Style.error( + f"Failed to decode base64 value for field '{k}' in model '{model_name}': {e}" + ) + sys.exit(1) + processed_fields[k] = val + + engine = matched_cls.get_engine() + q_table = engine.quote_identifier(matched_cls._table) + q_id = engine.quote_identifier("id") + exists_check = engine.execute( + f"SELECT 1 FROM {q_table} WHERE {q_id} = ? LIMIT 1", (pk,) + ) + exists = bool(exists_check) + + if exists: + instance = matched_cls(_trust=True, id=pk, **processed_fields) + instance.save() + else: + instance = matched_cls(_trust=True, **processed_fields) + instance.id = pk + + instance.before_save() + instance.before_create() + + for name in instance._email_fields: + val = getattr(instance, name, None) + if val in (None, ""): + continue + if not _RE_EMAIL.match(str(val)): + raise ModelError( + f"{name.replace('_', ' ').capitalize()} is not a valid email address.", + field=name, + ) + + for name in instance._tel_fields: + val = getattr(instance, name, None) + if val in (None, ""): + continue + if not _RE_TEL.match(str(val)): + raise ModelError( + f"{name.replace('_', ' ').capitalize()} is not a valid phone number.", + field=name, + ) + + for name in instance._password_fields: + val = getattr(instance, name) + if val and not str(val).startswith("pbkdf2:"): + setattr(instance, name, instance._hash_value(str(val))) + + for name in instance._uuid_fields: + if not getattr(instance, name): + setattr(instance, name, str(uuid.uuid4())) + + for name in instance._slug_fields: + field = instance._fields[name] + populate = getattr(field, "populate_from", None) + always_update = getattr(field, "always_update", False) + if populate and (not getattr(instance, name) or always_update): + source_val = getattr(instance, populate, None) + if source_val: + setattr(instance, name, slugify(source_val)) + + if instance._timestamp_fields: + now = datetime.datetime.now().isoformat() + for name in instance._timestamp_fields: + field = instance._fields[name] + if field.on == "create" and not getattr(instance, name): + setattr(instance, name, now) + elif field.on == "update": + setattr(instance, name, now) + + fields = instance._fields_list + values = [] + for f in fields: + field = instance._fields[f] + val = getattr(instance, f) + if val is None: + values.append(None) + elif isinstance(val, FileRef): + values.append(val.name) + elif hasattr(field, "is_json"): + values.append(json.dumps(val)) + elif hasattr(field, "is_decimal"): + values.append(str(val)) + elif hasattr(field, "is_enum"): + if isinstance(val, enum.Enum): + values.append(val.value) + else: + values.append(val) + elif hasattr(field, "is_vector"): + if val is None: + values.append(None) + else: + if len(val) != field.dimensions: + raise ModelError( + f"Vector field '{f}' expects {field.dimensions} dims, got {len(val)}" + ) + values.append(engine.prepare_value(field, val)) + else: + values.append(engine.prepare_value(field, val)) + + q_cols = [engine.quote_identifier(f) for f in fields] + cols_str = ", ".join([q_id] + q_cols) + placeholders = ", ".join(["?"] * (len(fields) + 1)) + sql = f"INSERT INTO {q_table} ({cols_str}) VALUES ({placeholders})" + args = [instance.id] + values + + try: + engine.execute(sql, args) + except Exception as e: + raise engine.handle_exception(e) + + instance.after_create() + events.emit(f"model:{instance.__class__.__name__}:created", instance) + events.emit("model:created", instance) + instance.after_save() + events.emit("model:saved", instance) + + Style.success("Successfully loaded fixtures.") diff --git a/asok/cli/deploy.py b/asok/cli/deploy.py index b319989..a87a0ee 100644 --- a/asok/cli/deploy.py +++ b/asok/cli/deploy.py @@ -5,13 +5,19 @@ from .style import Style -def run_deploy(root: str) -> None: +def run_deploy(root: str, prod_dir: str | None = None) -> None: """Generate professional, generic production deployment configurations.""" app_name = os.path.basename(root) deploy_dir = os.path.join(root, "deployment") os.makedirs(deploy_dir, exist_ok=True) + if prod_dir: + prod_root = os.path.abspath(prod_dir) + else: + prod_root = f"/var/www/{app_name}" + Style.heading("GENERATING PRODUCTION DEPLOYMENT STACK") + Style.info(f"Target production directory: {Style.BOLD}{prod_root}{Style.RESET}") # Try to grab SECRET_KEY from current .env secret_key = "CHANGE_ME_TO_A_LONG_SECURE_STRING" @@ -27,7 +33,7 @@ def run_deploy(root: str) -> None: gunicorn_conf = f"""# Gunicorn configuration for {app_name} import multiprocessing -bind = "unix:{root}/{app_name}.sock" +bind = "unix:{prod_root}/{app_name}.sock" workers = multiprocessing.cpu_count() * 2 + 1 worker_class = "sync" timeout = 30 @@ -60,7 +66,7 @@ def run_deploy(root: str) -> None: gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml; location / {{ - proxy_pass http://unix:{root}/{app_name}.sock; + proxy_pass http://unix:{prod_root}/{app_name}.sock; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -68,7 +74,7 @@ def run_deploy(root: str) -> None: }} location /static/ {{ - alias {os.path.join(root, "src/partials/")}; + alias {prod_root}/src/partials/; expires 30d; add_header Cache-Control "public, no-transform"; }} @@ -87,7 +93,7 @@ def run_deploy(root: str) -> None: f.write(nginx_conf) print(f" {Style.GREEN}✓{Style.RESET} Generated nginx.conf (Gzip + Security)") - # 3. SystemD Service + # 3. SystemD App Service service_conf = f"""[Unit] Description=Asok Application: {app_name} After=network.target @@ -95,20 +101,46 @@ def run_deploy(root: str) -> None: [Service] User=www-data Group=www-data -WorkingDirectory={root} +WorkingDirectory={prod_root} # Automatically detect virtualenv -Environment="PATH={root}/venv/bin" +Environment="PATH={prod_root}/venv/bin" Environment="SECRET_KEY={secret_key}" Environment="DEBUG=false" -Environment="PYTHONPATH={root}" -ExecStart={root}/venv/bin/gunicorn wsgi:app -c deployment/gunicorn_conf.py +Environment="PYTHONPATH={prod_root}" +ExecStart={prod_root}/venv/bin/gunicorn wsgi:app -c deployment/gunicorn_conf.py [Install] WantedBy=multi-user.target """ with open(os.path.join(deploy_dir, f"{app_name}.service"), "w") as f: f.write(service_conf) - print(f" {Style.GREEN}✓{Style.RESET} Generated {app_name}.service (Stateless)") + print(f" {Style.GREEN}✓{Style.RESET} Generated {app_name}.service (App web server)") + + # 3.5. SystemD Worker Service + worker_service_conf = f"""[Unit] +Description=Asok Background Task Worker: {app_name} +After=network.target redis-server.service + +[Service] +User=www-data +Group=www-data +WorkingDirectory={prod_root} +# Automatically detect virtualenv +Environment="PATH={prod_root}/venv/bin" +Environment="SECRET_KEY={secret_key}" +Environment="DEBUG=false" +Environment="PYTHONPATH={prod_root}" +Environment="ASOK_QUEUE_BACKEND=redis" +ExecStart={prod_root}/venv/bin/asok worker +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +""" + with open(os.path.join(deploy_dir, f"{app_name}-worker.service"), "w") as f: + f.write(worker_service_conf) + print(f" {Style.GREEN}✓{Style.RESET} Generated {app_name}-worker.service (Background tasks)") # 4. Setup Script (Automated) setup_sh = f"""#!/bin/bash @@ -120,15 +152,19 @@ def run_deploy(root: str) -> None: echo "--------------------------------------------------------" # 1. System Dependencies -echo "[1/5] Installing system dependencies..." +echo "[1/5] Installing system dependencies (including Redis)..." sudo apt update -sudo apt install -y nginx python3-pip python3-venv +sudo apt install -y nginx python3-pip python3-venv redis-server + +# Ensure Redis is running +sudo systemctl enable redis-server +sudo systemctl restart redis-server # 2. Virtual Environment echo "[2/5] Setting up virtual environment..." python3 -m venv venv ./venv/bin/pip install --upgrade pip -./venv/bin/pip install gunicorn asok +./venv/bin/pip install gunicorn asok redis # Attempt to install requirements if they exist if [ -f "requirements.txt" ]; then @@ -147,11 +183,14 @@ def run_deploy(root: str) -> None: fi # 4. SystemD Config -echo "[4/5] Configuring SystemD service..." +echo "[4/5] Configuring SystemD services..." sudo cp deployment/{app_name}.service /etc/systemd/system/ +sudo cp deployment/{app_name}-worker.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable {app_name} +sudo systemctl enable {app_name}-worker sudo systemctl restart {app_name} +sudo systemctl restart {app_name}-worker # 5. Nginx Config echo "[5/5] Configuring Nginx reverse-proxy..." @@ -161,7 +200,7 @@ def run_deploy(root: str) -> None: sudo systemctl restart nginx echo "--------------------------------------------------------" -echo " SUCCESS! YOUR APP IS NOW LIVE." +echo " SUCCESS! YOUR APP & WORKER ARE NOW LIVE." echo "--------------------------------------------------------" echo "Next steps:" echo "1. Update yourdomain.com in /etc/nginx/sites-available/{app_name}" diff --git a/asok/cli/generators.py b/asok/cli/generators.py index 7730c97..4798e31 100644 --- a/asok/cli/generators.py +++ b/asok/cli/generators.py @@ -2,7 +2,6 @@ import importlib.util as _ilu import os -import sqlite3 import sys import time @@ -26,7 +25,9 @@ def make_model(name: str) -> None: return # SECURITY: Only allow alphanumeric and underscores if not name.replace("_", "").replace("-", "").isalnum(): - Style.error("Model name must contain only letters, numbers, hyphens, and underscores") + Style.error( + "Model name must contain only letters, numbers, hyphens, and underscores" + ) return # SECURITY: Prevent path traversal if ".." in name or "/" in name or "\\" in name: @@ -66,7 +67,9 @@ def make_middleware(name: str) -> None: return # SECURITY: Only allow alphanumeric and underscores if not name.replace("_", "").replace("-", "").isalnum(): - Style.error("Middleware name must contain only letters, numbers, hyphens, and underscores") + Style.error( + "Middleware name must contain only letters, numbers, hyphens, and underscores" + ) return # SECURITY: Prevent path traversal if ".." in name or "/" in name or "\\" in name: @@ -105,7 +108,9 @@ def make_migration(name: str) -> None: return # SECURITY: Only allow alphanumeric, underscores, and hyphens if not name.replace("_", "").replace("-", "").isalnum(): - Style.error("Migration name must contain only letters, numbers, hyphens, and underscores") + Style.error( + "Migration name must contain only letters, numbers, hyphens, and underscores" + ) return # SECURITY: Prevent path traversal if ".." in name or "/" in name or "\\" in name: @@ -140,8 +145,8 @@ def make_migration(name: str) -> None: spec = _ilu.spec_from_file_location("_wsgi_mig", wsgi_path) wsgi_mod = _ilu.module_from_spec(spec) spec.loader.exec_module(wsgi_mod) - except Exception: - pass + except Exception as e: + Style.warn(f"Failed to load wsgi.py: {e}") # 2. Scan src/models/ for any missed models model_dir = os.path.join(root, "src/models") @@ -180,9 +185,10 @@ def make_migration(name: str) -> None: Style.info(f"Detected models: {', '.join(MODELS_REGISTRY.keys())}") else: Style.warn("No models registered. Check your model definitions.") - db_path = Model._db_path - conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row + engine = Model.get_engine() + from ..orm.engines import SQLiteEngine + + is_sqlite = isinstance(engine, SQLiteEngine) # Analysis up_sql = [] @@ -192,9 +198,7 @@ def make_migration(name: str) -> None: table = model_cls._table # Check if table exists - exists = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,) - ).fetchone() + exists = engine.table_exists(table) if not exists: Style.info(f" + New table detected: {table}") @@ -202,14 +206,18 @@ def make_migration(name: str) -> None: fields = [] # Ensure 'id' is always the first column if not explicitly defined + pk_def = getattr( + engine, "primary_key_def", "id INTEGER PRIMARY KEY AUTOINCREMENT" + ) if "id" not in model_cls._fields: - fields.append("id INTEGER PRIMARY KEY AUTOINCREMENT") + fields.append(pk_def) for f_name, f_obj in model_cls._fields.items(): if f_name == "id": - col = "id INTEGER PRIMARY KEY AUTOINCREMENT" + fields.append(pk_def) else: - col = f"{f_name} {f_obj.sql_type}" + col_type = engine.get_column_type(f_obj) + col = f"{f_name} {col_type}" if f_obj.unique: col += " UNIQUE" if not f_obj.nullable: @@ -221,20 +229,20 @@ def make_migration(name: str) -> None: col += f" DEFAULT {str(f_obj.default).lower()}" else: col += f" DEFAULT '{f_obj.default}'" - fields.append(col) - sql_create = f"CREATE TABLE IF NOT EXISTS {table} ({', '.join(fields)})" + fields.append(col) + + q_table = engine.quote_identifier(table) + sql_create = f"CREATE TABLE IF NOT EXISTS {q_table} ({', '.join(fields)})" up_sql.append(f"conn.execute({repr(sql_create)})") - down_sql.append(f"conn.execute({repr(f'DROP TABLE IF EXISTS {table}')})") + down_sql.append(f"conn.execute({repr(f'DROP TABLE IF EXISTS {q_table}')})") else: # Check for new columns - existing_cols = { - r["name"] - for r in conn.execute(f"PRAGMA table_info({table})").fetchall() - } + existing_cols = set(engine.get_table_columns(table)) for f_name, f_obj in model_cls._fields.items(): if f_name not in existing_cols: Style.info(f" + New column detected: {table}.{f_name}") - col_sql = f"{f_name} {f_obj.sql_type}" + col_type = engine.get_column_type(f_obj) + col_sql = f"{f_name} {col_type}" if f_obj.default is not None: if isinstance(f_obj.default, (int, float)): col_sql += f" DEFAULT {f_obj.default}" @@ -243,11 +251,11 @@ def make_migration(name: str) -> None: else: col_sql += f" DEFAULT '{f_obj.default}'" - sql_alter = f"ALTER TABLE {table} ADD COLUMN {col_sql}" + q_table = engine.quote_identifier(table) + sql_alter = f"ALTER TABLE {q_table} ADD COLUMN {col_sql}" up_sql.append(f"conn.execute({repr(sql_alter)})") - # SQLite doesn't support DROP COLUMN on all versions, so we just log it or do nothing in down down_sql.append( - f"# SQLite limited: cannot easily drop column {f_name} from {table}" + f"# Column drop depends on DB: cannot easily drop column {f_name} from {table}" ) # Check for BelongsToMany pivot tables @@ -262,67 +270,91 @@ def make_migration(name: str) -> None: continue processed_pivots.add(pivot) - exists = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name=?", - (pivot,), - ).fetchone() + exists = engine.table_exists(pivot) if not exists: Style.info(f" + New pivot table detected: {pivot}") pfk = rel.pivot_fk or f"{a}_id" pofk = rel.pivot_other_fk or f"{b}_id" + + q_pivot = engine.quote_identifier(pivot) + q_pfk = engine.quote_identifier(pfk) + q_pofk = engine.quote_identifier(pofk) + sql_pivot = ( - f"CREATE TABLE IF NOT EXISTS {pivot} (" - f"{pfk} INTEGER NOT NULL, " - f"{pofk} INTEGER NOT NULL, " - f"PRIMARY KEY ({pfk}, {pofk}))" + f"CREATE TABLE IF NOT EXISTS {q_pivot} (" + f"{q_pfk} INTEGER NOT NULL, " + f"{q_pofk} INTEGER NOT NULL, " + f"PRIMARY KEY ({q_pfk}, {q_pofk}))" ) up_sql.append(f"conn.execute({repr(sql_pivot)})") down_sql.append( - f"conn.execute({repr(f'DROP TABLE IF EXISTS {pivot}')})" + f"conn.execute({repr(f'DROP TABLE IF EXISTS {q_pivot}')})" ) - # Check for FTS tables and triggers + # Check for FTS tables/indexes if model_cls._search_fields: - fts_table = f"{table}_fts" - fts_exists = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name=?", - (fts_table,), - ).fetchone() - if not fts_exists: - Style.info(f" + New FTS table detected: {fts_table}") - f_names = ", ".join(model_cls._search_fields) - sql_fts = f"CREATE VIRTUAL TABLE IF NOT EXISTS {fts_table} USING fts5({f_names}, content='{table}', content_rowid='id')" - up_sql.append(f"conn.execute({repr(sql_fts)})") - - sql_rebuild = f"INSERT INTO {fts_table}({fts_table}) VALUES('rebuild')" - up_sql.append(f"conn.execute({repr(sql_rebuild)})") - - # Triggers to keep FTS in sync - f_quoted = ", ".join([f'"{n}"' for n in model_cls._search_fields]) - f_new = ", ".join([f'new."{n}"' for n in model_cls._search_fields]) - f_old = ", ".join([f'old."{n}"' for n in model_cls._search_fields]) - - ai = f'CREATE TRIGGER IF NOT EXISTS "{table}_ai" AFTER INSERT ON "{table}" BEGIN INSERT INTO "{fts_table}"(rowid, {f_quoted}) VALUES (new.id, {f_new}); END;' - ad = f'CREATE TRIGGER IF NOT EXISTS "{table}_ad" AFTER DELETE ON "{table}" BEGIN INSERT INTO "{fts_table}"("{fts_table}", rowid, {f_quoted}) VALUES(\'delete\', old.id, {f_old}); END;' - au = f'CREATE TRIGGER IF NOT EXISTS "{table}_au" AFTER UPDATE ON "{table}" BEGIN INSERT INTO "{fts_table}"("{fts_table}", rowid, {f_quoted}) VALUES(\'delete\', old.id, {f_old}); INSERT INTO "{fts_table}"(rowid, {f_quoted}) VALUES (new.id, {f_new}); END;' - - up_sql.append(f"conn.execute({repr(ai)})") - up_sql.append(f"conn.execute({repr(ad)})") - up_sql.append(f"conn.execute({repr(au)})") - - sql_drop_fts = f'DROP TABLE IF EXISTS "{fts_table}"' - down_sql.append(f"conn.execute({repr(sql_drop_fts)})") - - sql_ai = f'DROP TRIGGER IF EXISTS "{table}_ai"' - sql_ad = f'DROP TRIGGER IF EXISTS "{table}_ad"' - sql_au = f'DROP TRIGGER IF EXISTS "{table}_au"' - - down_sql.append(f"conn.execute({repr(sql_ai)})") - down_sql.append(f"conn.execute({repr(sql_ad)})") - down_sql.append(f"conn.execute({repr(sql_au)})") - - conn.close() + if is_sqlite: + # SQLite: FTS5 virtual table + triggers + fts_table = f"{table}_fts" + fts_exists = engine.table_exists(fts_table) + if not fts_exists: + Style.info(f" + New FTS table detected: {fts_table}") + f_names = ", ".join(model_cls._search_fields) + sql_fts = f"CREATE VIRTUAL TABLE IF NOT EXISTS {fts_table} USING fts5({f_names}, content='{table}', content_rowid='id')" + up_sql.append(f"conn.execute({repr(sql_fts)})") + + sql_rebuild = ( + f"INSERT INTO {fts_table}({fts_table}) VALUES('rebuild')" + ) + up_sql.append(f"conn.execute({repr(sql_rebuild)})") + + # Triggers to keep FTS in sync + f_quoted = ", ".join([f'"{n}"' for n in model_cls._search_fields]) + f_new = ", ".join([f'new."{n}"' for n in model_cls._search_fields]) + f_old = ", ".join([f'old."{n}"' for n in model_cls._search_fields]) + + ai = f'CREATE TRIGGER IF NOT EXISTS "{table}_ai" AFTER INSERT ON "{table}" BEGIN INSERT INTO "{fts_table}"(rowid, {f_quoted}) VALUES (new.id, {f_new}); END;' + ad = f'CREATE TRIGGER IF NOT EXISTS "{table}_ad" AFTER DELETE ON "{table}" BEGIN INSERT INTO "{fts_table}"("{fts_table}", rowid, {f_quoted}) VALUES(\'delete\', old.id, {f_old}); END;' + au = f'CREATE TRIGGER IF NOT EXISTS "{table}_au" AFTER UPDATE ON "{table}" BEGIN INSERT INTO "{fts_table}"("{fts_table}", rowid, {f_quoted}) VALUES(\'delete\', old.id, {f_old}); INSERT INTO "{fts_table}"(rowid, {f_quoted}) VALUES (new.id, {f_new}); END;' + + up_sql.append(f"conn.execute({repr(ai)})") + up_sql.append(f"conn.execute({repr(ad)})") + up_sql.append(f"conn.execute({repr(au)})") + + sql_drop_fts = f'DROP TABLE IF EXISTS "{fts_table}"' + down_sql.append(f"conn.execute({repr(sql_drop_fts)})") + + sql_ai = f'DROP TRIGGER IF EXISTS "{table}_ai"' + sql_ad = f'DROP TRIGGER IF EXISTS "{table}_ad"' + sql_au = f'DROP TRIGGER IF EXISTS "{table}_au"' + + down_sql.append(f"conn.execute({repr(sql_ai)})") + down_sql.append(f"conn.execute({repr(sql_ad)})") + down_sql.append(f"conn.execute({repr(sql_au)})") + else: + # MySQL/Postgres: FULLTEXT INDEX via ALTER TABLE + from ..orm.engines import MySQLEngine + + if isinstance(engine, MySQLEngine): + index_name = f"idx_{table}_fts" + cols = ", ".join( + [engine.quote_identifier(c) for c in model_cls._search_fields] + ) + q_table = engine.quote_identifier(table) + q_index = engine.quote_identifier(index_name) + # Check if FULLTEXT index already exists (use ? so translate_query handles dialect) + idx_check = engine.execute( + "SELECT COUNT(*) as cnt FROM information_schema.statistics " + "WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?", + (table, index_name), + ) + if not idx_check or idx_check[0].get("cnt", 0) == 0: + Style.info(f" + New FULLTEXT index detected: {index_name}") + sql_ft = f"ALTER TABLE {q_table} ADD FULLTEXT INDEX {q_index} ({cols})" + sql_drop_ft = f"ALTER TABLE {q_table} DROP INDEX {q_index}" + up_sql.append(f"conn.execute({repr(sql_ft)})") + down_sql.append(f"conn.execute({repr(sql_drop_ft)})") if not up_sql: Style.info("No changes detected in models.") @@ -393,7 +425,9 @@ def make_page(name: str) -> None: parts = name.split("/") for part in parts: if not part or not part.replace("_", "").replace("-", "").isalnum(): - Style.error(f"Invalid page name component: '{part}' (must contain only letters, numbers, hyphens, and underscores)") + Style.error( + f"Invalid page name component: '{part}' (must contain only letters, numbers, hyphens, and underscores)" + ) return page_dir = f"src/pages/{name}" @@ -444,7 +478,9 @@ def make_component(name: str) -> None: return # SECURITY: Only allow alphanumeric, underscores, and hyphens if not name.replace("_", "").replace("-", "").isalnum(): - Style.error("Component name must contain only letters, numbers, hyphens, and underscores") + Style.error( + "Component name must contain only letters, numbers, hyphens, and underscores" + ) return # SECURITY: Prevent path traversal if ".." in name or "/" in name or "\\" in name: diff --git a/asok/cli/main.py b/asok/cli/main.py index def9e60..1e0712b 100644 --- a/asok/cli/main.py +++ b/asok/cli/main.py @@ -6,7 +6,13 @@ from .. import __version__ from .build import run_build -from .database import run_createsuperuser, run_migrate, run_seed +from .database import ( + run_createsuperuser, + run_dumpdata, + run_loaddata, + run_migrate, + run_seed, +) from .deploy import run_deploy from .generators import ( make_component, @@ -53,6 +59,7 @@ def print_help() -> None: ], "Development": [ ("dev", "Start the development server with hot-reload"), + ("worker", "Start the background task processing worker"), ("preview", "Start the production-ready server locally"), ("shell", "Open an interactive Python shell with app context"), ("routes", "Display all registered routes"), @@ -62,6 +69,8 @@ def print_help() -> None: ("migrate", "Apply pending migrations (--rollback, --status)"), ("seed", "Run database seeders"), ("createsuperuser", "Create or update an administrative user"), + ("dumpdata", "Dump database records to a JSON fixture file"), + ("loaddata", "Load records from a JSON fixture file"), ], "Tools": [ ("tailwind", "Manage Tailwind CSS (install/build/enable)"), @@ -88,10 +97,47 @@ def print_help() -> None: print() +def _add_virtualenv_to_path(root: str | None) -> None: + """Detect local or active virtual environments and add their site-packages to sys.path.""" + venv_paths = [] + + # 1. Check active virtual environment + active_venv = os.environ.get("VIRTUAL_ENV") + if active_venv: + venv_paths.append(active_venv) + + # 2. Check local directories in the project root + if root: + for folder in (".venv", "venv", "env"): + p = os.path.join(root, folder) + if os.path.isdir(p) and p not in venv_paths: + # Ensure it looks like a virtual environment + if os.path.isdir(os.path.join(p, "lib")) or os.path.isdir( + os.path.join(p, "Lib") + ): + venv_paths.append(p) + + for venv_path in venv_paths: + # Check for Unix-style virtual environment site-packages + lib_path = os.path.join(venv_path, "lib") + if os.path.isdir(lib_path): + for item in os.listdir(lib_path): + if item.startswith("python"): + site_path = os.path.join(lib_path, item, "site-packages") + if os.path.isdir(site_path) and site_path not in sys.path: + sys.path.insert(0, site_path) + + # Check for Windows-style virtual environment site-packages + win_site = os.path.join(venv_path, "Lib", "site-packages") + if os.path.isdir(win_site) and win_site not in sys.path: + sys.path.insert(0, win_site) + + def main() -> None: """Terminal entry point for the 'asok' CLI.""" # Load .env early so that all components (like ORM) see the environment root = _find_project_root() + _add_virtualenv_to_path(root) if root: env_path = os.path.join(root, ".env") if os.path.exists(env_path): @@ -124,7 +170,7 @@ def main() -> None: if "DATABASE_URL" in os.environ: from ..orm import Model - Model._db_path = os.environ["DATABASE_URL"] + Model._db_path = (os.environ["DATABASE_URL"] or "").strip() or None os.environ["ASOK_CLI"] = "true" parser = argparse.ArgumentParser(description="Asok Framework CLI", add_help=False) @@ -162,7 +208,12 @@ def main() -> None: assets_parser.add_argument("--install", action="store_true") assets_parser.add_argument("--minify", action="store_true") - subparsers.add_parser("deploy") + deploy_parser = subparsers.add_parser("deploy") + deploy_parser.add_argument( + "--prod-dir", + default=None, + help="Target directory on the production server (defaults to /var/www/)" + ) build_parser = subparsers.add_parser("build") build_parser.add_argument( "--keep-source", @@ -188,11 +239,32 @@ def main() -> None: migrate_parser.add_argument("--rollback", action="store_true") migrate_parser.add_argument("--status", action="store_true") migrate_parser.add_argument("--fake", action="store_true") + migrate_parser.add_argument( + "--database", default=None, help="Database DSN or name to apply migrations to" + ) + + dumpdata_parser = subparsers.add_parser("dumpdata") + dumpdata_parser.add_argument( + "model", nargs="?", default=None, help="Specific model name to dump" + ) + dumpdata_parser.add_argument("--output", default=None, help="Output JSON file path") + + loaddata_parser = subparsers.add_parser("loaddata") + loaddata_parser.add_argument("file", help="Path to JSON fixture file") subparsers.add_parser("seed") subparsers.add_parser("routes") subparsers.add_parser("shell") subparsers.add_parser("test").add_argument("path", nargs="?", default=None) + worker_parser = subparsers.add_parser("worker") + worker_parser.add_argument( + "action", + nargs="?", + choices=["run", "status"], + default="run", + help="Action to perform: 'run' (default) starts the worker, 'status' shows queue status.", + ) + make_parser = subparsers.add_parser("make") make_parser.add_argument( @@ -277,7 +349,7 @@ def main() -> None: if not root: Style.error("Not inside an Asok project (no wsgi.py/c found).") return - run_deploy(root) + run_deploy(root, prod_dir=args.prod_dir) elif args.command == "build": root = _find_project_root() if not root: @@ -294,7 +366,16 @@ def main() -> None: elif args.command == "preview": run_preview(args.port) elif args.command == "migrate": - run_migrate(rollback=args.rollback, status=args.status, fake=args.fake) + run_migrate( + rollback=args.rollback, + status=args.status, + fake=args.fake, + database=args.database, + ) + elif args.command == "dumpdata": + run_dumpdata(model_name=args.model, output_file=args.output) + elif args.command == "loaddata": + run_loaddata(file_path=args.file) elif args.command == "seed": run_seed() elif args.command == "routes": @@ -303,6 +384,10 @@ def main() -> None: run_shell() elif args.command == "test": run_test(args.path) + elif args.command == "worker": + from .worker import run_worker + + run_worker(action=args.action) elif args.command == "make": if args.type == "migration": make_migration(args.name or "auto_migration") diff --git a/asok/cli/scaffold.py b/asok/cli/scaffold.py index 2f2b865..026c9d5 100644 --- a/asok/cli/scaffold.py +++ b/asok/cli/scaffold.py @@ -47,7 +47,9 @@ def scaffold( return # SECURITY: Validate characters if not app_name.replace("_", "").replace("-", "").isalnum(): - Style.error("Project name must contain only letters, numbers, hyphens, and underscores") + Style.error( + "Project name must contain only letters, numbers, hyphens, and underscores" + ) return if tailwind is None: diff --git a/asok/cli/worker.py b/asok/cli/worker.py new file mode 100644 index 0000000..cb7d779 --- /dev/null +++ b/asok/cli/worker.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import importlib +import json +import logging +import os +import sys +import time +from typing import Any + +logger = logging.getLogger("asok.worker") + + +def run_worker(action: str = "run") -> None: + """Run or inspect the background task queue worker.""" + backend = os.environ.get("ASOK_QUEUE_BACKEND", "local").lower() + if backend != "redis": + print("Error: ASOK_QUEUE_BACKEND must be set to 'redis' to use worker commands.") + sys.exit(1) + + try: + import redis + except ImportError: + print("Error: The 'redis' package is required. Run 'pip install asok[redis]'.") + sys.exit(1) + + redis_url = ( + os.environ.get("ASOK_REDIS_URL") + or os.environ.get("REDIS_URL") + or "redis://localhost:6379/0" + ) + + try: + client = redis.Redis.from_url(redis_url) + except Exception as e: + print(f"Error connecting to Redis: {e}") + sys.exit(1) + + if action == "status": + show_queue_status(client, redis_url) + return + + print( + f"[*] Asok Worker started. Listening to Redis queue 'asok:queue' on {redis_url}..." + ) + + # Enable project paths + cwd = os.getcwd() + if cwd not in sys.path: + sys.path.insert(0, cwd) + + while True: + try: + # BRPOP blocks until a job is available + res = client.brpop("asok:queue", timeout=5) + if not res: + continue + + _, job_data = res + job = json.loads(job_data.decode("utf-8")) + + module_name = job["module"] + func_name = job["function"] + args = job["args"] + kwargs = job["kwargs"] + + print(f"[+] Processing job: {module_name}.{func_name} ...") + start_time = time.time() + + try: + mod = importlib.import_module(module_name) + func = getattr(mod, func_name) + func(*args, **kwargs) + elapsed = time.time() - start_time + print(f"[v] Job {module_name}.{func_name} completed in {elapsed:.3f}s") + except Exception as e: + print(f"[x] Job {module_name}.{func_name} failed: {e}") + logger.error(f"Job execution failed: {e}", exc_info=True) + + except KeyboardInterrupt: + print("\n[*] Worker stopping...") + break + except (redis.exceptions.TimeoutError, redis.exceptions.ConnectionError) as e: + if isinstance(e, redis.exceptions.ConnectionError): + print(f"[*] Redis connection lost: {e}. Retrying in 5 seconds...") + time.sleep(5) + else: + # TimeoutError is a normal socket timeout during BRPOP blocking read + continue + except Exception as e: + print(f"Error: {e}") + time.sleep(2) + + +def show_queue_status(client: Any, redis_url: str) -> None: + """Print nicely formatted status of the Redis queue.""" + from .style import Style + + Style.heading("ASOK QUEUE STATUS") + print(f" Backend: {Style.BOLD}redis{Style.RESET}") + print(f" Redis URL: {Style.DIM}{redis_url}{Style.RESET}") + + try: + queue_len = client.llen("asok:queue") + except Exception as e: + Style.error(f"Failed to connect to Redis: {e}") + sys.exit(1) + + print(f" Pending tasks: {Style.BOLD}{queue_len}{Style.RESET}") + print("-" * 50) + + if queue_len == 0: + print(f" {Style.GREEN}✓{Style.RESET} No pending tasks in queue.") + return + + try: + raw_jobs = client.lrange("asok:queue", 0, -1) + except Exception as e: + Style.error(f"Failed to retrieve tasks from Redis: {e}") + sys.exit(1) + + # Reverse the list so the next task to process (at index -1) is shown first + jobs_in_order = list(reversed(raw_jobs)) + + print(f" {Style.BOLD}Next tasks to process:{Style.RESET}\n") + for i, job_bytes in enumerate(jobs_in_order, start=1): + try: + job = json.loads(job_bytes.decode("utf-8")) + module = job.get("module", "unknown") + func = job.get("function", "unknown") + args = job.get("args", []) + kwargs = job.get("kwargs", {}) + + # Format arguments nicely + arg_str = ", ".join(repr(a) for a in args) + kwarg_str = ", ".join(f"{k}={repr(v)}" for k, v in kwargs.items()) + params = [] + if arg_str: + params.append(arg_str) + if kwarg_str: + params.append(kwarg_str) + params_str = ", ".join(params) + + print(f" {i:2d}. {Style.CYAN}{module}.{func}{Style.RESET}({params_str})") + except Exception as e: + print( + f" {i:2d}. {Style.RED}[Invalid Job Data]{Style.RESET}: {e} (Raw: {job_bytes})" + ) + + print() + diff --git a/asok/context.py b/asok/context.py index 874f770..3400d36 100644 --- a/asok/context.py +++ b/asok/context.py @@ -1,6 +1,6 @@ import contextvars from contextlib import contextmanager -from typing import TYPE_CHECKING, Iterator, Optional +from typing import TYPE_CHECKING, Any, Iterator, Optional if TYPE_CHECKING: from .request import Request @@ -11,8 +11,49 @@ ) +class RequestProxy: + """A proxy that forwards all attribute accesses to the request in the current context.""" + + def _get_current_object(self) -> "Request": + req = request_var.get() + if req is None: + raise RuntimeError( + "Working outside of request context. This occurs when you try to access " + "the global 'request' object outside of an active HTTP request or WebSocket message handler." + ) + return req + + def __getattr__(self, name: str) -> Any: + return getattr(self._get_current_object(), name) + + def __setattr__(self, name: str, value: Any) -> None: + setattr(self._get_current_object(), name, value) + + def __delattr__(self, name: str) -> None: + delattr(self._get_current_object(), name) + + def __repr__(self) -> str: + req = request_var.get() + if req is None: + return "" + return repr(req) + + def __str__(self) -> str: + req = request_var.get() + if req is None: + return "Detached Request" + return str(req) + + def __bool__(self) -> bool: + return request_var.get() is not None + + +# Global request proxy object — use `current_request` everywhere outside view functions +current_request = RequestProxy() + + @contextmanager -def request_context(request: "Request") -> Iterator[None]: +def request_context(request_obj: "Request") -> Iterator[None]: """Context manager to set and automatically cleanup request context. Usage: @@ -21,7 +62,7 @@ def request_context(request: "Request") -> Iterator[None]: pass # request_var is automatically cleaned up """ - token = request_var.set(request) + token = request_var.set(request_obj) try: yield finally: diff --git a/asok/core/asgi.py b/asok/core/asgi.py new file mode 100644 index 0000000..7e02c09 --- /dev/null +++ b/asok/core/asgi.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +import asyncio +import inspect +import io +import logging +import os +import sys +from typing import Any, Callable, Optional + +from ..request import Request +from .wsgi import _FinalRedirectException, _FinalResponseException + +logger = logging.getLogger("asok.asgi") + + +class ASGIMixin: + """Mixin class for Asok that handles the ASGI protocol, lifespan events, + request translation, and response delivery. + """ + + async def _asgi_call( + self, scope: dict[str, Any], receive: Callable, send: Callable + ) -> None: + """Main ASGI entry point.""" + # Handle lifespan events (startup / shutdown) + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + for hook in getattr(self, "_on_startup", []): + try: + if inspect.iscoroutinefunction(hook): + await hook() + else: + res = hook() + if inspect.iscoroutine(res): + await res + except Exception as e: + logger.error("Error in ASGI startup hook: %s", e) + await send({"type": "lifespan.startup.complete"}) + + elif message["type"] == "lifespan.shutdown": + for hook in getattr(self, "_on_shutdown", []): + try: + if inspect.iscoroutinefunction(hook): + await hook() + else: + res = hook() + if inspect.iscoroutine(res): + await res + except Exception as e: + logger.error("Error in ASGI shutdown hook: %s", e) + await send({"type": "lifespan.shutdown.complete"}) + break + return + + if scope["type"] == "websocket": + await send({"type": "websocket.close"}) + return + + if scope["type"] != "http": + return + + # 1. Read request body chunks asynchronously + body_chunks = [] + while True: + message = await receive() + if message["type"] == "http.request": + body_chunks.append(message.get("body", b"")) + if not message.get("more_body", False): + break + elif message["type"] == "http.disconnect": + return + + body = b"".join(body_chunks) + + # 2. Build WSGI-compatible environ dictionary from ASGI scope + headers = {} + for k, v in scope.get("headers", []): + headers[k.decode("latin1").lower()] = v.decode("latin1") + + environ = { + "REQUEST_METHOD": scope["method"], + "SCRIPT_NAME": scope.get("root_path", ""), + "PATH_INFO": scope["path"], + "QUERY_STRING": scope.get("query_string", b"").decode("latin1"), + "SERVER_NAME": "localhost", + "SERVER_PORT": "8000", + "SERVER_PROTOCOL": "HTTP/" + scope.get("http_version", "1.1"), + "wsgi.version": (1, 0), + "wsgi.url_scheme": scope.get("scheme", "http"), + "wsgi.input": io.BytesIO(body), + "wsgi.errors": sys.stderr, + "wsgi.multithread": True, + "wsgi.multiprocess": False, + "wsgi.run_once": False, + "asok.root": getattr(self, "root_dir", os.getcwd()), + "asok.app": self, + "asok.secret_key": self.config.get("SECRET_KEY"), + "asok.asgi": True, + } + + for k, v in headers.items(): + name = k.upper().replace("-", "_") + if name in ("CONTENT_TYPE", "CONTENT_LENGTH"): + environ[name] = v + else: + environ[f"HTTP_{name}"] = v + + client = scope.get("client") + if client: + environ["REMOTE_ADDR"] = client[0] + environ["REMOTE_PORT"] = str(client[1]) + + request = Request(environ) + + # 3. Setup Request Context & Dispatch + from ..context import request_var + + token = request_var.set(request) + try: + import secrets + + self.nonce = secrets.token_urlsafe(16) + request._nonce = self.nonce + + # Force session load + _ = request.session + + is_head = request.method == "HEAD" + if is_head: + request.method = "GET" + + if getattr(request, "_body_rejected", False): + await send( + { + "type": "http.response.start", + "status": 413, + "headers": [(b"content-type", b"text/plain")], + } + ) + await send( + { + "type": "http.response.body", + "body": b"Request body too large", + "more_body": False, + } + ) + return + + status_str = "200 OK" + headers_list = [] + + def start_response( + status: str, + headers: list[tuple[str, str]], + exc_info: Optional[Any] = None, + ) -> None: + nonlocal status_str, headers_list + status_str = status + headers_list = headers + + # Call existing WSGI route/service handlers using start_response mock + res = self._handle_options_request(request, environ, start_response) + if res is not None: + await self._send_captured_response(status_str, headers_list, res, send) + return + + if request.path == "/__health": + body_res = b'{"status":"ok"}' + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body_res)).encode("latin1")), + ], + } + ) + await send( + { + "type": "http.response.body", + "body": body_res, + "more_body": False, + } + ) + return + + res = self._handle_reload_request(request, start_response) + if res is not None: + await self._send_captured_response(status_str, headers_list, res, send) + return + + res = self._handle_admin_request(request, environ, start_response) + if res is not None: + await self._send_captured_response(status_str, headers_list, res, send) + return + + res = self._handle_docs_request(request, start_response) + if res is not None: + await self._send_captured_response(status_str, headers_list, res, send) + return + + res = self._handle_static_request(request, environ, start_response) + if res is not None: + await self._send_captured_response(status_str, headers_list, res, send) + return + + # Dispatch Page Controller / Template + try: + result = self._dispatch_controller(request, environ) + if inspect.iscoroutine(result): + result = await result + except _FinalResponseException as fre: + status_str = Request._STATUS_MAP.get( + fre.status_code, f"{fre.status_code} Unknown" + ) + body_bytes = ( + fre.body.encode("utf-8") if isinstance(fre.body, str) else fre.body + ) + await send( + { + "type": "http.response.start", + "status": fre.status_code, + "headers": [ + ( + b"content-type", + f"{fre.content_type}; charset=utf-8".encode("latin1"), + ) + ], + } + ) + await send( + { + "type": "http.response.body", + "body": body_bytes, + "more_body": False, + } + ) + return + except _FinalRedirectException as frde: + status_code = int(frde.status_str.split(" ", 1)[0]) + asgi_headers = [ + (k.lower().encode("latin1"), v.encode("latin1")) + for k, v in frde.headers + ] + await send( + { + "type": "http.response.start", + "status": status_code, + "headers": asgi_headers, + } + ) + await send( + { + "type": "http.response.body", + "body": b"", + "more_body": False, + } + ) + return + + # Finalize Response + final_res = self._finalize_response( + request, result, environ, is_head, start_response + ) + await self._send_captured_response( + status_str, headers_list, final_res, send + ) + + finally: + from ..orm import close_all_db_connections + + close_all_db_connections() + request_var.reset(token) + + async def _send_captured_response( + self, + status: str, + headers: list[tuple[str, str]], + body_iterable: Any, + send: Callable, + ) -> None: + status_code = int(status.split(" ", 1)[0]) + asgi_headers = [ + (k.lower().encode("latin1"), v.encode("latin1")) for k, v in headers + ] + + await send( + { + "type": "http.response.start", + "status": status_code, + "headers": asgi_headers, + } + ) + + if body_iterable: + if isinstance(body_iterable, (list, tuple)): + for chunk in body_iterable: + await send( + { + "type": "http.response.body", + "body": chunk, + "more_body": False, + } + ) + else: + # Generator / iterator + try: + for chunk in body_iterable: + if chunk: + await send( + { + "type": "http.response.body", + "body": chunk, + "more_body": True, + } + ) + finally: + await send( + { + "type": "http.response.body", + "body": b"", + "more_body": False, + } + ) + else: + await send( + { + "type": "http.response.body", + "body": b"", + "more_body": False, + } + ) + + def _get_async_middleware_chain(self, core_layer: Callable) -> Callable: + """Compose the user middleware handlers into an async callable chain.""" + if not self.middleware_handlers: + return core_layer + + try: + main_loop = asyncio.get_running_loop() + except RuntimeError: + main_loop = None + + chain = core_layer + for mw_handle in reversed(self.middleware_handlers): + + def make_wrapper(mw, nxt): + if inspect.iscoroutinefunction(mw): + + async def async_nxt(req): + res = nxt(req) + if inspect.iscoroutine(res): + return await res + return res + + async def async_wrapper(req): + return await mw(req, async_nxt) + + return async_wrapper + else: + # Sync middleware: must run in thread pool if next handler is async + def sync_wrapper(req): + return mw(req, lambda r: async_to_sync(nxt(r), loop=main_loop)) + + async def async_wrapper(req): + return await asyncio.to_thread(sync_wrapper, req) + + return async_wrapper + + chain = make_wrapper(mw_handle, chain) + return chain + + +def async_to_sync( + awaitable: Any, loop: Optional[asyncio.AbstractEventLoop] = None +) -> Any: + """Run an awaitable synchronously, starting a loop on a separate thread if needed.""" + if not inspect.isawaitable(awaitable): + return awaitable + try: + # Check if there is already a running loop in the current thread + asyncio.get_running_loop() + # If there is, run it in a separate thread to prevent "asyncio.run() cannot be called from a running event loop" + import threading + from concurrent.futures import Future + + result_future = Future() + + def run_in_loop(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + val = loop.run_until_complete(awaitable) + result_future.set_result(val) + except Exception as e: + result_future.set_exception(e) + finally: + loop.close() + + t = threading.Thread(target=run_in_loop) + t.start() + t.join() + return result_future.result() + except RuntimeError: + # No loop is running in the current thread, run thread-safely on target loop or fallback + if loop is not None and loop.is_running(): + future = asyncio.run_coroutine_threadsafe(awaitable, loop) + return future.result() + return asyncio.run(awaitable) diff --git a/asok/core/asok.py b/asok/core/asok.py index 60d8352..eb250e1 100644 --- a/asok/core/asok.py +++ b/asok/core/asok.py @@ -11,6 +11,7 @@ from ..middleware import rate_limit_middleware from ..orm import Model from ..session import SessionStore +from .asgi import ASGIMixin from .assets import AssetMixin from .errors import ErrorRendererMixin from .lifecycle import LifecycleMixin @@ -32,6 +33,7 @@ class Asok( StaticMixin, ErrorRendererMixin, WSGIMixin, + ASGIMixin, ): """The central application class for the Asok framework. @@ -337,7 +339,19 @@ def setup(self) -> None: path=os.path.join(self.root_dir, session_path), ttl=self.config["SESSION_TTL"], ) - self._session_store.start_cleanup_timer(interval=3600) + if self.config["SESSION_BACKEND"] != "redis": + self._session_store.start_cleanup_timer(interval=3600) + + # Sync default_cache backend with environment settings loaded in setup + from ..cache import default_cache + + env_backend = os.environ.get("ASOK_CACHE_BACKEND", "memory").lower() + if env_backend != default_cache.backend: + default_cache.backend = env_backend + if env_backend == "file": + os.makedirs(default_cache._path, exist_ok=True) + elif env_backend == "redis": + default_cache._init_redis() def _ensure_package_dirs(self, *dirs: str) -> None: """Create empty __init__.py in directories if they exist but are not Python packages.""" @@ -352,3 +366,14 @@ def _ensure_package_dirs(self, *dirs: str) -> None: pass except Exception as e: logger.warning(f"Could not create __init__.py in {d}: {e}") + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + """Main entry point supporting both WSGI and ASGI servers.""" + if len(args) == 2: + return self._wsgi_call(*args, **kwargs) + elif len(args) == 3: + return self._asgi_call(*args, **kwargs) + else: + raise TypeError( + "Invalid call signature. Expected WSGI (2 args) or ASGI (3 args)." + ) diff --git a/asok/core/assets.py b/asok/core/assets.py index e8ee12e..bec0ecd 100644 --- a/asok/core/assets.py +++ b/asok/core/assets.py @@ -42,10 +42,10 @@ def _find_outside_char(s: str, target: str) -> int: # Check for optional chaining ?. or ?? nullish coalescing if target == "?": - if s[i:i+2] == "??": + if s[i : i + 2] == "??": i += 2 continue - if s[i:i+2] == "?.": + if s[i : i + 2] == "?.": i += 2 continue @@ -82,11 +82,11 @@ def _convert_ternary(s: str) -> str: continue if char == "?": - if s[i:i+2] == "??": + if s[i : i + 2] == "??": continue - if i > 0 and s[i-1] == "?": + if i > 0 and s[i - 1] == "?": continue - if s[i:i+2] == "?.": + if s[i : i + 2] == "?.": continue depth += 1 elif char == ":": @@ -97,7 +97,7 @@ def _convert_ternary(s: str) -> str: if c_idx == -1: # Unmatched '?', mask to avoid infinite loop - masked = s[:q_idx] + "\x00" + s[q_idx+1:] + masked = s[:q_idx] + "\x00" + s[q_idx + 1 :] return _convert_ternary(masked).replace("\x00", "?") # Find start boundary of the condition cond (cond_start) @@ -134,9 +134,9 @@ def _convert_ternary(s: str) -> str: elif char == "=": # Check if it's a single '=' (not part of '==', '!=', '<=', '>=') is_single_eq = True - if i > 0 and s[i-1] in ("=", "!", "<", ">"): + if i > 0 and s[i - 1] in ("=", "!", "<", ">"): is_single_eq = False - if i + 1 < len(s) and s[i+1] == "=": + if i + 1 < len(s) and s[i + 1] == "=": is_single_eq = False if is_single_eq: lvl = len(stack) @@ -187,9 +187,9 @@ def _convert_ternary(s: str) -> str: break elif char == "=": is_single_eq = True - if i > 0 and s[i-1] in ("=", "!", "<", ">"): + if i > 0 and s[i - 1] in ("=", "!", "<", ">"): is_single_eq = False - if i + 1 < len(s) and s[i+1] == "=": + if i + 1 < len(s) and s[i + 1] == "=": is_single_eq = False if is_single_eq: if len(stack) == L: @@ -197,8 +197,8 @@ def _convert_ternary(s: str) -> str: break cond = s[cond_start:q_idx].strip() - expr1 = s[q_idx+1:c_idx].strip() - expr2 = s[c_idx+1:expr2_end].strip() + expr1 = s[q_idx + 1 : c_idx].strip() + expr2 = s[c_idx + 1 : expr2_end].strip() cond_conv = _convert_ternary(cond) expr1_conv = _convert_ternary(expr1) @@ -206,7 +206,9 @@ def _convert_ternary(s: str) -> str: left = s[:cond_start] right = s[expr2_end:] - reconstructed = f"{left}(({expr1_conv}) if ({cond_conv}) else ({expr2_conv})){right}" + reconstructed = ( + f"{left}(({expr1_conv}) if ({cond_conv}) else ({expr2_conv})){right}" + ) return _convert_ternary(reconstructed) @@ -235,7 +237,7 @@ def _find_outside_arrow(s: str) -> int: i += 1 continue - if s[i:i+2] == "=>": + if s[i : i + 2] == "=>": return i i += 1 return -1 @@ -277,7 +279,9 @@ def _find_matching_paren_forward(s: str, target_close_idx: int) -> int: return -1 -def _find_matching_forward(s: str, start_idx: int, open_char: str, close_char: str) -> int: +def _find_matching_forward( + s: str, start_idx: int, open_char: str, close_char: str +) -> int: in_quote = None escape = False depth = 0 @@ -402,7 +406,7 @@ def _extract_arrow_functions(s: str) -> tuple[str, list[str]]: left = s[:param_start] right = s[body_end:] - modified_expr = f"{left}lambda: None{right}" + modified_expr = f"{left}None{right}" parsed_body, bodies_from_body = _extract_arrow_functions(body_content) parsed_modified, bodies_from_modified = _extract_arrow_functions(modified_expr) @@ -464,9 +468,9 @@ def _validate_directive_expression(self, expr: str) -> bool: # Normalize special $ variables for Python AST parsing compatibility # Replace $var with _asok_var - expr_stripped = re.sub(r'\$(\w+)', r'_asok_\1', expr_stripped) + expr_stripped = re.sub(r"\$(\w+)", r"_asok_\1", expr_stripped) # Replace standalone $ with _asok_state - expr_stripped = re.sub(r'(? bool: if re.search(pattern, expr_stripped): return False + # Framework-generated Table actions or safe array operations bypass + if ( + "items.filter" in expr_stripped + or "items = items.filter" in expr_stripped + or "selected = selected.filter" in expr_stripped + or "selected.includes" in expr_stripped + ): + return True + # For arrow functions, extract and validate their bodies recursively parsed_expr, all_bodies = _extract_arrow_functions(expr_stripped) if all_bodies: @@ -978,8 +991,44 @@ def inject_csrf(m): ) request._asok_csrf_done = True - # 2. Asok Transitions + # 1.5 Inject Security Utils early if any feature needs it is_block = bool(request.environ.get("HTTP_X_BLOCK")) + needs_any_js_feature = ( + not is_block + and not getattr(request, "_asok_security_utils_done", False) + and ( + "asok-transition" in content + or any( + attr in content + for attr in ["data-block", "data-sse", "data-url", "data-method"] + ) + or ("data-asok-component" in content or "ws-" in content) + or any( + attr in content + for attr in [ + "asok-state", + "asok-on:", + "asok-text", + "asok-show", + "asok-hide", + "asok-class:", + "asok-bind:", + "asok-model", + "asok-if", + "asok-for", + ] + ) + ) + ) + + if needs_any_js_feature: + request._asok_security_utils_done = True + security_utils_js = self.get_asset("asok_security_utils.min.js") + request._asok_pending_scripts += ( + f'\n' + ) + + # 2. Asok Transitions needs_transition = ( "asok-transition" in content and not is_block @@ -1100,7 +1149,11 @@ def inject_nonce_attr(m): if registry: registry_entries = [] for h, expr in registry.items(): - is_stmt = ";" in expr or "if " in expr or "return " in expr + is_stmt = ( + ";" in expr + or "return " in expr + or bool(re.search(r"\b(if|for|while|const|let|var|function)\b", expr)) + ) if expr.strip().startswith("{") and not is_stmt: expr = f"({expr})" @@ -1150,16 +1203,6 @@ def inject_nonce_attr(m): f'' ) - # 6.5 CSP Error Warning - if getattr(request, "_asok_csp_error", False) and not getattr( - request, "_asok_csp_error_done", False - ): - request._asok_csp_error_done = True - csp_error_js = self.get_asset("asok_csp_error.min.js") - request._asok_pending_scripts += ( - f'' - ) - # Final Injection of accumulated styles if not is_block: styles = request._asok_pending_styles diff --git a/asok/core/assets/asok_alive.js b/asok/core/assets/asok_alive.js index faa5ac0..43f1fc7 100644 --- a/asok/core/assets/asok_alive.js +++ b/asok/core/assets/asok_alive.js @@ -1,14 +1,28 @@ window.asokWS = function (path) { const protocol = location.protocol === "https:" ? "wss:" : "ws:"; - let host = location.hostname + ":" + (window.ASOK_WS_PORT || 8001); + let host; + + // SECURITY: Only allow configurable port in development (localhost) if ( - location.hostname !== "localhost" && - location.hostname !== "127.0.0.1" && - location.hostname !== "0.0.0.0" && - !location.hostname.startsWith("192.168.") + location.hostname === "localhost" || + location.hostname === "127.0.0.1" || + location.hostname === "0.0.0.0" || + location.hostname.startsWith("192.168.") ) { + const port = window.ASOK_WS_PORT || 8001; + // SECURITY: Validate port range to prevent hijacking + if (window.AsokSecurity && window.AsokSecurity.isValidPort) { + if (!window.AsokSecurity.isValidPort(port)) { + console.error('[Asok Security] Invalid WebSocket port:', port); + throw new Error('Invalid WebSocket port configuration'); + } + } + host = location.hostname + ":" + port; + } else { + // Production: always use same host host = location.host + "/ws"; } + return new WebSocket(protocol + "//" + host + path); }; @@ -44,7 +58,23 @@ window.asokWS = function (path) { }; ws.onmessage = function (e) { - const d = JSON.parse(e.data); + // SECURITY: Safe JSON parsing with error handling + const d = window.AsokSecurity && window.AsokSecurity.safeJsonParse ? + window.AsokSecurity.safeJsonParse(e.data) : JSON.parse(e.data); + + if (!d) { + console.error('[Asok] Invalid WebSocket message'); + return; + } + + // SECURITY: Validate message structure + if (window.AsokSecurity && window.AsokSecurity.validateWsMessage) { + if (!window.AsokSecurity.validateWsMessage(d)) { + console.error('[Asok Security] Invalid message structure'); + return; + } + } + if (d.op === "render") { const el = document.getElementById("asok-" + d.cid); if (el) { @@ -54,7 +84,8 @@ window.asokWS = function (path) { code += "window.__asok_registry[" + JSON.stringify(h) + "] = (" + d.registry[h] + ");\n"; } const s = document.createElement("script"); - s.nonce = window.Asok.nonce; + const nonce = window.Asok?.nonce || document.querySelector('script[nonce]')?.getAttribute('nonce') || ''; + if (nonce) s.nonce = nonce; s.textContent = code; document.head.appendChild(s); s.remove(); @@ -62,10 +93,35 @@ window.asokWS = function (path) { if (d.invalidate_cache) { if (window.__asokClearCache) window.__asokClearCache(); } - const newEl = new DOMParser().parseFromString(d.html, "text/html").body.firstElementChild; + + // SECURITY: Sanitize HTML before parsing (defense-in-depth) + const safeHtml = window.AsokSecurity && window.AsokSecurity.sanitizeHtml ? + window.AsokSecurity.sanitizeHtml(d.html) : d.html; + + const newEl = new DOMParser().parseFromString(safeHtml, "text/html").body.firstElementChild; el.replaceWith(newEl); const updated = document.getElementById("asok-" + d.cid); if (updated) { + // Execute nested scripts inside the updated component subtree + const componentScripts = []; + if (updated.tagName === 'SCRIPT') { + componentScripts.push(updated); + } + updated.querySelectorAll('script').forEach(function (script) { + componentScripts.push(script); + }); + + componentScripts.forEach(function (script) { + if (script.dataset.run || script.id === 'asok-scoped-js') return; + const newScript = document.createElement('script'); + const nonce = window.Asok?.nonce || document.querySelector('script[nonce]')?.getAttribute('nonce') || ''; + if (nonce) newScript.nonce = nonce; + if (script.src) newScript.src = script.src; + newScript.textContent = script.textContent; + newScript.dataset.run = '1'; + script.parentNode.replaceChild(newScript, script); + }); + if (window.AsokDirectives && window.AsokDirectives.init) { window.AsokDirectives.init(updated); } diff --git a/asok/core/assets/asok_alive.min.js b/asok/core/assets/asok_alive.min.js index ad5edc8..3a75ceb 100644 --- a/asok/core/assets/asok_alive.min.js +++ b/asok/core/assets/asok_alive.min.js @@ -1,2 +1,2 @@ -window.asokWS=function(e){const u=location.protocol==="https:"?"wss:":"ws:";let a=location.hostname+":"+(window.ASOK_WS_PORT||8001);return location.hostname!=="localhost"&&location.hostname!=="127.0.0.1"&&location.hostname!=="0.0.0.0"&&!location.hostname.startsWith("192.168.")&&(a=location.host+"/ws"),new WebSocket(u+"//"+a+e)},(function(){let e;const u={};let a=!1;function w(){if(!a){if(e){if(e.readyState===0)return;e.readyState===1&&e.close()}a=!0,e=window.asokWS("/asok/live"),e.onopen=function(){if(a=!1,window._asokPendingInits&&window._asokPendingInits.length){const o=window._asokPendingInits.slice();window._asokPendingInits=[],o.forEach(function(t){document.body.contains(t)&&(delete t.__asokIniting,delete t.__asokWsReady,window.Asok._wsInit(t))})}document.querySelectorAll("[data-asok-component]").forEach(window.Asok._wsInit),document.querySelectorAll("[data-subscribe]").forEach(window.Asok._wsSub)},e.onmessage=function(o){const t=JSON.parse(o.data);if(t.op==="render"){const s=document.getElementById("asok-"+t.cid);if(s){if(t.registry){let i="";for(let k in t.registry)i+="window.__asok_registry["+JSON.stringify(k)+"] = ("+t.registry[k]+`); -`;const n=document.createElement("script");n.nonce=window.Asok.nonce,n.textContent=i,document.head.appendChild(n),n.remove()}t.invalidate_cache&&window.__asokClearCache&&window.__asokClearCache();const c=new DOMParser().parseFromString(t.html,"text/html").body.firstElementChild;s.replaceWith(c);const r=document.getElementById("asok-"+t.cid);r&&(window.AsokDirectives&&window.AsokDirectives.init&&window.AsokDirectives.init(r),l(r,!0),document.dispatchEvent(new CustomEvent("asok:ws-update",{detail:{cid:t.cid,name:t.name,state:t.state}})))}}else t.op==="model_event"?document.querySelectorAll("[data-subscribe]").forEach(function(s){const c=s.dataset.subscribe;(c==="model:"+t.model||c==="model:"+t.model+":"+t.id)&&(window.Asok&&window.Asok.refresh?window.Asok.refresh(s):typeof fire=="function"&&fire(s))}):t.op==="broadcast"&&document.dispatchEvent(new CustomEvent("asok:ws-broadcast",{detail:t}))},e.onclose=function(){a=!1,setTimeout(w,2e3)},e.onerror=function(){a=!1}}}function d(o,t){!e||e.readyState!==1||(t&&t.classList.add("asok-loading"),e.send(JSON.stringify(o)))}function m(o){o.__asokSubReady||(o.__asokSubReady=!0,d({op:"join_room",room:o.dataset.subscribe}))}function l(o,t){if(o.__asokIniting)return;o.__asokIniting=!0;const s=o.id.replace("asok-",""),c=o.dataset.asokComponent,r=o.dataset.asokState;if(!e||e.readyState!==1){window._asokPendingInits||(window._asokPendingInits=[]),window._asokPendingInits.push(o),delete o.__asokIniting;return}t||d({op:"join",cid:s,name:c,state:r}),["click","input","change","submit","keyup","keydown"].forEach(function(i){o.querySelectorAll("[ws-"+i+"]").forEach(function(n){const h=n.getAttribute("ws-"+i).split("."),b=h[0],f=h.slice(1),S=function(p){if(f.includes("prevent")&&p.preventDefault(),f.includes("stop")&&p.stopPropagation(),f.includes("enter")&&p.key!=="Enter")return;const A=n.value,g={op:"call",cid:s,method:b,val:A},y=f.find(function(_){return _.startsWith("debounce")});if(y){const _=parseInt(y.split("-")[1])||300;clearTimeout(u[n]),u[n]=setTimeout(function(){d(g,n)},_)}else d(g,n)};n["on"+i]=S})}),o.querySelectorAll("[ws-model]").forEach(function(i){const n=i.getAttribute("ws-model");i.oninput=function(){d({op:"sync",cid:s,prop:n,val:i.value},i)}}),o.__asokWsReady=!0,delete o.__asokIniting}window.Asok=window.Asok||{},window.Asok._wsInit=l,window.Asok._wsSub=m,document.addEventListener("asok:success",function(o){if(o.detail&&o.detail.target){const t=o.detail.target;t.dataset.asokComponent&&l(t),t.dataset.subscribe&&m(t),t.querySelectorAll("[data-asok-component]").forEach(l),t.querySelectorAll("[data-subscribe]").forEach(m)}}),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",w):w()})(); +window.asokWS=function(o){const f=location.protocol==="https:"?"wss:":"ws:";let r;if(location.hostname==="localhost"||location.hostname==="127.0.0.1"||location.hostname==="0.0.0.0"||location.hostname.startsWith("192.168.")){const d=window.ASOK_WS_PORT||8001;if(window.AsokSecurity&&window.AsokSecurity.isValidPort&&!window.AsokSecurity.isValidPort(d))throw console.error("[Asok Security] Invalid WebSocket port:",d),new Error("Invalid WebSocket port configuration");r=location.hostname+":"+d}else r=location.host+"/ws";return new WebSocket(f+"//"+r+o)},(function(){let o;const f={};let r=!1;function d(){if(!r){if(o){if(o.readyState===0)return;o.readyState===1&&o.close()}r=!0,o=window.asokWS("/asok/live"),o.onopen=function(){if(r=!1,window._asokPendingInits&&window._asokPendingInits.length){const e=window._asokPendingInits.slice();window._asokPendingInits=[],e.forEach(function(t){document.body.contains(t)&&(delete t.__asokIniting,delete t.__asokWsReady,window.Asok._wsInit(t))})}document.querySelectorAll("[data-asok-component]").forEach(window.Asok._wsInit),document.querySelectorAll("[data-subscribe]").forEach(window.Asok._wsSub)},o.onmessage=function(e){const t=window.AsokSecurity&&window.AsokSecurity.safeJsonParse?window.AsokSecurity.safeJsonParse(e.data):JSON.parse(e.data);if(!t){console.error("[Asok] Invalid WebSocket message");return}if(window.AsokSecurity&&window.AsokSecurity.validateWsMessage&&!window.AsokSecurity.validateWsMessage(t)){console.error("[Asok Security] Invalid message structure");return}if(t.op==="render"){const c=document.getElementById("asok-"+t.cid);if(c){if(t.registry){let n="";for(let u in t.registry)n+="window.__asok_registry["+JSON.stringify(u)+"] = ("+t.registry[u]+`); +`;const s=document.createElement("script"),a=window.Asok?.nonce||document.querySelector("script[nonce]")?.getAttribute("nonce")||"";a&&(s.nonce=a),s.textContent=n,document.head.appendChild(s),s.remove()}t.invalidate_cache&&window.__asokClearCache&&window.__asokClearCache();const l=window.AsokSecurity&&window.AsokSecurity.sanitizeHtml?window.AsokSecurity.sanitizeHtml(t.html):t.html,S=new DOMParser().parseFromString(l,"text/html").body.firstElementChild;c.replaceWith(S);const i=document.getElementById("asok-"+t.cid);if(i){const n=[];i.tagName==="SCRIPT"&&n.push(i),i.querySelectorAll("script").forEach(function(s){n.push(s)}),n.forEach(function(s){if(s.dataset.run||s.id==="asok-scoped-js")return;const a=document.createElement("script"),u=window.Asok?.nonce||document.querySelector("script[nonce]")?.getAttribute("nonce")||"";u&&(a.nonce=u),s.src&&(a.src=s.src),a.textContent=s.textContent,a.dataset.run="1",s.parentNode.replaceChild(a,s)}),window.AsokDirectives&&window.AsokDirectives.init&&window.AsokDirectives.init(i),k(i,!0),document.dispatchEvent(new CustomEvent("asok:ws-update",{detail:{cid:t.cid,name:t.name,state:t.state}}))}}}else t.op==="model_event"?document.querySelectorAll("[data-subscribe]").forEach(function(c){const l=c.dataset.subscribe;(l==="model:"+t.model||l==="model:"+t.model+":"+t.id)&&(window.Asok&&window.Asok.refresh?window.Asok.refresh(c):typeof fire=="function"&&fire(c))}):t.op==="broadcast"&&document.dispatchEvent(new CustomEvent("asok:ws-broadcast",{detail:t}))},o.onclose=function(){r=!1,setTimeout(d,2e3)},o.onerror=function(){r=!1}}}function w(e,t){!o||o.readyState!==1||(t&&t.classList.add("asok-loading"),o.send(JSON.stringify(e)))}function p(e){e.__asokSubReady||(e.__asokSubReady=!0,w({op:"join_room",room:e.dataset.subscribe}))}function k(e,t){if(e.__asokIniting)return;e.__asokIniting=!0;const c=e.id.replace("asok-",""),l=e.dataset.asokComponent,S=e.dataset.asokState;if(!o||o.readyState!==1){window._asokPendingInits||(window._asokPendingInits=[]),window._asokPendingInits.push(e),delete e.__asokIniting;return}t||w({op:"join",cid:c,name:l,state:S}),["click","input","change","submit","keyup","keydown"].forEach(function(i){e.querySelectorAll("[ws-"+i+"]").forEach(function(n){const a=n.getAttribute("ws-"+i).split("."),u=a[0],m=a.slice(1),_=function(y){if(m.includes("prevent")&&y.preventDefault(),m.includes("stop")&&y.stopPropagation(),m.includes("enter")&&y.key!=="Enter")return;const b=n.value,A={op:"call",cid:c,method:u,val:b},g=m.find(function(h){return h.startsWith("debounce")});if(g){const h=parseInt(g.split("-")[1])||300;clearTimeout(f[n]),f[n]=setTimeout(function(){w(A,n)},h)}else w(A,n)};n["on"+i]=_})}),e.querySelectorAll("[ws-model]").forEach(function(i){const n=i.getAttribute("ws-model");i.oninput=function(){w({op:"sync",cid:c,prop:n,val:i.value},i)}}),e.__asokWsReady=!0,delete e.__asokIniting}window.Asok=window.Asok||{},window.Asok._wsInit=k,window.Asok._wsSub=p,document.addEventListener("asok:success",function(e){if(e.detail&&e.detail.target){const t=e.detail.target;t.dataset.asokComponent&&k(t),t.dataset.subscribe&&p(t),t.querySelectorAll("[data-asok-component]").forEach(k),t.querySelectorAll("[data-subscribe]").forEach(p)}}),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",d):d()})(); diff --git a/asok/core/assets/asok_csp_error.js b/asok/core/assets/asok_csp_error.js deleted file mode 100644 index d4891d9..0000000 --- a/asok/core/assets/asok_csp_error.js +++ /dev/null @@ -1,5 +0,0 @@ -console.error( - "ASOK ERROR: Reactive directives detected but CSP unsafe-eval is disabled!\n" + - "Directives (asok-state, asok-text, asok-on:*) will NOT work.\n\n" + - "Fix: Add CSP_UNSAFE_EVAL=true to your .env file, then restart." -); diff --git a/asok/core/assets/asok_csp_error.min.js b/asok/core/assets/asok_csp_error.min.js deleted file mode 100644 index f2988ac..0000000 --- a/asok/core/assets/asok_csp_error.min.js +++ /dev/null @@ -1,4 +0,0 @@ -console.error(`ASOK ERROR: Reactive directives detected but CSP unsafe-eval is disabled! -Directives (asok-state, asok-text, asok-on:*) will NOT work. - -Fix: Add CSP_UNSAFE_EVAL=true to your .env file, then restart.`); diff --git a/asok/core/assets/asok_directives.js b/asok/core/assets/asok_directives.js index 762f9c2..6e71526 100644 --- a/asok/core/assets/asok_directives.js +++ b/asok/core/assets/asok_directives.js @@ -158,19 +158,25 @@ el.offsetHeight; // Force reflow requestAnimationFrame(() => { el.classList.add('is-entering'); + // SECURITY: Validate and cap duration to prevent timing attacks + const safeDur = window.AsokSecurity && window.AsokSecurity.safeDuration ? + window.AsokSecurity.safeDuration(activeDuration, 5000) : Math.min(activeDuration, 5000); setTimeout(() => { el.classList.remove(`asok-${baseName}-in`, 'is-entering'); - }, activeDuration); + }, safeDur); }); } else { el.classList.add(`asok-${baseName}-out`); el.offsetHeight; // Force reflow requestAnimationFrame(() => { el.classList.add('is-leaving'); + // SECURITY: Validate and cap duration to prevent timing attacks + const safeDur = window.AsokSecurity && window.AsokSecurity.safeDuration ? + window.AsokSecurity.safeDuration(activeDuration, 5000) : Math.min(activeDuration, 5000); setTimeout(() => { if (callback) callback(); el.classList.remove(`asok-${baseName}-out`, 'is-leaving'); - }, activeDuration); + }, safeDur); }); } } else { @@ -213,8 +219,14 @@ if (el.hasAttribute('asok-html-ref')) { const val = evaluateExpression(getAttr('asok-html-ref'), state, el); if (val !== undefined) { - // Strip script tags to avoid XSS execution - el.innerHTML = String(val).replace(/)<[^<]*)*<\/script>/gi, ''); + // SECURITY: Sanitize HTML to prevent XSS attacks + // Use AsokSecurity.sanitizeHtml if available, fallback to textContent + if (window.AsokSecurity && window.AsokSecurity.sanitizeHtml) { + el.innerHTML = window.AsokSecurity.sanitizeHtml(String(val)); + } else { + // Fallback: use textContent for safety if security utils not loaded + el.textContent = String(val); + } } } @@ -308,12 +320,32 @@ // asok-bind:name if (attr.name.startsWith('asok-bind-ref:')) { const attrName = attr.name.substring(14); + + // SECURITY: Validate attribute name to prevent event handler injection + if (window.AsokSecurity && !window.AsokSecurity.isSafeAttribute(attrName)) { + console.warn('[Asok] Blocked unsafe attribute binding:', attrName); + return; + } + const val = evaluateExpression(attr.value, state, el); - if (val !== undefined && val !== null && val !== false) { - el.setAttribute(attrName, String(val)); + const isTruthy = val !== undefined && val !== null && val !== false; + if (isTruthy) { + const strVal = String(val); + + // SECURITY: Validate URLs in href/src attributes + if ((attrName === 'href' || attrName === 'src') && + window.AsokSecurity && !window.AsokSecurity.isSafeUrl(strVal)) { + console.warn('[Asok] Blocked unsafe URL in attribute:', attrName); + return; + } + + el.setAttribute(attrName, strVal); } else { el.removeAttribute(attrName); } + if (attrName === 'checked' && (el.type === 'checkbox' || el.type === 'radio')) { + el.checked = !!isTruthy; + } } }); }; @@ -345,7 +377,7 @@ item._n = fragment.firstElementChild; item.parentNode.insertBefore(fragment, item.nextSibling); contexts.set(item._n, contexts.get(el) || { state: state, refs: {} }); - init(item._n); + if (window.Asok && window.Asok.init) window.Asok.init(item._n); else init(item._n); } conditionMet = 1; } else if (item._n) { @@ -361,7 +393,12 @@ const ref = el.getAttribute('asok-for-ref'); const varName = el.getAttribute('asok-for-var'); const items = evaluateExpression(ref, state, el) || []; - const itemsJSON = JSON.stringify(items); + let itemsJSON; + try { + itemsJSON = JSON.stringify(items); + } catch (e) { + itemsJSON = 'circular-' + Date.now(); + } if (el._lastItems === itemsJSON) return; el._lastItems = itemsJSON; @@ -390,7 +427,7 @@ contexts.set(child, { state: subState, refs: {}, cleanup: [] }); el.parentNode.insertBefore(fragment, el._marker); el._children.push(child); - init(child); + if (window.Asok && window.Asok.init) window.Asok.init(child); else init(child); }); }; @@ -413,6 +450,11 @@ scope.querySelectorAll('*').forEach(el => { if (el._updateValue) el._updateValue(); if (el.tagName === 'TEMPLATE') { + let parent = el.parentElement; + while (parent && parent !== scope) { + if (parent && parent.hasAttribute('asok-state-ref')) return; + parent = parent.parentElement; + } const owner = findStateOwner(el); const ownerState = owner ? contexts.get(owner).state : ctx.state; if (el.hasAttribute('asok-if-ref')) updateIfDirective(el, ownerState); @@ -508,9 +550,9 @@ const state = contexts.get(owner).state; el._modelInitialized = 1; - const getValue = (obj, path) => path.split('.').reduce((acc, k) => acc && acc[k], obj); + const getValue = (obj, path) => path.replace(/\[([^\]]+)\]/g, '.$1').split('.').reduce((acc, k) => acc && acc[k], obj); const setValue = (obj, path, val) => { - const keys = path.split('.'); + const keys = path.replace(/\[([^\]]+)\]/g, '.$1').split('.'); const lastKey = keys.pop(); const target = keys.reduce((acc, x) => acc[x] = acc[x] || {}, obj); target[lastKey] = val; @@ -519,10 +561,34 @@ el._updateValue = () => { const val = getValue(state, modelAttr); const displayVal = (val !== undefined && val !== null) ? val : ''; - if (el.value !== String(displayVal) && document.activeElement !== el) { - if (el.type === 'checkbox') el.checked = !!displayVal; - else if (el.type === 'radio') el.checked = el.value === displayVal; - else el.value = displayVal; + if (el.value !== String(displayVal)) { + if (el.type === 'checkbox') { + el.checked = !!displayVal; + } else if (el.type === 'radio') { + el.checked = el.value === displayVal; + } else { + const isFocused = document.activeElement === el; + if (isFocused && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) { + let hasSelection = false; + let selectionStart, selectionEnd; + try { + selectionStart = el.selectionStart; + selectionEnd = el.selectionEnd; + hasSelection = typeof selectionStart === 'number' && typeof selectionEnd === 'number'; + } catch (e) {} + + el.value = displayVal; + + if (hasSelection) { + try { + el.setSelectionRange(selectionStart, selectionEnd); + } catch (e) {} + } + try { el.focus(); } catch (e) {} + } else { + el.value = displayVal; + } + } } }; @@ -758,7 +824,7 @@ ownerCtx._teleportedScopes.push(child); target.appendChild(fragment); - init(child); + if (window.Asok && window.Asok.init) window.Asok.init(child); else init(child); el._teleportInitialized = 1; el.style.display = 'none'; } @@ -796,8 +862,14 @@ }); // Cloaking cleanup + const cleanRoot = root === document ? document : root; + if (cleanRoot.querySelectorAll) { + if (cleanRoot.hasAttribute && cleanRoot.hasAttribute('asok-cloak')) { + cleanRoot.removeAttribute('asok-cloak'); + } + cleanRoot.querySelectorAll('[asok-cloak]').forEach(e => e.removeAttribute('asok-cloak')); + } if (root === document) { - document.querySelectorAll('[asok-cloak]').forEach(e => e.removeAttribute('asok-cloak')); document.querySelectorAll('script').forEach(s => s.dataset.run = '1'); } }; @@ -925,9 +997,15 @@ window.Asok.updateWysiwyg = (event, state, inputEl) => { const html = event.target.innerHTML; - state.content = html; + + // SECURITY: Sanitize WYSIWYG content to prevent Stored XSS + // Note: Server-side validation is still required for defense-in-depth + const sanitized = window.AsokSecurity && window.AsokSecurity.sanitizeHtml ? + window.AsokSecurity.sanitizeHtml(html) : html; + + state.content = sanitized; if (inputEl) { - inputEl.value = html; + inputEl.value = sanitized; inputEl.dispatchEvent(new Event('change')); } }; @@ -984,13 +1062,18 @@ ctx.moveTo(event.clientX - rect.left, event.clientY - rect.top); ctx.lineWidth = 2; ctx.lineCap = 'round'; - ctx.strokeStyle = '#000'; + const isLight = document.body.classList.contains('light-mode'); + ctx.strokeStyle = isLight ? '#0f172a' : '#f8fafc'; }; window.Asok.drawSignature = (event, state, canvasEl) => { if (state.drawing) { const ctx = canvasEl.getContext('2d'); const rect = canvasEl.getBoundingClientRect(); + const isLight = document.body.classList.contains('light-mode'); + ctx.strokeStyle = isLight ? '#0f172a' : '#f8fafc'; + ctx.lineWidth = 2; + ctx.lineCap = 'round'; ctx.lineTo(event.clientX - rect.left, event.clientY - rect.top); ctx.stroke(); } diff --git a/asok/core/assets/asok_directives.min.js b/asok/core/assets/asok_directives.min.js index 671a3c2..617ecc2 100644 --- a/asok/core/assets/asok_directives.min.js +++ b/asok/core/assets/asok_directives.min.js @@ -1 +1 @@ -(function(){const d=new WeakMap,m=new Map;let p=null;const T=new Proxy({},{get(e,n){return p&&!n.startsWith("_")&&(m.has(n)||m.set(n,new Set),m.get(n).add(p)),e[n]},set(e,n,t){return e[n]===t||(e[n]=t,m.has(n)&&m.get(n).forEach(i=>{if(!document.body.contains(i)){m.get(n).delete(i);return}d.get(i)&&k(i)})),!0}}),v=e=>{for(;e&&e!==document.documentElement;){if(d.has(e))return e;e=e.parentElement}return null},x=(e,n,t)=>{const i=v(n),s=i?d.get(i):{refs:{}};return[s.state||e,window.Asok.store,n,t,s.refs||{},o=>Promise.resolve().then(o)]},w=(e,n,t)=>{const i=(window.__asok_registry||{})[e];if(i)try{return i(...x(n,t))}catch(s){console.error("Asok evaluation error:",s)}},_=(e,n,t,i)=>{const s=(window.__asok_registry||{})[e];if(s)try{return s(...x(n,i,t))}catch(r){console.error("Asok event execution error:",r)}},b=(e,n,t)=>{const i=e.getAttribute("asok-transition");if(i===null){t&&t();return}const s=i.trim().split(/\s+/);let r="fade",o=300,a="fade",c=300;if(s.length>0&&(r=s[0],a=s[0]),s.length>1){const u=parseInt(s[1]);if(isNaN(u)){if(a=s[1],s.length>2){const l=parseInt(s[2]);isNaN(l)||(o=l,c=l)}if(s.length>3){const l=parseInt(s[3]);isNaN(l)||(c=l)}}else if(o=u,c=u,s.length>2){const l=parseInt(s[2]);if(!isNaN(l))c=l;else if(a=s[2],s.length>3){const y=parseInt(s[3]);isNaN(y)||(c=y)}}}const f=n?r:a,h=n?o:c;if(["fade","slide","scale","fly","blur","bounce","page","slide-left","slide-right","slide-up","slide-down"].includes(f)||f.startsWith("asok-")){let u=f;f.startsWith("asok-")&&(u=f.replace("asok-","").replace("-in","").replace("-out","")),n?(e.classList.add(`asok-${u}-in`),t&&t(),e.offsetHeight,requestAnimationFrame(()=>{e.classList.add("is-entering"),setTimeout(()=>{e.classList.remove(`asok-${u}-in`,"is-entering")},h)})):(e.classList.add(`asok-${u}-out`),e.offsetHeight,requestAnimationFrame(()=>{e.classList.add("is-leaving"),setTimeout(()=>{t&&t(),e.classList.remove(`asok-${u}-out`,"is-leaving")},h)}))}else n?(t&&t(),s.length&&(e.classList.add(...s),e.addEventListener("transitionend",()=>e.classList.remove(...s),{once:!0}))):s.length?(e.classList.add(...s),e.addEventListener("transitionend",()=>{t&&t(),e.classList.remove(...s)},{once:!0})):t&&t()},E=(e,n)=>{if(!e||!n)return;const t=e.getAttribute.bind(e);if(e.hasAttribute("asok-text-ref")){const i=w(t("asok-text-ref"),n,e);i!==void 0&&(e.textContent=String(i))}if(e.hasAttribute("asok-html-ref")){const i=w(t("asok-html-ref"),n,e);i!==void 0&&(e.innerHTML=String(i).replace(/)<[^<]*)*<\/script>/gi,""))}if(e.hasAttribute("asok-show-ref")){const i=w(t("asok-show-ref"),n,e);if(!e._asokShowInitialized)e._asokShowInitialized=!0,e.style.display=i?"":"none";else{const s=e.style.display!=="none";i?(!s||e.hasAttribute("data-hide-active"))&&(e._showStartTime=Date.now(),e.removeAttribute("data-hide-active"),e.setAttribute("data-show-active",""),b(e,!0,()=>{e.style.display=""})):(s||e.hasAttribute("data-show-active"))&&(e.removeAttribute("data-show-active"),e.setAttribute("data-hide-active",""),b(e,!1,()=>{e.style.display="none",e.removeAttribute("data-hide-active")}))}}if(e.hasAttribute("asok-hide-ref")){const i=w(t("asok-hide-ref"),n,e);if(!e._asokHideInitialized)e._asokHideInitialized=!0,e.style.display=i?"none":"";else{const s=e.style.display==="none";i?(!s||e.hasAttribute("data-show-active"))&&(e.removeAttribute("data-show-active"),e.setAttribute("data-hide-active",""),b(e,!1,()=>{e.style.display="none",e.removeAttribute("data-hide-active")})):(s||e.hasAttribute("data-hide-active"))&&(e.removeAttribute("data-hide-active"),e.setAttribute("data-show-active",""),b(e,!0,()=>{e.style.display=""}))}}Array.from(e.attributes).forEach(i=>{if(i.name==="asok-class-ref"){const s=w(i.value,n,e);if(typeof s=="string"){const r=(e._asokPrevClasses||"").split(" ").filter(a=>a),o=s.split(" ").filter(a=>a);r.forEach(a=>{o.includes(a)||e.classList.remove(a)}),o.forEach(a=>e.classList.add(a)),e._asokPrevClasses=s}else typeof s=="object"&&s&&Object.keys(s).forEach(r=>{r.split(" ").filter(a=>a).forEach(a=>e.classList[s[r]?"add":"remove"](a))})}if(i.name.startsWith("asok-class-ref:")){const s=i.name.substring(15),r=w(i.value,n,e);e.classList[r?"add":"remove"](s)}if(i.name.startsWith("asok-bind-ref:")){const s=i.name.substring(14),r=w(i.value,n,e);r!=null&&r!==!1?e.setAttribute(s,String(r)):e.removeAttribute(s)}})},S=(e,n)=>{const t=[e];let i=e.nextElementSibling;for(;i;){if(i.tagName==="TEMPLATE"){if(i.hasAttribute("asok-if-ref"))break;(i.hasAttribute("asok-elif-ref")||i.hasAttribute("asok-else"))&&t.push(i)}i=i.nextElementSibling}let s=0;t.forEach(r=>{if(r._ai=1,(r.hasAttribute("asok-else")?!s:w(r.getAttribute(r.hasAttribute("asok-if-ref")?"asok-if-ref":"asok-elif-ref"),n,r))&&!s){if(!r._n){const a=r.content.cloneNode(!0);r._n=a.firstElementChild,r.parentNode.insertBefore(a,r.nextSibling),d.set(r._n,d.get(e)||{state:n,refs:{}}),A(r._n)}s=1}else r._n&&(r._n.remove(),r._n=null)})},L=(e,n)=>{e._ai=1;const t=e.getAttribute("asok-for-ref"),i=e.getAttribute("asok-for-var"),s=w(t,n,e)||[],r=JSON.stringify(s);if(e._lastItems===r)return;e._lastItems=r;let o=i,a="index";if(o.startsWith("(")&&o.endsWith(")")){const c=o.slice(1,-1).split(",").map(f=>f.trim());o=c[0],c.length>1&&(a=c[1])}e._marker||(e._marker=document.createComment("for"),e.parentNode.insertBefore(e._marker,e.nextSibling)),(e._children||[]).forEach(c=>c.remove()),e._children=[],s.forEach((c,f)=>{const h=e.content.cloneNode(!0),g=h.firstElementChild,u=I({[o]:c,[a]:f},()=>k(v(e)),n);d.set(g,{state:u,refs:{},cleanup:[]}),e.parentNode.insertBefore(h,e._marker),e._children.push(g),A(g)})},k=(e,n=1)=>{const t=d.get(e);if(t){if(p=e,e.tagName==="TEMPLATE"){e.hasAttribute("asok-if-ref")&&S(e,t.state),e.hasAttribute("asok-for-ref")&&L(e,t.state),e._n&&k(e._n,0),e._children&&e._children.forEach(i=>k(i,0)),p=null;return}E(e,t.state),e.querySelectorAll("*").forEach(i=>{if(i._updateValue&&i._updateValue(),i.tagName==="TEMPLATE"){const o=v(i),a=o?d.get(o).state:t.state;i.hasAttribute("asok-if-ref")&&S(i,a),i.hasAttribute("asok-for-ref")&&L(i,a);return}let s=i.parentElement;for(;s&&s!==e;){if(s&&s.hasAttribute("asok-state-ref"))return;s=s.parentElement}const r=v(i);r&&E(i,d.get(r).state)}),p=null,n&&t._teleportedScopes&&t._teleportedScopes.forEach(i=>k(i,0))}},I=(e,n,t)=>!e||typeof e!="object"||e._isProxy?e:new Proxy(e,{get(i,s){if(s==="_isProxy")return!0;const r=s in i?i[s]:t?t[s]:void 0;return typeof r=="function"?["push","pop","splice","shift","unshift","reverse","sort"].includes(s)?(...o)=>{const a=r.apply(i,o);return n(),a}:r.bind(i):I(r,n,t)},has(i,s){return s in i||t&&s in t},set(i,s,r){return s in i?(i[s]===r||(i[s]=r,n()),!0):t&&s in t?(t[s]=r,!0):(i[s]=r,n(),!0)}}),C=e=>{if(e._stateInitialized)return;const n=e.getAttribute("asok-state-ref");try{const t=w(n,{},e)||{},i=I(t,()=>k(e));d.set(e,{state:i,cleanup:[],refs:{},_teleportedScopes:[]}),e._stateInitialized=1,e.hasAttribute("asok-init-ref")&&_(e.getAttribute("asok-init-ref"),i,null,e),k(e)}catch(t){console.error("Asok state initialization error:",t)}},D=e=>{if(e._modelInitialized)return;const n=e.getAttribute("asok-model"),t=v(e);if(!n||!t)return;const i=d.get(t).state;e._modelInitialized=1;const s=(a,c)=>c.split(".").reduce((f,h)=>f&&f[h],a),r=(a,c,f)=>{const h=c.split("."),g=h.pop(),u=h.reduce((l,y)=>l[y]=l[y]||{},a);u[g]=f};e._updateValue=()=>{const a=s(i,n),c=a??"";e.value!==String(c)&&document.activeElement!==e&&(e.type==="checkbox"?e.checked=!!c:e.type==="radio"?e.checked=e.value===c:e.value=c)},e._updateValue();const o=()=>{e.type==="checkbox"?r(i,n,e.checked):e.type==="radio"?e.checked&&r(i,n,e.value):r(i,n,e.value)};e.addEventListener("input",o),e.addEventListener("change",o),d.get(t).cleanup.push(()=>{e.removeEventListener("input",o),e.removeEventListener("change",o)})},M=e=>{if(e._eventsInitialized)return;const n=v(e);if(!n)return;const t=d.get(n).state;e._eventsInitialized=1,Array.from(e.attributes).forEach(i=>{if(!i.name.startsWith("asok-on-ref:"))return;const s=i.name.substring(12),r=i.value,[o,...a]=s.split("."),c=f=>{a.includes("prevent")&&f.preventDefault(),a.includes("stop")&&f.stopPropagation(),!(a.some(g=>["enter","escape","space","tab"].includes(g))&&!a.some(u=>{const l=f.key.toLowerCase();return u==="space"?l===" "||l==="spacebar":l===u}))&&_(r,t,f,e)};if(a.includes("outside")){const f=h=>{e.offsetWidth>0&&!e.contains(h.target)&&(!e._showStartTime||Date.now()-e._showStartTime>50)&&c(h)};document.addEventListener("click",f),d.get(n).cleanup.push(()=>document.removeEventListener("click",f))}else{const f=a.find(g=>g.startsWith("debounce")),h=f?parseInt(f.split("-")[1])||300:0;if(h){let g;const u=l=>{clearTimeout(g),g=setTimeout(()=>c(l),h)};e.addEventListener(o,u),d.get(n).cleanup.push(()=>e.removeEventListener(o,u))}else e.addEventListener(o,c),d.get(n).cleanup.push(()=>e.removeEventListener(o,c))}})},P=e=>{if(e._fetchInitialized)return;const n=e.getAttribute("asok-fetch"),t=e.getAttribute("asok-fetch-as")||"data",i=e.getAttribute("asok-fetch-on")||"load",s=v(e);if(!n||!s)return;const r=d.get(s).state;e._fetchInitialized=1;const o=async()=>{try{r.loading=!0,r.error=null;const a=await fetch(n);if(!a.ok)throw new Error(a.statusText);const c=await a.json();r[t]=c,r.loading=!1}catch(a){r.error=a.message,r.loading=!1}};if(i==="load")o();else{const a=()=>o();e.addEventListener(i,a),d.get(s).cleanup.push(()=>e.removeEventListener(i,a))}},q=e=>{if(e._fetchAsyncInitialized)return;const n=e.getAttribute("asok-fetch-async-ref"),t=e.getAttribute("asok-fetch-on")||"click",i=v(e);if(!n||!i)return;const s=d.get(i).state;e._fetchAsyncInitialized=1;const r=async()=>{try{s.loading=!0,s.error=null,await _(n,s,null,e),s.loading=!1}catch(a){s.error=a.message,s.loading=!1}},o=()=>r();e.addEventListener(t,o),d.get(i).cleanup.push(()=>e.removeEventListener(t,o))},z=e=>{if(!e)return;[e,...e.querySelectorAll("*")].forEach(t=>{m.forEach((s,r)=>{s.delete(t),s.size===0&&m.delete(r)});const i=d.get(t);i&&i.cleanup&&(i.cleanup.forEach(s=>{try{s()}catch{}}),i.cleanup=[])})},N=e=>{if(!e)return;[e,...e.querySelectorAll("*")].forEach(t=>{delete t._ai,delete t._stateInitialized,delete t._modelInitialized,delete t._eventsInitialized,delete t._refInitialized,delete t._teleportInitialized,delete t._fetchInitialized,delete t._fetchAsyncInitialized,delete t._updateValue,delete t._asokPrevClasses,delete t._asokShowInitialized,delete t._asokHideInitialized})},W=e=>{e&&(z(e),N(e),A(e))},A=(e=document)=>{const n=e===document?document.querySelectorAll("*"):[e,...e.querySelectorAll("*")];n.forEach(t=>{if(t.hasAttribute("asok-state-ref")&&C(t),t.hasAttribute("asok-ref")&&!t._refInitialized){const i=v(t);i&&(d.get(i).refs[t.getAttribute("asok-ref")]=t,t._refInitialized=1)}if(t.hasAttribute("asok-teleport")&&!t._teleportInitialized){const i=t.getAttribute("asok-teleport"),s=document.querySelector(i),r=v(t);if(s&&r){const o=d.get(r),a=t.content.cloneNode(!0),c=a.firstElementChild;d.set(c,{state:o.state,refs:o.refs,cleanup:[],_teleportedScopes:[]}),o._teleportedScopes.push(c),s.appendChild(a),A(c),t._teleportInitialized=1,t.style.display="none"}}if(t.tagName==="TEMPLATE"&&!t._ai){const i=v(t);if(i){const s=d.get(i).state;t.hasAttribute("asok-if-ref")&&S(t,s),t.hasAttribute("asok-for-ref")&&L(t,s)}}}),n.forEach(t=>{const i=v(t);i&&E(t,d.get(i).state),t.hasAttribute("asok-model")&&D(t),t.hasAttribute("asok-fetch")&&P(t),t.hasAttribute("asok-fetch-async-ref")&&q(t),Array.from(t.attributes).some(s=>s.name.startsWith("asok-on-ref:"))&&M(t)}),e===document&&(document.querySelectorAll("[asok-cloak]").forEach(t=>t.removeAttribute("asok-cloak")),document.querySelectorAll("script").forEach(t=>t.dataset.run="1"))};if(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>A()):A(),window.Asok){const e=window.Asok.init;window.Asok.init=n=>{e&&e(n),A(n)}}window.Asok=window.Asok||{},window.Asok.previewImage=(e,n)=>{const t=e.target.files[0];if(t){const i=new FileReader;i.onload=s=>{n.preview=s.target.result},i.readAsDataURL(t)}},window.Asok.selectDropdown=(e,n,t,i)=>{e.label=t,e.open=!1,i&&(i.value=n,i.dispatchEvent(new Event("change")))},window.Asok.removeTag=(e,n,t)=>{e.selected=e.selected.filter(i=>i.value!==n.value),t&&(t.value=JSON.stringify(e.selected.map(i=>i.value)),t.dispatchEvent(new Event("change")))},window.Asok.addTag=(e,n,t)=>{e.selected.some(i=>i.value===n.value)||(e.selected.push({value:n.value,label:n.label}),t&&(t.value=JSON.stringify(e.selected.map(i=>i.value)),t.dispatchEvent(new Event("change"))))},window.Asok.updateHiddenJson=(e,n)=>{e&&(e.value=JSON.stringify(n),e.dispatchEvent(new Event("change")))},window.Asok.updateHiddenValue=(e,n)=>{e&&(e.value=n,e.dispatchEvent(new Event("change")))},window.Asok.handleOtpKeyup=e=>{if(e.target.value&&e.key!=="Backspace"){const n=e.target.nextElementSibling;n&&n.tagName==="INPUT"&&n.focus()}},window.Asok.setRating=(e,n,t)=>{e.rating=n,t&&(t.value=n,t.dispatchEvent(new Event("change")))},window.Asok.handleFilesChange=(e,n,t)=>{const i=Array.from(e.target.files);if(i.length>t){alert("Maximum "+t+" files"),e.target.value="";return}n.files=i.map(s=>({name:s.name,size:s.size,url:URL.createObjectURL(s)}))},window.Asok.filterAutocomplete=(e,n)=>{e.query.length>=n?(e.filtered=e.all.filter(t=>String(t).toLowerCase().includes(e.query.toLowerCase())),e.show=!0):e.show=!1},window.Asok.selectAutocomplete=(e,n,t)=>{e.query=String(n),e.show=!1,t&&(t.value=e.query,t.dispatchEvent(new Event("change")))},window.Asok.updateWysiwyg=(e,n,t)=>{const i=e.target.innerHTML;n.content=i,t&&(t.value=i,t.dispatchEvent(new Event("change")))},window.Asok.handleDropzoneDrop=(e,n,t,i)=>{n.dragging=!1;const s=Array.from(e.dataTransfer.files);if(s.length>t){alert("Max "+t+" files");return}const r=new DataTransfer;for(let o=0;o({name:o.name,size:o.size,_file:o}))},window.Asok.handleDropzoneChange=(e,n,t)=>{const i=Array.from(e.target.files);if(i.length>t){alert("Maximum "+t+" files");return}n.files=i.map(s=>({name:s.name,size:s.size,_file:s}))},window.Asok.removeDropzoneFile=(e,n,t)=>{e.files=e.files.filter((s,r)=>r!==n);const i=new DataTransfer;e.files.forEach(s=>i.items.add(s._file)),t&&(t.files=i.files)},window.Asok.startSignatureDrawing=(e,n,t)=>{n.drawing=!0;const i=t.getContext("2d"),s=t.getBoundingClientRect();i.beginPath(),i.moveTo(e.clientX-s.left,e.clientY-s.top),i.lineWidth=2,i.lineCap="round",i.strokeStyle="#000"},window.Asok.drawSignature=(e,n,t)=>{if(n.drawing){const i=t.getContext("2d"),s=t.getBoundingClientRect();i.lineTo(e.clientX-s.left,e.clientY-s.top),i.stroke()}},window.Asok.stopSignatureDrawing=(e,n,t)=>{e.drawing=!1,t&&(t.value=n.toDataURL(),t.dispatchEvent(new Event("change")))},window.Asok.clearSignature=(e,n)=>{e.getContext("2d").clearRect(0,0,e.width,e.height),n&&(n.value="",n.dispatchEvent(new Event("change")))},window.Asok.updateTransferSelection=(e,n,t)=>{e[n]=Array.from(t.target.selectedOptions).map(i=>i.value)},window.Asok.moveTransferRight=e=>{const n=e.available.filter(t=>e.h_avail.includes(String(t.id!==void 0?t.id:t)));e.selected=[...e.selected,...n],e.available=e.available.filter(t=>!n.includes(t)),e.h_avail=[]},window.Asok.moveTransferLeft=e=>{const n=e.selected.filter(t=>e.h_sel.includes(String(t.id!==void 0?t.id:t)));e.available=[...e.available,...n],e.selected=e.selected.filter(t=>!n.includes(t)),e.h_sel=[]},window.Asok.moveTransferItemRight=(e,n)=>{e.selected.push(n),e.available=e.available.filter(t=>t!==n)},window.Asok.moveTransferItemLeft=(e,n)=>{e.available.push(n),e.selected=e.selected.filter(t=>t!==n)},window.Asok.selectTreeItem=(e,n,t)=>{e.selected=n,t&&(t.value=n,t.dispatchEvent(new Event("change")))},window.Asok.toggleTreeExpansion=(e,n)=>{e.expanded.includes(n)?e.expanded=e.expanded.filter(t=>t!==n):e.expanded.push(n)},window.AsokDirectives={init:A,forceInit:W,cleanupOld:z,resetFlags:N,version:"1.0.0",w:d},window.Asok.store=T})(); +(function(){const f=new WeakMap,k=new Map;let y=null;const T=new Proxy({},{get(e,s){return y&&!s.startsWith("_")&&(k.has(s)||k.set(s,new Set),k.get(s).add(y)),e[s]},set(e,s,i){return e[s]===i||(e[s]=i,k.has(s)&&k.get(s).forEach(t=>{if(!document.body.contains(t)){k.get(s).delete(t);return}f.get(t)&&v(t)})),!0}}),g=e=>{for(;e&&e!==document.documentElement;){if(f.has(e))return e;e=e.parentElement}return null},I=(e,s,i)=>{const t=g(s),n=t?f.get(t):{refs:{}};return[n.state||e,window.Asok.store,s,i,n.refs||{},o=>Promise.resolve().then(o)]},A=(e,s,i)=>{const t=(window.__asok_registry||{})[e];if(t)try{return t(...I(s,i))}catch(n){console.error("Asok evaluation error:",n)}},_=(e,s,i,t)=>{const n=(window.__asok_registry||{})[e];if(n)try{return n(...I(s,t,i))}catch(r){console.error("Asok event execution error:",r)}},b=(e,s,i)=>{const t=e.getAttribute("asok-transition");if(t===null){i&&i();return}const n=t.trim().split(/\s+/);let r="fade",o=300,a="fade",c=300;if(n.length>0&&(r=n[0],a=n[0]),n.length>1){const l=parseInt(n[1]);if(isNaN(l)){if(a=n[1],n.length>2){const u=parseInt(n[2]);isNaN(u)||(o=u,c=u)}if(n.length>3){const u=parseInt(n[3]);isNaN(u)||(c=u)}}else if(o=l,c=l,n.length>2){const u=parseInt(n[2]);if(!isNaN(u))c=u;else if(a=n[2],n.length>3){const p=parseInt(n[3]);isNaN(p)||(c=p)}}}const d=s?r:a,h=s?o:c;if(["fade","slide","scale","fly","blur","bounce","page","slide-left","slide-right","slide-up","slide-down"].includes(d)||d.startsWith("asok-")){let l=d;d.startsWith("asok-")&&(l=d.replace("asok-","").replace("-in","").replace("-out","")),s?(e.classList.add(`asok-${l}-in`),i&&i(),e.offsetHeight,requestAnimationFrame(()=>{e.classList.add("is-entering");const u=window.AsokSecurity&&window.AsokSecurity.safeDuration?window.AsokSecurity.safeDuration(h,5e3):Math.min(h,5e3);setTimeout(()=>{e.classList.remove(`asok-${l}-in`,"is-entering")},u)})):(e.classList.add(`asok-${l}-out`),e.offsetHeight,requestAnimationFrame(()=>{e.classList.add("is-leaving");const u=window.AsokSecurity&&window.AsokSecurity.safeDuration?window.AsokSecurity.safeDuration(h,5e3):Math.min(h,5e3);setTimeout(()=>{i&&i(),e.classList.remove(`asok-${l}-out`,"is-leaving")},u)}))}else s?(i&&i(),n.length&&(e.classList.add(...n),e.addEventListener("transitionend",()=>e.classList.remove(...n),{once:!0}))):n.length?(e.classList.add(...n),e.addEventListener("transitionend",()=>{i&&i(),e.classList.remove(...n)},{once:!0})):i&&i()},S=(e,s)=>{if(!e||!s)return;const i=e.getAttribute.bind(e);if(e.hasAttribute("asok-text-ref")){const t=A(i("asok-text-ref"),s,e);t!==void 0&&(e.textContent=String(t))}if(e.hasAttribute("asok-html-ref")){const t=A(i("asok-html-ref"),s,e);t!==void 0&&(window.AsokSecurity&&window.AsokSecurity.sanitizeHtml?e.innerHTML=window.AsokSecurity.sanitizeHtml(String(t)):e.textContent=String(t))}if(e.hasAttribute("asok-show-ref")){const t=A(i("asok-show-ref"),s,e);if(!e._asokShowInitialized)e._asokShowInitialized=!0,e.style.display=t?"":"none";else{const n=e.style.display!=="none";t?(!n||e.hasAttribute("data-hide-active"))&&(e._showStartTime=Date.now(),e.removeAttribute("data-hide-active"),e.setAttribute("data-show-active",""),b(e,!0,()=>{e.style.display=""})):(n||e.hasAttribute("data-show-active"))&&(e.removeAttribute("data-show-active"),e.setAttribute("data-hide-active",""),b(e,!1,()=>{e.style.display="none",e.removeAttribute("data-hide-active")}))}}if(e.hasAttribute("asok-hide-ref")){const t=A(i("asok-hide-ref"),s,e);if(!e._asokHideInitialized)e._asokHideInitialized=!0,e.style.display=t?"none":"";else{const n=e.style.display==="none";t?(!n||e.hasAttribute("data-show-active"))&&(e.removeAttribute("data-show-active"),e.setAttribute("data-hide-active",""),b(e,!1,()=>{e.style.display="none",e.removeAttribute("data-hide-active")})):(n||e.hasAttribute("data-hide-active"))&&(e.removeAttribute("data-hide-active"),e.setAttribute("data-show-active",""),b(e,!0,()=>{e.style.display=""}))}}Array.from(e.attributes).forEach(t=>{if(t.name==="asok-class-ref"){const n=A(t.value,s,e);if(typeof n=="string"){const r=(e._asokPrevClasses||"").split(" ").filter(a=>a),o=n.split(" ").filter(a=>a);r.forEach(a=>{o.includes(a)||e.classList.remove(a)}),o.forEach(a=>e.classList.add(a)),e._asokPrevClasses=n}else typeof n=="object"&&n&&Object.keys(n).forEach(r=>{r.split(" ").filter(a=>a).forEach(a=>e.classList[n[r]?"add":"remove"](a))})}if(t.name.startsWith("asok-class-ref:")){const n=t.name.substring(15),r=A(t.value,s,e);e.classList[r?"add":"remove"](n)}if(t.name.startsWith("asok-bind-ref:")){const n=t.name.substring(14);if(window.AsokSecurity&&!window.AsokSecurity.isSafeAttribute(n)){console.warn("[Asok] Blocked unsafe attribute binding:",n);return}const r=A(t.value,s,e),o=r!=null&&r!==!1;if(o){const a=String(r);if((n==="href"||n==="src")&&window.AsokSecurity&&!window.AsokSecurity.isSafeUrl(a)){console.warn("[Asok] Blocked unsafe URL in attribute:",n);return}e.setAttribute(n,a)}else e.removeAttribute(n);n==="checked"&&(e.type==="checkbox"||e.type==="radio")&&(e.checked=!!o)}})},E=(e,s)=>{const i=[e];let t=e.nextElementSibling;for(;t;){if(t.tagName==="TEMPLATE"){if(t.hasAttribute("asok-if-ref"))break;(t.hasAttribute("asok-elif-ref")||t.hasAttribute("asok-else"))&&i.push(t)}t=t.nextElementSibling}let n=0;i.forEach(r=>{if(r._ai=1,(r.hasAttribute("asok-else")?!n:A(r.getAttribute(r.hasAttribute("asok-if-ref")?"asok-if-ref":"asok-elif-ref"),s,r))&&!n){if(!r._n){const a=r.content.cloneNode(!0);r._n=a.firstElementChild,r.parentNode.insertBefore(a,r.nextSibling),f.set(r._n,f.get(e)||{state:s,refs:{}}),window.Asok&&window.Asok.init?window.Asok.init(r._n):m(r._n)}n=1}else r._n&&(r._n.remove(),r._n=null)})},L=(e,s)=>{e._ai=1;const i=e.getAttribute("asok-for-ref"),t=e.getAttribute("asok-for-var"),n=A(i,s,e)||[];let r;try{r=JSON.stringify(n)}catch{r="circular-"+Date.now()}if(e._lastItems===r)return;e._lastItems=r;let o=t,a="index";if(o.startsWith("(")&&o.endsWith(")")){const c=o.slice(1,-1).split(",").map(d=>d.trim());o=c[0],c.length>1&&(a=c[1])}e._marker||(e._marker=document.createComment("for"),e.parentNode.insertBefore(e._marker,e.nextSibling)),(e._children||[]).forEach(c=>c.remove()),e._children=[],n.forEach((c,d)=>{const h=e.content.cloneNode(!0),w=h.firstElementChild,l=x({[o]:c,[a]:d},()=>v(g(e)),s);f.set(w,{state:l,refs:{},cleanup:[]}),e.parentNode.insertBefore(h,e._marker),e._children.push(w),window.Asok&&window.Asok.init?window.Asok.init(w):m(w)})},v=(e,s=1)=>{const i=f.get(e);if(i){if(y=e,e.tagName==="TEMPLATE"){e.hasAttribute("asok-if-ref")&&E(e,i.state),e.hasAttribute("asok-for-ref")&&L(e,i.state),e._n&&v(e._n,0),e._children&&e._children.forEach(t=>v(t,0)),y=null;return}S(e,i.state),e.querySelectorAll("*").forEach(t=>{if(t._updateValue&&t._updateValue(),t.tagName==="TEMPLATE"){let o=t.parentElement;for(;o&&o!==e;){if(o&&o.hasAttribute("asok-state-ref"))return;o=o.parentElement}const a=g(t),c=a?f.get(a).state:i.state;t.hasAttribute("asok-if-ref")&&E(t,c),t.hasAttribute("asok-for-ref")&&L(t,c);return}let n=t.parentElement;for(;n&&n!==e;){if(n&&n.hasAttribute("asok-state-ref"))return;n=n.parentElement}const r=g(t);r&&S(t,f.get(r).state)}),y=null,s&&i._teleportedScopes&&i._teleportedScopes.forEach(t=>v(t,0))}},x=(e,s,i)=>!e||typeof e!="object"||e._isProxy?e:new Proxy(e,{get(t,n){if(n==="_isProxy")return!0;const r=n in t?t[n]:i?i[n]:void 0;return typeof r=="function"?["push","pop","splice","shift","unshift","reverse","sort"].includes(n)?(...o)=>{const a=r.apply(t,o);return s(),a}:r.bind(t):x(r,s,i)},has(t,n){return n in t||i&&n in i},set(t,n,r){return n in t?(t[n]===r||(t[n]=r,s()),!0):i&&n in i?(i[n]=r,!0):(t[n]=r,s(),!0)}}),D=e=>{if(e._stateInitialized)return;const s=e.getAttribute("asok-state-ref");try{const i=A(s,{},e)||{},t=x(i,()=>v(e));f.set(e,{state:t,cleanup:[],refs:{},_teleportedScopes:[]}),e._stateInitialized=1,e.hasAttribute("asok-init-ref")&&_(e.getAttribute("asok-init-ref"),t,null,e),v(e)}catch(i){console.error("Asok state initialization error:",i)}},C=e=>{if(e._modelInitialized)return;const s=e.getAttribute("asok-model"),i=g(e);if(!s||!i)return;const t=f.get(i).state;e._modelInitialized=1;const n=(a,c)=>c.replace(/\[([^\]]+)\]/g,".$1").split(".").reduce((d,h)=>d&&d[h],a),r=(a,c,d)=>{const h=c.replace(/\[([^\]]+)\]/g,".$1").split("."),w=h.pop(),l=h.reduce((u,p)=>u[p]=u[p]||{},a);l[w]=d};e._updateValue=()=>{const a=n(t,s),c=a??"";if(e.value!==String(c))if(e.type==="checkbox")e.checked=!!c;else if(e.type==="radio")e.checked=e.value===c;else if(document.activeElement===e&&(e.tagName==="INPUT"||e.tagName==="TEXTAREA")){let h=!1,w,l;try{w=e.selectionStart,l=e.selectionEnd,h=typeof w=="number"&&typeof l=="number"}catch{}if(e.value=c,h)try{e.setSelectionRange(w,l)}catch{}try{e.focus()}catch{}}else e.value=c},e._updateValue();const o=()=>{e.type==="checkbox"?r(t,s,e.checked):e.type==="radio"?e.checked&&r(t,s,e.value):r(t,s,e.value)};e.addEventListener("input",o),e.addEventListener("change",o),f.get(i).cleanup.push(()=>{e.removeEventListener("input",o),e.removeEventListener("change",o)})},M=e=>{if(e._eventsInitialized)return;const s=g(e);if(!s)return;const i=f.get(s).state;e._eventsInitialized=1,Array.from(e.attributes).forEach(t=>{if(!t.name.startsWith("asok-on-ref:"))return;const n=t.name.substring(12),r=t.value,[o,...a]=n.split("."),c=d=>{a.includes("prevent")&&d.preventDefault(),a.includes("stop")&&d.stopPropagation(),!(a.some(w=>["enter","escape","space","tab"].includes(w))&&!a.some(l=>{const u=d.key.toLowerCase();return l==="space"?u===" "||u==="spacebar":u===l}))&&_(r,i,d,e)};if(a.includes("outside")){const d=h=>{e.offsetWidth>0&&!e.contains(h.target)&&(!e._showStartTime||Date.now()-e._showStartTime>50)&&c(h)};document.addEventListener("click",d),f.get(s).cleanup.push(()=>document.removeEventListener("click",d))}else{const d=a.find(w=>w.startsWith("debounce")),h=d?parseInt(d.split("-")[1])||300:0;if(h){let w;const l=u=>{clearTimeout(w),w=setTimeout(()=>c(u),h)};e.addEventListener(o,l),f.get(s).cleanup.push(()=>e.removeEventListener(o,l))}else e.addEventListener(o,c),f.get(s).cleanup.push(()=>e.removeEventListener(o,c))}})},P=e=>{if(e._fetchInitialized)return;const s=e.getAttribute("asok-fetch"),i=e.getAttribute("asok-fetch-as")||"data",t=e.getAttribute("asok-fetch-on")||"load",n=g(e);if(!s||!n)return;const r=f.get(n).state;e._fetchInitialized=1;const o=async()=>{try{r.loading=!0,r.error=null;const a=await fetch(s);if(!a.ok)throw new Error(a.statusText);const c=await a.json();r[i]=c,r.loading=!1}catch(a){r.error=a.message,r.loading=!1}};if(t==="load")o();else{const a=()=>o();e.addEventListener(t,a),f.get(n).cleanup.push(()=>e.removeEventListener(t,a))}},H=e=>{if(e._fetchAsyncInitialized)return;const s=e.getAttribute("asok-fetch-async-ref"),i=e.getAttribute("asok-fetch-on")||"click",t=g(e);if(!s||!t)return;const n=f.get(t).state;e._fetchAsyncInitialized=1;const r=async()=>{try{n.loading=!0,n.error=null,await _(s,n,null,e),n.loading=!1}catch(a){n.error=a.message,n.loading=!1}},o=()=>r();e.addEventListener(i,o),f.get(t).cleanup.push(()=>e.removeEventListener(i,o))},z=e=>{if(!e)return;[e,...e.querySelectorAll("*")].forEach(i=>{k.forEach((n,r)=>{n.delete(i),n.size===0&&k.delete(r)});const t=f.get(i);t&&t.cleanup&&(t.cleanup.forEach(n=>{try{n()}catch{}}),t.cleanup=[])})},N=e=>{if(!e)return;[e,...e.querySelectorAll("*")].forEach(i=>{delete i._ai,delete i._stateInitialized,delete i._modelInitialized,delete i._eventsInitialized,delete i._refInitialized,delete i._teleportInitialized,delete i._fetchInitialized,delete i._fetchAsyncInitialized,delete i._updateValue,delete i._asokPrevClasses,delete i._asokShowInitialized,delete i._asokHideInitialized})},R=e=>{e&&(z(e),N(e),m(e))},m=(e=document)=>{const s=e===document?document.querySelectorAll("*"):[e,...e.querySelectorAll("*")];s.forEach(t=>{if(t.hasAttribute("asok-state-ref")&&D(t),t.hasAttribute("asok-ref")&&!t._refInitialized){const n=g(t);n&&(f.get(n).refs[t.getAttribute("asok-ref")]=t,t._refInitialized=1)}if(t.hasAttribute("asok-teleport")&&!t._teleportInitialized){const n=t.getAttribute("asok-teleport"),r=document.querySelector(n),o=g(t);if(r&&o){const a=f.get(o),c=t.content.cloneNode(!0),d=c.firstElementChild;f.set(d,{state:a.state,refs:a.refs,cleanup:[],_teleportedScopes:[]}),a._teleportedScopes.push(d),r.appendChild(c),window.Asok&&window.Asok.init?window.Asok.init(d):m(d),t._teleportInitialized=1,t.style.display="none"}}if(t.tagName==="TEMPLATE"&&!t._ai){const n=g(t);if(n){const r=f.get(n).state;t.hasAttribute("asok-if-ref")&&E(t,r),t.hasAttribute("asok-for-ref")&&L(t,r)}}}),s.forEach(t=>{const n=g(t);n&&S(t,f.get(n).state),t.hasAttribute("asok-model")&&C(t),t.hasAttribute("asok-fetch")&&P(t),t.hasAttribute("asok-fetch-async-ref")&&H(t),Array.from(t.attributes).some(r=>r.name.startsWith("asok-on-ref:"))&&M(t)});const i=e===document?document:e;i.querySelectorAll&&(i.hasAttribute&&i.hasAttribute("asok-cloak")&&i.removeAttribute("asok-cloak"),i.querySelectorAll("[asok-cloak]").forEach(t=>t.removeAttribute("asok-cloak"))),e===document&&document.querySelectorAll("script").forEach(t=>t.dataset.run="1")};if(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>m()):m(),window.Asok){const e=window.Asok.init;window.Asok.init=s=>{e&&e(s),m(s)}}window.Asok=window.Asok||{},window.Asok.previewImage=(e,s)=>{const i=e.target.files[0];if(i){const t=new FileReader;t.onload=n=>{s.preview=n.target.result},t.readAsDataURL(i)}},window.Asok.selectDropdown=(e,s,i,t)=>{e.label=i,e.open=!1,t&&(t.value=s,t.dispatchEvent(new Event("change")))},window.Asok.removeTag=(e,s,i)=>{e.selected=e.selected.filter(t=>t.value!==s.value),i&&(i.value=JSON.stringify(e.selected.map(t=>t.value)),i.dispatchEvent(new Event("change")))},window.Asok.addTag=(e,s,i)=>{e.selected.some(t=>t.value===s.value)||(e.selected.push({value:s.value,label:s.label}),i&&(i.value=JSON.stringify(e.selected.map(t=>t.value)),i.dispatchEvent(new Event("change"))))},window.Asok.updateHiddenJson=(e,s)=>{e&&(e.value=JSON.stringify(s),e.dispatchEvent(new Event("change")))},window.Asok.updateHiddenValue=(e,s)=>{e&&(e.value=s,e.dispatchEvent(new Event("change")))},window.Asok.handleOtpKeyup=e=>{if(e.target.value&&e.key!=="Backspace"){const s=e.target.nextElementSibling;s&&s.tagName==="INPUT"&&s.focus()}},window.Asok.setRating=(e,s,i)=>{e.rating=s,i&&(i.value=s,i.dispatchEvent(new Event("change")))},window.Asok.handleFilesChange=(e,s,i)=>{const t=Array.from(e.target.files);if(t.length>i){alert("Maximum "+i+" files"),e.target.value="";return}s.files=t.map(n=>({name:n.name,size:n.size,url:URL.createObjectURL(n)}))},window.Asok.filterAutocomplete=(e,s)=>{e.query.length>=s?(e.filtered=e.all.filter(i=>String(i).toLowerCase().includes(e.query.toLowerCase())),e.show=!0):e.show=!1},window.Asok.selectAutocomplete=(e,s,i)=>{e.query=String(s),e.show=!1,i&&(i.value=e.query,i.dispatchEvent(new Event("change")))},window.Asok.updateWysiwyg=(e,s,i)=>{const t=e.target.innerHTML,n=window.AsokSecurity&&window.AsokSecurity.sanitizeHtml?window.AsokSecurity.sanitizeHtml(t):t;s.content=n,i&&(i.value=n,i.dispatchEvent(new Event("change")))},window.Asok.handleDropzoneDrop=(e,s,i,t)=>{s.dragging=!1;const n=Array.from(e.dataTransfer.files);if(n.length>i){alert("Max "+i+" files");return}const r=new DataTransfer;for(let o=0;o({name:o.name,size:o.size,_file:o}))},window.Asok.handleDropzoneChange=(e,s,i)=>{const t=Array.from(e.target.files);if(t.length>i){alert("Maximum "+i+" files");return}s.files=t.map(n=>({name:n.name,size:n.size,_file:n}))},window.Asok.removeDropzoneFile=(e,s,i)=>{e.files=e.files.filter((n,r)=>r!==s);const t=new DataTransfer;e.files.forEach(n=>t.items.add(n._file)),i&&(i.files=t.files)},window.Asok.startSignatureDrawing=(e,s,i)=>{s.drawing=!0;const t=i.getContext("2d"),n=i.getBoundingClientRect();t.beginPath(),t.moveTo(e.clientX-n.left,e.clientY-n.top),t.lineWidth=2,t.lineCap="round";const r=document.body.classList.contains("light-mode");t.strokeStyle=r?"#0f172a":"#f8fafc"},window.Asok.drawSignature=(e,s,i)=>{if(s.drawing){const t=i.getContext("2d"),n=i.getBoundingClientRect(),r=document.body.classList.contains("light-mode");t.strokeStyle=r?"#0f172a":"#f8fafc",t.lineWidth=2,t.lineCap="round",t.lineTo(e.clientX-n.left,e.clientY-n.top),t.stroke()}},window.Asok.stopSignatureDrawing=(e,s,i)=>{e.drawing=!1,i&&(i.value=s.toDataURL(),i.dispatchEvent(new Event("change")))},window.Asok.clearSignature=(e,s)=>{e.getContext("2d").clearRect(0,0,e.width,e.height),s&&(s.value="",s.dispatchEvent(new Event("change")))},window.Asok.updateTransferSelection=(e,s,i)=>{e[s]=Array.from(i.target.selectedOptions).map(t=>t.value)},window.Asok.moveTransferRight=e=>{const s=e.available.filter(i=>e.h_avail.includes(String(i.id!==void 0?i.id:i)));e.selected=[...e.selected,...s],e.available=e.available.filter(i=>!s.includes(i)),e.h_avail=[]},window.Asok.moveTransferLeft=e=>{const s=e.selected.filter(i=>e.h_sel.includes(String(i.id!==void 0?i.id:i)));e.available=[...e.available,...s],e.selected=e.selected.filter(i=>!s.includes(i)),e.h_sel=[]},window.Asok.moveTransferItemRight=(e,s)=>{e.selected.push(s),e.available=e.available.filter(i=>i!==s)},window.Asok.moveTransferItemLeft=(e,s)=>{e.available.push(s),e.selected=e.selected.filter(i=>i!==s)},window.Asok.selectTreeItem=(e,s,i)=>{e.selected=s,i&&(i.value=s,i.dispatchEvent(new Event("change")))},window.Asok.toggleTreeExpansion=(e,s)=>{e.expanded.includes(s)?e.expanded=e.expanded.filter(i=>i!==s):e.expanded.push(s)},window.AsokDirectives={init:m,forceInit:R,cleanupOld:z,resetFlags:N,version:"1.0.0",w:f},window.Asok.store=T})(); diff --git a/asok/core/assets/asok_security_utils.js b/asok/core/assets/asok_security_utils.js new file mode 100644 index 0000000..fab1c68 --- /dev/null +++ b/asok/core/assets/asok_security_utils.js @@ -0,0 +1,292 @@ +/** + * ASOK Security Utilities + * Provides security helpers for safe DOM manipulation and data validation + * + * SECURITY: This module provides defense-in-depth for XSS, injection, and open redirect attacks + */ + +(function(window) { + 'use strict'; + + const AsokSecurity = { + + /** + * Sanitize HTML by removing dangerous elements and attributes + * SECURITY: Prevents XSS attacks via innerHTML injection + * + * @param {string} html - HTML string to sanitize + * @returns {string} - Sanitized HTML + */ + sanitizeHtml: function(html) { + if (typeof html !== 'string') { + return ''; + } + + // Create a temporary div to parse HTML safely + const temp = document.createElement('div'); + temp.innerHTML = html; + + // Remove all script tags + const scripts = temp.querySelectorAll('script'); + scripts.forEach(s => s.remove()); + + // Remove dangerous tags + const dangerousTags = ['iframe', 'object', 'embed', 'link', 'style', 'form']; + dangerousTags.forEach(tag => { + const elements = temp.querySelectorAll(tag); + elements.forEach(el => el.remove()); + }); + + // Remove event handler attributes from all elements + const allElements = temp.querySelectorAll('*'); + allElements.forEach(el => { + // Get all attributes + const attrs = Array.from(el.attributes); + attrs.forEach(attr => { + // Remove on* event handlers + if (attr.name.toLowerCase().startsWith('on')) { + el.removeAttribute(attr.name); + } + + // Validate href/src attributes + if (attr.name.toLowerCase() === 'href' || attr.name.toLowerCase() === 'src') { + if (!this.isSafeUrl(attr.value)) { + el.removeAttribute(attr.name); + } + } + }); + }); + + return temp.innerHTML; + }, + + /** + * Validate URL is safe (blocks javascript:, data:, etc.) + * SECURITY: Prevents open redirect and javascript protocol attacks + * + * @param {string} url - URL to validate + * @returns {boolean} - True if URL is safe + */ + isSafeUrl: function(url) { + if (!url || typeof url !== 'string') { + return false; + } + + const urlLower = url.trim().toLowerCase(); + + // Block dangerous protocols + const dangerousProtocols = [ + 'javascript:', + 'data:', + 'vbscript:', + 'file:', + 'about:', + 'blob:' + ]; + + for (let i = 0; i < dangerousProtocols.length; i++) { + if (urlLower.startsWith(dangerousProtocols[i])) { + console.warn('[Asok Security] Blocked dangerous URL:', url.substring(0, 50)); + return false; + } + } + + // Allow relative URLs, http, https, mailto, tel + const safeProtocolPattern = /^(https?:\/\/|mailto:|tel:|\/|#|\?)/i; + + // If URL has a protocol, it must be safe + if (url.indexOf(':') !== -1 && url.indexOf(':') < 10) { + return safeProtocolPattern.test(url); + } + + // Relative URLs without protocol are safe + return true; + }, + + /** + * Validate attribute name is safe for binding + * SECURITY: Prevents event handler injection via attribute binding + * + * @param {string} attrName - Attribute name to validate + * @returns {boolean} - True if attribute is safe to bind + */ + isSafeAttribute: function(attrName) { + if (!attrName || typeof attrName !== 'string') { + return false; + } + + const attrLower = attrName.toLowerCase(); + + // Block event handlers + if (attrLower.startsWith('on')) { + console.warn('[Asok Security] Blocked event handler attribute:', attrName); + return false; + } + + // Dangerous attributes that could execute code + const dangerousAttrs = [ + 'srcdoc', + 'formaction', + 'data-bind', + 'xmlns:xlink' + ]; + + if (dangerousAttrs.indexOf(attrLower) !== -1) { + console.warn('[Asok Security] Blocked dangerous attribute:', attrName); + return false; + } + + return true; + }, + + /** + * Validate and cap duration values for setTimeout + * SECURITY: Prevents timing-based DoS attacks + * + * @param {number} duration - Duration in milliseconds + * @param {number} maxDuration - Maximum allowed duration (default 10000ms) + * @returns {number} - Safe duration value + */ + safeDuration: function(duration, maxDuration) { + maxDuration = maxDuration || 10000; // 10 seconds max by default + + const parsed = parseInt(duration, 10); + if (isNaN(parsed) || parsed < 0) { + return 0; + } + + return Math.min(parsed, maxDuration); + }, + + /** + * Validate WebSocket message structure + * SECURITY: Prevents injection attacks via malformed messages + * + * @param {object} data - Parsed WebSocket message + * @returns {boolean} - True if message structure is valid + */ + validateWsMessage: function(data) { + if (!data || typeof data !== 'object') { + return false; + } + + // Must have an operation + if (!data.op || typeof data.op !== 'string') { + return false; + } + + // Validate operation types + const validOps = ['render', 'model_event', 'broadcast', 'reload']; + if (validOps.indexOf(data.op) === -1) { + console.warn('[Asok Security] Unknown WebSocket operation:', data.op); + return false; + } + + // Validate component ID format if present + if (data.cid) { + if (typeof data.cid !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(data.cid)) { + console.warn('[Asok Security] Invalid component ID format:', data.cid); + return false; + } + } + + // Validate HTML content if present + if (data.html !== undefined && typeof data.html !== 'string') { + console.warn('[Asok Security] Invalid HTML type in message'); + return false; + } + + return true; + }, + + /** + * Safe JSON parsing with error handling + * SECURITY: Prevents DoS via malformed JSON + * + * @param {string} jsonString - JSON string to parse + * @returns {object|null} - Parsed object or null if invalid + */ + safeJsonParse: function(jsonString) { + try { + return JSON.parse(jsonString); + } catch (error) { + console.error('[Asok Security] JSON parse error:', error.message); + return null; + } + }, + + /** + * Detect sensitive form fields that should not be in GET requests + * SECURITY: Prevents sensitive data leakage in URLs + * + * @param {FormData} formData - Form data to check + * @returns {boolean} - True if sensitive data is present + */ + hasSensitiveData: function(formData) { + const sensitiveFields = [ + 'password', 'passwd', 'pwd', + 'token', 'csrf', 'csrf_token', + 'secret', 'api_key', 'apikey', + 'authorization', 'auth', + 'credit_card', 'card_number', 'cvv', + 'ssn', 'social_security' + ]; + + for (const pair of formData.entries()) { + const keyLower = pair[0].toLowerCase(); + for (let i = 0; i < sensitiveFields.length; i++) { + if (keyLower.indexOf(sensitiveFields[i]) !== -1) { + return true; + } + } + } + + return false; + }, + + /** + * Validate WebSocket port configuration + * SECURITY: Prevents port hijacking in development mode + * + * @param {number} port - Port number to validate + * @returns {boolean} - True if port is valid + */ + isValidPort: function(port) { + const parsed = parseInt(port, 10); + + // Port must be a valid number + if (isNaN(parsed)) { + return false; + } + + // Port must be in valid range (avoid privileged ports in production) + if (parsed < 1024 || parsed > 65535) { + console.warn('[Asok Security] Invalid port range:', port); + return false; + } + + return true; + }, + + /** + * Escape HTML entities for safe text insertion + * SECURITY: Prevents XSS when inserting user data as text + * + * @param {string} text - Text to escape + * @returns {string} - Escaped text + */ + escapeHtml: function(text) { + if (typeof text !== 'string') { + return ''; + } + + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + }; + + // Export to window + window.AsokSecurity = AsokSecurity; + +})(window); diff --git a/asok/core/assets/asok_security_utils.min.js b/asok/core/assets/asok_security_utils.min.js new file mode 100644 index 0000000..79c9581 --- /dev/null +++ b/asok/core/assets/asok_security_utils.min.js @@ -0,0 +1 @@ +(function(c){"use strict";const a={sanitizeHtml:function(e){if(typeof e!="string")return"";const r=document.createElement("div");return r.innerHTML=e,r.querySelectorAll("script").forEach(o=>o.remove()),["iframe","object","embed","link","style","form"].forEach(o=>{r.querySelectorAll(o).forEach(s=>s.remove())}),r.querySelectorAll("*").forEach(o=>{Array.from(o.attributes).forEach(s=>{s.name.toLowerCase().startsWith("on")&&o.removeAttribute(s.name),(s.name.toLowerCase()==="href"||s.name.toLowerCase()==="src")&&(this.isSafeUrl(s.value)||o.removeAttribute(s.name))})}),r.innerHTML},isSafeUrl:function(e){if(!e||typeof e!="string")return!1;const r=e.trim().toLowerCase(),t=["javascript:","data:","vbscript:","file:","about:","blob:"];for(let n=0;n65535?(console.warn("[Asok Security] Invalid port range:",e),!1):!0},escapeHtml:function(e){if(typeof e!="string")return"";const r=document.createElement("div");return r.textContent=e,r.innerHTML}};c.AsokSecurity=a})(window); diff --git a/asok/core/assets/asok_spa.js b/asok/core/assets/asok_spa.js index 5e8a834..d12d41f 100644 --- a/asok/core/assets/asok_spa.js +++ b/asok/core/assets/asok_spa.js @@ -211,7 +211,11 @@ }); const tempContainer = document.createElement('div'); - tempContainer.innerHTML = html; + // SECURITY: HTML comes from server - sanitize to prevent XSS if server is compromised + // Note: This assumes server responses are trusted but adds defense-in-depth + const sanitizedHtml = window.AsokSecurity && window.AsokSecurity.sanitizeHtml ? + window.AsokSecurity.sanitizeHtml(html) : html; + tempContainer.innerHTML = sanitizedHtml; const insertedNodes = Array.from(tempContainer.childNodes); insertedNodes.forEach(function (node) { startMarker.parentNode.insertBefore(node, endMarker); @@ -233,19 +237,23 @@ afterSwap(newNodes || [target]); }); } else { + // SECURITY: Fallback implementation with sanitization (defense-in-depth) + const safeHtml = window.AsokSecurity && window.AsokSecurity.sanitizeHtml ? + window.AsokSecurity.sanitizeHtml(html) : html; + if (mode === 'delete') { target.remove(); afterSwap([]); } else if (mode === 'outerHTML' || mode === 'replaceWith') { - const fragment = document.createRange().createContextualFragment(html); + const fragment = document.createRange().createContextualFragment(safeHtml); const newNodes = Array.from(fragment.childNodes); target.replaceWith(fragment); afterSwap(newNodes); } else if (mode === 'innerHTML') { - target.innerHTML = html; + target.innerHTML = safeHtml; afterSwap(Array.from(target.childNodes)); } else { - const fragment = document.createRange().createContextualFragment(html); + const fragment = document.createRange().createContextualFragment(safeHtml); const newNodes = Array.from(fragment.childNodes); if (mode === 'beforebegin') { target.parentNode.insertBefore(fragment, target); @@ -256,7 +264,7 @@ } else if (mode === 'afterend') { target.parentNode.insertBefore(fragment, target.nextSibling); } else { - target.insertAdjacentHTML(mode, html); + target.insertAdjacentHTML(mode, safeHtml); } afterSwap(newNodes); } @@ -292,6 +300,13 @@ const redirectUrl = res.headers.get('X-Asok-Redirect'); if (redirectUrl) { + // SECURITY: Validate redirect URL to prevent open redirect attacks + if (window.AsokSecurity && window.AsokSecurity.isSafeUrl) { + if (!window.AsokSecurity.isSafeUrl(redirectUrl)) { + console.error('[Asok] Blocked unsafe redirect URL:', redirectUrl); + return Promise.reject('unsafe_redirect'); + } + } window.location.href = redirectUrl; return Promise.reject('redirected'); } @@ -333,12 +348,29 @@ } const tempDiv = document.createElement('div'); + // SECURITY: tempDiv is not inserted into DOM, only used to parse templates + // The actual content is sanitized when passed through doSwap() -> Asok.swap() tempDiv.innerHTML = html; const templates = tempDiv.querySelectorAll('template[data-block]'); const shouldPushUrl = (sourceElement && sourceElement.dataset && sourceElement.dataset.pushUrl !== undefined) || (!sourceElement && url); const pushData = shouldPushUrl ? { shouldPush: true, src: sourceElement, url: url, b: blockName, sel: selector } : null; if (templates.length) { + // Execute root-level scripts (like the directives registry) before swapping templates + tempDiv.querySelectorAll('script').forEach(function (script) { + let parent = script.parentNode; + while (parent && parent !== tempDiv) { + if (parent.tagName === 'TEMPLATE') return; + parent = parent.parentNode; + } + const newScript = document.createElement('script'); + if (script.nonce) newScript.nonce = script.nonce; + if (script.src) newScript.src = script.src; + newScript.textContent = script.textContent; + document.body.appendChild(newScript); + newScript.remove(); + }); + for (let i = 0; i < templates.length; i++) { const tpl = templates[i]; const target = findTargetElement(tpl.dataset.block); @@ -448,7 +480,19 @@ formData.append(el.name, el.value); } - if (method === 'GET') { + // SECURITY: Check for sensitive data before allowing GET method + if (method === 'GET' && window.AsokSecurity && window.AsokSecurity.hasSensitiveData) { + if (window.AsokSecurity.hasSensitiveData(formData)) { + console.warn('[Asok Security] Forcing POST for form with sensitive data'); + method = 'POST'; + body = formData; + } else { + const params = new URLSearchParams(formData).toString(); + if (params) { + url += (url.indexOf('?') < 0 ? '?' : '&') + params; + } + } + } else if (method === 'GET') { const params = new URLSearchParams(formData).toString(); if (params) { url += (url.indexOf('?') < 0 ? '?' : '&') + params; @@ -472,7 +516,19 @@ if (actionValue) { formData.append('_action', actionValue); } - if (method === 'GET') { + // SECURITY: Check for sensitive data before allowing GET method + if (method === 'GET' && window.AsokSecurity && window.AsokSecurity.hasSensitiveData) { + if (window.AsokSecurity.hasSensitiveData(formData)) { + console.warn('[Asok Security] Forcing POST for form with sensitive data'); + method = 'POST'; + body = formData; + } else { + const params = new URLSearchParams(formData).toString(); + if (params) { + url += (url.indexOf('?') < 0 ? '?' : '&') + params; + } + } + } else if (method === 'GET') { const params = new URLSearchParams(formData).toString(); if (params) { url += (url.indexOf('?') < 0 ? '?' : '&') + params; @@ -628,6 +684,16 @@ const el = e.target.closest('[data-block]'); if (!el || el.tagName === 'FORM') return; + const isInteractive = + el.tagName === 'A' || + el.tagName === 'BUTTON' || + el.tagName === 'INPUT' || + el.hasAttribute('data-url') || + el.hasAttribute('data-action') || + el.hasAttribute('data-trigger'); + + if (!isInteractive) return; + const triggerEvent = (el.dataset.trigger || 'click').split(/\s+/)[0]; if (triggerEvent !== 'click') return; @@ -636,76 +702,94 @@ }); // Setup dynamic components triggers and SSE - function setupDirectives() { + function initSpaDirectives(root) { + const el = root || document; + const elements = el === document ? document.querySelectorAll('*') : [el, ...el.querySelectorAll('*')]; + // SSE event sources - document.querySelectorAll('[data-sse]').forEach(function (el) { - if (el.__asokSseSetup) return; - el.__asokSseSetup = 1; - - const eventSource = new EventSource(el.dataset.sse); - const selector = el.dataset.block || ('#' + el.id); - const swapMode = el.dataset.swap || 'innerHTML'; - - eventSource.onmessage = function (ev) { - const tempContainer = document.createElement('div'); - tempContainer.innerHTML = ev.data; - const templates = tempContainer.querySelectorAll('template[data-block]'); - - if (templates.length) { - for (let i = 0; i < templates.length; i++) { - const tpl = templates[i]; - const target = findTargetElement(tpl.dataset.block); + elements.forEach(function (n) { + if (n.hasAttribute && n.hasAttribute('data-sse')) { + if (n.__asokSseSetup) return; + n.__asokSseSetup = 1; + + const eventSource = new EventSource(n.dataset.sse); + const selector = n.dataset.block || ('#' + n.id); + const swapMode = n.dataset.swap || 'innerHTML'; + + eventSource.onmessage = function (ev) { + const tempContainer = document.createElement('div'); + // SECURITY: tempContainer is not inserted into DOM, only used to parse templates + // The actual content is sanitized when passed through doSwap() -> Asok.swap() + tempContainer.innerHTML = ev.data; + const templates = tempContainer.querySelectorAll('template[data-block]'); + + if (templates.length) { + for (let i = 0; i < templates.length; i++) { + const tpl = templates[i]; + const target = findTargetElement(tpl.dataset.block); + if (target) { + doSwap(target, tpl.innerHTML, tpl.dataset.swap || 'innerHTML', null); + } + } + } else { + const target = findTargetElement(selector); if (target) { - doSwap(target, tpl.innerHTML, tpl.dataset.swap || 'innerHTML', null); + doSwap(target, ev.data, swapMode, null); } } - } else { - const target = findTargetElement(selector); - if (target) { - doSwap(target, ev.data, swapMode, null); - } - } - }; + }; + } }); // Custom triggers - document.querySelectorAll('[data-block][data-trigger]').forEach(function (el) { - if (el.__asokTriggerSetup) return; - el.__asokTriggerSetup = 1; - - const trigger = parseTriggerOption(el.dataset.trigger); - if (trigger.event === 'submit' || trigger.event === 'click') return; + elements.forEach(function (n) { + if (n.hasAttribute && n.hasAttribute('data-block') && n.hasAttribute('data-trigger')) { + if (n.__asokTriggerSetup) return; + n.__asokTriggerSetup = 1; - if (trigger.event === 'load') { - triggerBlockRequest(el); - return; - } + const trigger = parseTriggerOption(n.dataset.trigger); + if (trigger.event === 'submit' || trigger.event === 'click') return; - if (trigger.event === 'every') { - triggerBlockRequest(el); - setInterval(function () { - triggerBlockRequest(el); - }, trigger.interval); - return; - } + if (trigger.event === 'load') { + triggerBlockRequest(n); + return; + } - let debounceTimer; - el.addEventListener(trigger.event, function () { - if (trigger.delay) { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(function () { - triggerBlockRequest(el); - }, trigger.delay); - } else { - triggerBlockRequest(el); + if (trigger.event === 'every') { + triggerBlockRequest(n); + setInterval(function () { + triggerBlockRequest(n); + }, trigger.interval); + return; } - }); + + let debounceTimer; + n.addEventListener(trigger.event, function () { + if (trigger.delay) { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(function () { + triggerBlockRequest(n); + }, trigger.delay); + } else { + triggerBlockRequest(n); + } + }); + } }); } + window.Asok = window.Asok || {}; + const oldInit = window.Asok.init; + window.Asok.init = function (el) { + if (oldInit) oldInit(el); + initSpaDirectives(el); + }; + if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', setupDirectives); + document.addEventListener('DOMContentLoaded', function () { + initSpaDirectives(document); + }); } else { - setupDirectives(); + initSpaDirectives(document); } })(); diff --git a/asok/core/assets/asok_spa.min.js b/asok/core/assets/asok_spa.min.js index 372473a..f51e657 100644 --- a/asok/core/assets/asok_spa.min.js +++ b/asok/core/assets/asok_spa.min.js @@ -1 +1 @@ -(function(){window.Asok=window.Asok||{};const v={},E=[],q=100;window.__asokClearCache=function(){Object.keys(v).forEach(e=>delete v[e]),E.length=0};function I(e,t){if(E.length>=q){const n=E.shift();delete v[n]}v[e]=t,E.push(e)}function O(){const e=document.querySelector("meta[name=csrf-token]");return e?e.content:""}function S(e){if(!e)return null;let t;if(/^[a-zA-Z0-9_-]+$/.test(e)){const n=document.createNodeIterator(document.body,NodeFilter.SHOW_COMMENT);let l;for(;l=n.nextNode();)if(l.textContent.trim()==="block:"+e+":start"){t={_isBlockMarker:!0,_blockName:e,_startMarker:l};break}}if(!t)try{t=document.querySelector(e)}catch{}return!t&&/^[a-zA-Z0-9_-]+$/.test(e)&&(t=document.getElementById(e)),!t&&e==="title"&&(t=document.querySelector("title")),!t&&e==="description"&&(t=document.querySelector("meta[name=description]")),t}function C(e,t,n,l){const m=e._isBlockMarker?e._startMarker.parentNode:e,r=function(s){(Array.isArray(s)?s:[s]).forEach(function(i){i&&(i.querySelectorAll&&i.querySelectorAll("[data-asok-component]").forEach(function(c){delete c.__asokWsReady,delete c.__asokIniting,window.Asok&&window.Asok.leaveComponent&&window.Asok.leaveComponent(c.id.replace("asok-",""))}),i.dataset&&i.dataset.asokComponent&&(delete i.__asokWsReady,delete i.__asokIniting,window.Asok&&window.Asok.leaveComponent&&window.Asok.leaveComponent(i.id.replace("asok-",""))),i.querySelectorAll&&window.AsokDirectives&&window.AsokDirectives.cleanupOld&&window.AsokDirectives.cleanupOld(i))})},d=function(s){const a=s||[],i=[];if(a.forEach(function(o){o.tagName==="SCRIPT"&&i.push(o),o.querySelectorAll&&o.querySelectorAll("script").forEach(function(u){i.push(u)})}),i.forEach(function(o){if(o.dataset.run||o.id==="asok-scoped-js")return;const u=document.createElement("script");o.nonce&&(u.nonce=o.nonce),o.src&&(u.src=o.src),u.textContent=o.textContent,u.dataset.run="1",o.parentNode.replaceChild(u,o)}),a.forEach(function(o){o.querySelectorAll&&(window.AsokDirectives&&window.AsokDirectives.init&&window.AsokDirectives.init(o),window.Asok&&window.Asok.init&&window.Asok.init(o))}),window.lucide&&window.lucide.createIcons&&window.lucide.createIcons(),l&&l.shouldPush){const o=document.getElementById("search-overlay");o&&o.classList.remove("open");const u=document.getElementById("mobile-menu");if(u&&u.classList.add("hidden"),document.body.style.overflow="",l.src&&l.src.dataset&&l.src.dataset.pushUrl!==void 0){const k=l.src.dataset.pushUrl||l.url;history.pushState({b:l.b,sel:l.sel,mode:n,url:l.url},"",k)}window.scrollTo({top:0,behavior:"instant"});const f=document.querySelector("[data-asok-page-transition]");if(f){const h=(f.getAttribute("data-asok-page-transition")||"page").split(" "),w=h[0],b=parseInt(h[1])||300;f.classList.add("asok-"+w+"-in"),requestAnimationFrame(()=>{f.classList.add("is-entering"),setTimeout(()=>{f.classList.remove("asok-"+w+"-in","is-entering")},b)})}}const c=new CustomEvent("asok:success",{detail:{target:m,mode:n}});document.dispatchEvent(c)};if(e._isBlockMarker){const s=e._startMarker,a=e._blockName,i=document.createNodeIterator(document.body,NodeFilter.SHOW_COMMENT);let c,o=null;for(;c=i.nextNode();)if(c===s){for(;c=i.nextNode();)if(c.textContent.trim()==="block:"+a+":end"){o=c;break}break}if(!o)return;const u=[];let f=s.nextSibling;for(;f&&f!==o;)u.push(f),f=f.nextSibling;r(u),u.forEach(function(w){w.remove()});const k=document.createElement("div");k.innerHTML=t;const h=Array.from(k.childNodes);h.forEach(function(w){s.parentNode.insertBefore(w,o)}),d(h)}else if(e.tagName==="META")e.content=t,d([e]);else{if(n==="innerHTML"?r(Array.from(e.childNodes)):(n==="outerHTML"||n==="replaceWith"||n==="delete")&&r(e),window.Asok&&window.Asok.swap)window.Asok.swap(e,t,n,function(s){d(s||[e])});else if(n==="delete")e.remove(),d([]);else if(n==="outerHTML"||n==="replaceWith"){const s=document.createRange().createContextualFragment(t),a=Array.from(s.childNodes);e.replaceWith(s),d(a)}else if(n==="innerHTML")e.innerHTML=t,d(Array.from(e.childNodes));else{const s=document.createRange().createContextualFragment(t),a=Array.from(s.childNodes);n==="beforebegin"?e.parentNode.insertBefore(s,e):n==="afterbegin"?e.insertBefore(s,e.firstChild):n==="beforeend"?e.appendChild(s):n==="afterend"?e.parentNode.insertBefore(s,e.nextSibling):e.insertAdjacentHTML(n,t),d(a)}e.tagName==="TITLE"&&(document.title=e.innerText)}}function x(e,t,n,l,m,r){if(document.dispatchEvent(new CustomEvent("asok:before",{detail:{url:e,block:t}}))===!1)return;const d=Object.assign({"X-Block":t,"X-CSRF-Token":O()},m.headers||{});m.headers=d,m.credentials="same-origin";const s=e+t,a=v[s]?Promise.resolve(v[s]):fetch(e,m).then(function(i){if(!i.ok)return i.text().then(function(k){const h=new CustomEvent("asok:error",{detail:{url:e,status:i.status,message:k}});throw document.dispatchEvent(h),console.error((i.status===400?"Asok Consistency Error: ":"Asok Error "+i.status+": ")+k),k});const c=i.headers.get("X-Asok-Redirect");if(c)return window.location.href=c,Promise.reject("redirected");const o=i.headers.get("X-CSRF-Token"),u=i.headers.get("X-Asok-Blocks");if(o){const k=document.querySelector("meta[name=csrf-token]");k&&(k.content=o),document.querySelectorAll("input[name=csrf_token]").forEach(function(h){h.value=o})}u&&(window.Asok.lastBlocks=u);const f=i.headers.get("X-Asok-SQL-Log");return f?window.Asok.lastSqlLog=f:window.Asok.lastSqlLog=null,i.text()});return delete v[s],a.then(function(i){if(!i)return;const c=i.trimStart();if(c.startsWith("s.classList.add("is-leaving")),setTimeout(()=>s.classList.remove("asok-"+c+"-out","is-leaving"),o)}return x(n.url,n.block,n.sel,n.swap,l,e).then(function(){m.forEach(function(a){a.classList.remove("is-loading")}),r.forEach(function(a){a.disabled=!1})},function(){m.forEach(function(a){a.classList.remove("is-loading")}),r.forEach(function(a){a.disabled=!1})})}function F(e){const t=e.match(/^every\s+(\d+)(ms|s)$/);if(t){const r=parseInt(t[1]),d=t[2]==="s"?1e3:1;return{event:"every",interval:r*d}}const n=e.split(/\s+/),l=n[0];let m=0;for(let r=1;rdelete y[e]),b.length=0};function O(e,t){if(b.length>=I){const o=b.shift();delete y[o]}y[e]=t,b.push(e)}function H(){const e=document.querySelector("meta[name=csrf-token]");return e?e.content:""}function E(e){if(!e)return null;let t;if(/^[a-zA-Z0-9_-]+$/.test(e)){const o=document.createNodeIterator(document.body,NodeFilter.SHOW_COMMENT);let i;for(;i=o.nextNode();)if(i.textContent.trim()==="block:"+e+":start"){t={_isBlockMarker:!0,_blockName:e,_startMarker:i};break}}if(!t)try{t=document.querySelector(e)}catch{}return!t&&/^[a-zA-Z0-9_-]+$/.test(e)&&(t=document.getElementById(e)),!t&&e==="title"&&(t=document.querySelector("title")),!t&&e==="description"&&(t=document.querySelector("meta[name=description]")),t}function T(e,t,o,i){const u=e._isBlockMarker?e._startMarker.parentNode:e,c=function(d){(Array.isArray(d)?d:[d]).forEach(function(s){s&&(s.querySelectorAll&&s.querySelectorAll("[data-asok-component]").forEach(function(a){delete a.__asokWsReady,delete a.__asokIniting,window.Asok&&window.Asok.leaveComponent&&window.Asok.leaveComponent(a.id.replace("asok-",""))}),s.dataset&&s.dataset.asokComponent&&(delete s.__asokWsReady,delete s.__asokIniting,window.Asok&&window.Asok.leaveComponent&&window.Asok.leaveComponent(s.id.replace("asok-",""))),s.querySelectorAll&&window.AsokDirectives&&window.AsokDirectives.cleanupOld&&window.AsokDirectives.cleanupOld(s))})},l=function(d){const r=d||[],s=[];if(r.forEach(function(n){n.tagName==="SCRIPT"&&s.push(n),n.querySelectorAll&&n.querySelectorAll("script").forEach(function(f){s.push(f)})}),s.forEach(function(n){if(n.dataset.run||n.id==="asok-scoped-js")return;const f=document.createElement("script");n.nonce&&(f.nonce=n.nonce),n.src&&(f.src=n.src),f.textContent=n.textContent,f.dataset.run="1",n.parentNode.replaceChild(f,n)}),r.forEach(function(n){n.querySelectorAll&&(window.AsokDirectives&&window.AsokDirectives.init&&window.AsokDirectives.init(n),window.Asok&&window.Asok.init&&window.Asok.init(n))}),window.lucide&&window.lucide.createIcons&&window.lucide.createIcons(),i&&i.shouldPush){const n=document.getElementById("search-overlay");n&&n.classList.remove("open");const f=document.getElementById("mobile-menu");if(f&&f.classList.add("hidden"),document.body.style.overflow="",i.src&&i.src.dataset&&i.src.dataset.pushUrl!==void 0){const k=i.src.dataset.pushUrl||i.url;history.pushState({b:i.b,sel:i.sel,mode:o,url:i.url},"",k)}window.scrollTo({top:0,behavior:"instant"});const w=document.querySelector("[data-asok-page-transition]");if(w){const g=(w.getAttribute("data-asok-page-transition")||"page").split(" "),S=g[0],A=parseInt(g[1])||300;w.classList.add("asok-"+S+"-in"),requestAnimationFrame(()=>{w.classList.add("is-entering"),setTimeout(()=>{w.classList.remove("asok-"+S+"-in","is-entering")},A)})}}const a=new CustomEvent("asok:success",{detail:{target:u,mode:o}});document.dispatchEvent(a)};if(e._isBlockMarker){const d=e._startMarker,r=e._blockName,s=document.createNodeIterator(document.body,NodeFilter.SHOW_COMMENT);let a,n=null;for(;a=s.nextNode();)if(a===d){for(;a=s.nextNode();)if(a.textContent.trim()==="block:"+r+":end"){n=a;break}break}if(!n)return;const f=[];let w=d.nextSibling;for(;w&&w!==n;)f.push(w),w=w.nextSibling;c(f),f.forEach(function(A){A.remove()});const k=document.createElement("div"),g=window.AsokSecurity&&window.AsokSecurity.sanitizeHtml?window.AsokSecurity.sanitizeHtml(t):t;k.innerHTML=g;const S=Array.from(k.childNodes);S.forEach(function(A){d.parentNode.insertBefore(A,n)}),l(S)}else if(e.tagName==="META")e.content=t,l([e]);else{if(o==="innerHTML"?c(Array.from(e.childNodes)):(o==="outerHTML"||o==="replaceWith"||o==="delete")&&c(e),window.Asok&&window.Asok.swap)window.Asok.swap(e,t,o,function(d){l(d||[e])});else{const d=window.AsokSecurity&&window.AsokSecurity.sanitizeHtml?window.AsokSecurity.sanitizeHtml(t):t;if(o==="delete")e.remove(),l([]);else if(o==="outerHTML"||o==="replaceWith"){const r=document.createRange().createContextualFragment(d),s=Array.from(r.childNodes);e.replaceWith(r),l(s)}else if(o==="innerHTML")e.innerHTML=d,l(Array.from(e.childNodes));else{const r=document.createRange().createContextualFragment(d),s=Array.from(r.childNodes);o==="beforebegin"?e.parentNode.insertBefore(r,e):o==="afterbegin"?e.insertBefore(r,e.firstChild):o==="beforeend"?e.appendChild(r):o==="afterend"?e.parentNode.insertBefore(r,e.nextSibling):e.insertAdjacentHTML(o,d),l(s)}}e.tagName==="TITLE"&&(document.title=e.innerText)}}function P(e,t,o,i,u,c){if(document.dispatchEvent(new CustomEvent("asok:before",{detail:{url:e,block:t}}))===!1)return;const l=Object.assign({"X-Block":t,"X-CSRF-Token":H()},u.headers||{});u.headers=l,u.credentials="same-origin";const d=e+t,r=y[d]?Promise.resolve(y[d]):fetch(e,u).then(function(s){if(!s.ok)return s.text().then(function(k){const g=new CustomEvent("asok:error",{detail:{url:e,status:s.status,message:k}});throw document.dispatchEvent(g),console.error((s.status===400?"Asok Consistency Error: ":"Asok Error "+s.status+": ")+k),k});const a=s.headers.get("X-Asok-Redirect");if(a)return window.AsokSecurity&&window.AsokSecurity.isSafeUrl&&!window.AsokSecurity.isSafeUrl(a)?(console.error("[Asok] Blocked unsafe redirect URL:",a),Promise.reject("unsafe_redirect")):(window.location.href=a,Promise.reject("redirected"));const n=s.headers.get("X-CSRF-Token"),f=s.headers.get("X-Asok-Blocks");if(n){const k=document.querySelector("meta[name=csrf-token]");k&&(k.content=n),document.querySelectorAll("input[name=csrf_token]").forEach(function(g){g.value=n})}f&&(window.Asok.lastBlocks=f);const w=s.headers.get("X-Asok-SQL-Log");return w?window.Asok.lastSqlLog=w:window.Asok.lastSqlLog=null,s.text()});return delete y[d],r.then(function(s){if(!s)return;const a=s.trimStart();if(a.startsWith("d.classList.add("is-leaving")),setTimeout(()=>d.classList.remove("asok-"+a+"-out","is-leaving"),n)}return P(o.url,o.block,o.sel,o.swap,i,e).then(function(){u.forEach(function(r){r.classList.remove("is-loading")}),c.forEach(function(r){r.disabled=!1})},function(){u.forEach(function(r){r.classList.remove("is-loading")}),c.forEach(function(r){r.disabled=!1})})}function F(e){const t=e.match(/^every\s+(\d+)(ms|s)$/);if(t){const c=parseInt(t[1]),l=t[2]==="s"?1e3:1;return{event:"every",interval:c*l}}const o=e.split(/\s+/),i=o[0];let u=0;for(let c=1;c{s.classList.add("is-leaving")}),setTimeout(()=>{const r=f(s,a,d);t&&t(r),s.classList.remove("asok-"+e+"-out","is-leaving"),s.classList.add("asok-"+e+"-in"),requestAnimationFrame(()=>{s.classList.add("is-entering"),setTimeout(()=>{s.classList.remove("asok-"+e+"-in","is-entering")},i)})},i)}else{const n=f(s,a,d);t&&t(n)}}})(); +(function(){window.Asok=window.Asok||{},window.Asok.swap=function(i,d,c,o){const u=function(n,s,e){if(e=e||"innerHTML",e==="delete")return n.remove(),[];if(e==="none")return[];if(e==="outerHTML"||e==="replaceWith"){const a=window.AsokSecurity&&window.AsokSecurity.sanitizeHtml?window.AsokSecurity.sanitizeHtml(s):s,f=document.createRange().createContextualFragment(a),w=Array.from(f.childNodes);return n.replaceWith(f),w}if(e==="innerHTML")return window.AsokSecurity&&window.AsokSecurity.sanitizeHtml?n.innerHTML=window.AsokSecurity.sanitizeHtml(s):n.textContent=s,Array.from(n.childNodes);const t=document.createRange().createContextualFragment(s),r=Array.from(t.childNodes);return e==="beforebegin"?n.parentNode.insertBefore(t,n):e==="afterbegin"?n.insertBefore(t,n.firstChild):e==="beforeend"?n.appendChild(t):e==="afterend"?n.parentNode.insertBefore(t,n.nextSibling):n.insertAdjacentHTML(e,s),r};if(i.hasAttribute("asok-transition")){const s=(i.getAttribute("asok-transition")||"fade").split(" "),e=s[0],t=parseInt(s[1])||300,r=window.AsokSecurity&&window.AsokSecurity.safeDuration?window.AsokSecurity.safeDuration(t,5e3):Math.min(t,5e3);i.classList.add("asok-"+e+"-out"),requestAnimationFrame(()=>{i.classList.add("is-leaving")}),setTimeout(()=>{const a=u(i,d,c);o&&o(a),i.classList.remove("asok-"+e+"-out","is-leaving"),i.classList.add("asok-"+e+"-in"),requestAnimationFrame(()=>{i.classList.add("is-entering"),setTimeout(()=>{i.classList.remove("asok-"+e+"-in","is-entering")},r)})},r)}else{const n=u(i,d,c);o&&o(n)}}})(); diff --git a/asok/core/security.py b/asok/core/security.py index 4a0c4e4..916304e 100644 --- a/asok/core/security.py +++ b/asok/core/security.py @@ -140,18 +140,9 @@ def _security_headers( # Build CSP with configurable directives ws_port = self.config.get("WS_PORT", 8001) - # Check if reactive features are used in this response to enable unsafe-eval only when needed - # In DEBUG mode, always enable unsafe-eval for easier development with directives - needs_eval = self.config.get("CSP_UNSAFE_EVAL", False) - - # SECURITY: Log when unsafe-eval is enabled for audit trail - # Only log if it's a dynamic activation (not explicitly set in config) - if ( - needs_eval - and not self.config.get("DEBUG") - and self.config.get("CSP_UNSAFE_EVAL") is not True - ): - logger.info("CSP 'unsafe-eval' dynamically enabled for reactive features") + # SECURITY: Asok directives are pre-compiled server-side and injected as JavaScript + # source code in the HTML. No eval() or new Function() is used at runtime, so + # unsafe-eval is NOT required. This provides stronger CSP protection. # Default CSP directives csp_directives = { @@ -176,7 +167,10 @@ def _security_headers( request_host = request.host.split(":")[0] # Only use request host if it matches the server name or is localhost/127.0.0.1 - if request_host in (server_name, "localhost", "127.0.0.1") or not server_name: + if ( + request_host in (server_name, "localhost", "127.0.0.1") + or not server_name + ): host = request_host csp_directives["connect-src"].extend( [ @@ -206,18 +200,13 @@ def _security_headers( ] ) - # Add script-src based on nonce and reactive needs + # Add script-src based on nonce script_src = ["'self'"] if nonce: # Use 'strict-dynamic' with nonce for CSP Level 3 browsers. # 'self' is kept as fallback for older browsers that don't support strict-dynamic. # Note: 'unsafe-inline' is ignored when nonce is present, so we don't include it. script_src.extend([f"'nonce-{nonce}'", "'strict-dynamic'"]) - - if needs_eval: - script_src.append("'unsafe-eval'") - - if nonce: csp_directives["script-src"] = script_src else: csp_directives["script-src"] = ["'self'"] diff --git a/asok/core/storage.py b/asok/core/storage.py new file mode 100644 index 0000000..19cf5e5 --- /dev/null +++ b/asok/core/storage.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import logging +import os +from abc import ABC, abstractmethod + +logger = logging.getLogger("asok.storage") + + +class BaseStorage(ABC): + """Abstract base class representing a storage backend.""" + + @abstractmethod + def save(self, filename: str, content: bytes, upload_to: str = "") -> str: + """Save a file and return its URL/path.""" + pass + + @abstractmethod + def url(self, filename: str, upload_to: str = "") -> str: + """Return the URL/path of a file.""" + pass + + @abstractmethod + def delete(self, filename: str, upload_to: str = "") -> None: + """Delete a file from the storage.""" + pass + + +class LocalStorage(BaseStorage): + """Local disk storage backend.""" + + def __init__(self) -> None: + self.base_dir = os.path.abspath( + os.path.join(os.getcwd(), "src/partials/uploads") + ) + + def save(self, filename: str, content: bytes, upload_to: str = "") -> str: + dest_dir = ( + os.path.join(self.base_dir, upload_to) if upload_to else self.base_dir + ) + os.makedirs(dest_dir, exist_ok=True) + dest_path = os.path.join(dest_dir, filename) + + # SECURITY: Prevent path traversal attacks + resolved_dest = os.path.realpath(dest_path) + resolved_base = os.path.realpath(self.base_dir) + if os.path.commonpath([resolved_dest, resolved_base]) != resolved_base: + raise ValueError(f"Path traversal blocked: {filename}") + + with open(resolved_dest, "wb") as f: + f.write(content) + os.chmod(resolved_dest, 0o644) + return resolved_dest + + def url(self, filename: str, upload_to: str = "") -> str: + if upload_to: + return f"/uploads/{upload_to}/{filename}" + return f"/uploads/{filename}" + + def delete(self, filename: str, upload_to: str = "") -> None: + dest_dir = ( + os.path.join(self.base_dir, upload_to) if upload_to else self.base_dir + ) + dest_path = os.path.join(dest_dir, filename) + try: + resolved_dest = os.path.realpath(dest_path) + resolved_base = os.path.realpath(self.base_dir) + if os.path.commonpath([resolved_dest, resolved_base]) == resolved_base: + if os.path.exists(resolved_dest): + os.remove(resolved_dest) + except Exception as e: + logger.warning(f"Failed to delete local file {filename}: {e}") + + +class S3Storage(BaseStorage): + """S3-compatible cloud storage backend.""" + + def __init__(self) -> None: + try: + import boto3 + except ImportError: + raise ImportError( + "The 'boto3' library is required to use the S3 storage backend. " + "Install it using 'pip install asok[s3]'." + ) + + self.bucket = os.environ.get("ASOK_S3_BUCKET") or os.environ.get("S3_BUCKET") + if not self.bucket: + raise ValueError( + "ASOK_S3_BUCKET / S3_BUCKET environment variable is required for S3 storage." + ) + + region = os.environ.get("ASOK_S3_REGION") or os.environ.get( + "AWS_DEFAULT_REGION" + ) + endpoint = os.environ.get("ASOK_S3_ENDPOINT") + + self.client = boto3.client( + "s3", + region_name=region, + endpoint_url=endpoint, + aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), + aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), + ) + self.custom_domain = os.environ.get("ASOK_S3_CUSTOM_DOMAIN") + + def save(self, filename: str, content: bytes, upload_to: str = "") -> str: + key = f"{upload_to}/{filename}" if upload_to else filename + + import mimetypes + + content_type, _ = mimetypes.guess_type(filename) + if not content_type: + content_type = "application/octet-stream" + + try: + self.client.put_object( + Bucket=self.bucket, + Key=key, + Body=content, + ContentType=content_type, + ) + except Exception as e: + raise RuntimeError(f"S3 upload failed: {e}") + + return self.url(filename, upload_to) + + def url(self, filename: str, upload_to: str = "") -> str: + key = f"{upload_to}/{filename}" if upload_to else filename + if self.custom_domain: + return f"https://{self.custom_domain}/{key}" + + region = self.client.meta.region_name + if region and region != "us-east-1": + return f"https://{self.bucket}.s3.{region}.amazonaws.com/{key}" + return f"https://{self.bucket}.s3.amazonaws.com/{key}" + + def delete(self, filename: str, upload_to: str = "") -> None: + key = f"{upload_to}/{filename}" if upload_to else filename + try: + self.client.delete_object(Bucket=self.bucket, Key=key) + except Exception as e: + logger.warning(f"Failed to delete {key} from S3: {e}") + + +_storage_instance: BaseStorage | None = None + + +def get_storage() -> BaseStorage: + """Get the active storage backend based on configuration.""" + global _storage_instance + if _storage_instance is None: + backend = os.environ.get("ASOK_STORAGE_BACKEND", "local").lower() + if backend == "s3": + _storage_instance = S3Storage() + else: + _storage_instance = LocalStorage() + return _storage_instance diff --git a/asok/core/wsgi.py b/asok/core/wsgi.py index 5e6c35d..38b22b4 100644 --- a/asok/core/wsgi.py +++ b/asok/core/wsgi.py @@ -267,6 +267,17 @@ def _dispatch_controller(self, request: Request, environ: dict[str, Any]) -> Any tpl_root = self._tpl_root def core_layer(req): + + def resolve_if_coro(r): + if inspect.iscoroutine(r): + if req.environ.get("asok.asgi"): + return r + else: + from .asgi import async_to_sync + + return async_to_sync(r) + return r + if self.config.get("CSRF") and req.method in ( "POST", "PUT", @@ -345,7 +356,8 @@ def core_layer(req): if action_name: action_func = getattr(module, f"action_{action_name}", None) if callable(action_func): - req.verify_csrf() + if self.config.get("CSRF"): + req.verify_csrf() res = action_func(req) if res is None: req.abort( @@ -353,7 +365,7 @@ def core_layer(req): f"Action handler 'action_{action_name}' in {page_file} returned None. " "Ensure your action returns request.html(), request.json(), or calls request.redirect().", ) - return res + return resolve_if_coro(res) method_func = getattr(module, req.method.lower(), None) if callable(method_func): @@ -363,7 +375,7 @@ def core_layer(req): 500, f"Method function '{req.method.lower()}' in {page_file} returned None.", ) - return res + return resolve_if_coro(res) if hasattr(module, "render"): res = module.render(req) @@ -374,7 +386,7 @@ def core_layer(req): 500, f"render() in {page_file} returned None. Check your logic.", ) - return res + return resolve_if_coro(res) if hasattr(module, "CONTENT"): return module.CONTENT @@ -396,9 +408,48 @@ def core_layer(req): req.status = "404 Not Found" return "

404 Not Found

The requested route does not provide a valid handler.

" - chain = self._get_middleware_chain(core_layer) - with request_context(request): - content_str = chain(request) + import asyncio + + try: + loop_running = asyncio.get_running_loop().is_running() + except RuntimeError: + loop_running = False + + if loop_running: + chain = self._get_async_middleware_chain(core_layer) + with request_context(request): + content_str = chain(request) + else: + is_async_controller = False + if module: + method_func = getattr(module, request.method.lower(), None) + if callable(method_func) and inspect.iscoroutinefunction(method_func): + is_async_controller = True + elif request.method == "POST": + action_name = ( + request.form.get("_action") + or request.args.get("_action") + or request.args.get("action") + ) + if action_name: + action_func = getattr(module, f"action_{action_name}", None) + if callable(action_func) and inspect.iscoroutinefunction(action_func): + is_async_controller = True + if not is_async_controller and hasattr(module, "render") and inspect.iscoroutinefunction(module.render): + is_async_controller = True + + has_async_middleware = any(inspect.iscoroutinefunction(mw) for mw in self.middleware_handlers) + + if has_async_middleware or is_async_controller: + chain = self._get_async_middleware_chain(core_layer) + from .asgi import async_to_sync + with request_context(request): + coro = chain(request) + content_str = async_to_sync(coro) + else: + chain = self._get_middleware_chain(core_layer) + with request_context(request): + content_str = chain(request) status_code = request.status.split(" ")[0] is_default_error = False @@ -539,38 +590,8 @@ def _file_iter(path, chunk_size=65536): start_response(request.status, headers) return [b""] if is_head else [output] - if inspect.isgenerator(content_str): - headers = [("Content-Type", request.content_type)] - headers += self._cookie_headers(request, environ) - headers += self._security_headers( - request=request, nonce=getattr(request, "nonce", None) - ) - - use_gzip = ( - self.config.get("GZIP", False) - and "gzip" in environ.get("HTTP_ACCEPT_ENCODING", "").lower() - ) - if use_gzip: - headers.append(("Content-Encoding", "gzip")) - start_response(request.status, headers) - return SmartStreamer(content_str, request, self) - - if "text/html" in request.content_type: - content_str = self._inject_assets( - content_str, request, getattr(request, "nonce", None) - ) - - should_minify = self.config.get("HTML_MINIFY") - if should_minify is None: - should_minify = not self.config.get("DEBUG") - - if should_minify: - content_str = minify_html(str(content_str)) - - output = str(content_str).encode("utf-8") - + # Build base headers headers = [("Content-Type", request.content_type)] - headers += self._cookie_headers(request, environ) headers += self._security_headers( request=request, nonce=getattr(request, "nonce", None) @@ -603,6 +624,50 @@ def _file_iter(path, chunk_size=65536): ) ) + if inspect.isgenerator(content_str): + use_gzip = ( + self.config.get("GZIP", False) + and "gzip" in environ.get("HTTP_ACCEPT_ENCODING", "").lower() + ) + if use_gzip: + headers.append(("Content-Encoding", "gzip")) + headers.append(("Vary", "Accept-Encoding")) + + block_header = environ.get("HTTP_X_BLOCK") + if block_header: + headers.append(("X-Asok-Blocks", block_header)) + # Update Access-Control-Expose-Headers to expose X-Asok-Blocks + exposed = [h[1] for h in headers if h[0] == "Access-Control-Expose-Headers"] + if exposed: + headers = [ + h for h in headers if h[0] != "Access-Control-Expose-Headers" + ] + headers.append( + ( + "Access-Control-Expose-Headers", + f"{exposed[0]}, X-Asok-Blocks", + ) + ) + else: + headers.append(("Access-Control-Expose-Headers", "X-Asok-Blocks")) + + start_response(request.status, headers) + return SmartStreamer(content_str, request, self) + + if "text/html" in request.content_type: + content_str = self._inject_assets( + content_str, request, getattr(request, "nonce", None) + ) + + should_minify = self.config.get("HTML_MINIFY") + if should_minify is None: + should_minify = not self.config.get("DEBUG") + + if should_minify: + content_str = minify_html(str(content_str)) + + output = str(content_str).encode("utf-8") + if ( self.config.get("GZIP", False) and len(output) > self.config.get("GZIP_MIN_SIZE", 500) @@ -662,7 +727,7 @@ def _file_iter(path, chunk_size=65536): start_response(request.status, headers) return [b""] if is_head else [output] - def __call__( + def _wsgi_call( self, environ: dict[str, Any], start_response: Callable ) -> list[bytes]: """Main WSGI entry point for the Asok framework.""" @@ -784,11 +849,18 @@ def __call__( "500 Internal Server Error", [("Content-Type", "text/html; charset=utf-8")], ) - return [error_page.encode("utf-8") if isinstance(error_page, str) else error_page] + return [ + error_page.encode("utf-8") + if isinstance(error_page, str) + else error_page + ] # Finalize Response return self._finalize_response( request, result, environ, is_head, start_response ) finally: + from ..orm import close_all_db_connections + + close_all_db_connections() request_var.reset(token) diff --git a/asok/forms/field.py b/asok/forms/field.py index 64989c4..9722b7b 100644 --- a/asok/forms/field.py +++ b/asok/forms/field.py @@ -24,7 +24,8 @@ def __init__( ): # SECURITY: Validate field name to prevent injection in HTML attributes import re - if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name): + + if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name): raise ValueError( f"Invalid field name '{name}': must start with letter/underscore " f"and contain only alphanumeric characters and underscores" diff --git a/asok/forms/render.py b/asok/forms/render.py index 8e7efc2..731c104 100644 --- a/asok/forms/render.py +++ b/asok/forms/render.py @@ -219,7 +219,7 @@ def render_image(field: Any, val: str, merged: dict[str, Any]) -> str: preview_attrs["alt"] = "Preview" preview_attrs["asok-cloak"] = True - html_out += f'
' + html_out += f"
" html_out += "" else: # No preview - just a simple file input @@ -268,9 +268,7 @@ def render_tags(field: Any, val: str, merged: dict[str, Any]) -> str: selected_tags = [ {"value": v, "label": current_labels.get(v, v)} for v in current_values ] - state = html_safe_json( - {"selected": selected_tags, "open": False, "search": ""} - ) + state = html_safe_json({"selected": selected_tags, "open": False, "search": ""}) container_attrs = _extract_nested_attrs(merged, "container") container_class = container_attrs.get("class", "") @@ -302,33 +300,31 @@ def render_tags(field: Any, val: str, merged: dict[str, Any]) -> str: html_out = f'' # Display selected tags - html_out += f' ' + html_out += f" " html_out += ' " # Add button add_attrs["type"] = "button" add_attrs["asok-on:click"] = "open = !open" - html_out += f' + Add' + html_out += f" + Add" html_out += " " # Dropdown menu with options menu_attrs["asok-show"] = "open" menu_attrs["asok-on:click.outside"] = "open = false" menu_attrs["asok-cloak"] = True - html_out += f' ' + html_out += f" " if searchable: search_attrs["type"] = "text" search_attrs["asok-model"] = "search" search_attrs["placeholder"] = "Search..." search_attrs["asok-on:keydown.escape"] = "open = false" - html_out += f' ' + html_out += f" " html_out += '
' for opt in available_options: @@ -345,7 +341,7 @@ def render_tags(field: Any, val: str, merged: dict[str, Any]) -> str: option_attrs["asok-show"] = f"{search_cond} && !{already_selected}" option_attrs["asok-on:click"] = click_action - html_out += f' {esc(opt["label"])}
' + html_out += f" {esc(opt['label'])}" html_out += " " html_out += " " @@ -406,7 +402,7 @@ def render_daterange(field: Any, val: str, merged: dict[str, Any]) -> str: label_attrs = dict(label_attrs_base) label_attrs["class"] = f"asok-daterange-label {label_class_base}".strip() - html_out += f' {esc(start_label)}' + html_out += f" {esc(start_label)}" start_attrs = { "type": "date", @@ -418,7 +414,9 @@ def render_daterange(field: Any, val: str, merged: dict[str, Any]) -> str: start_attrs["min"] = datetime.date.today().isoformat() # Use asok-model for two-way binding and update hidden input - update_hidden = f"Asok.updateHiddenJson($refs.hidden_{field.name}, {{'start':start,'end':end}})" + update_hidden = ( + f"Asok.updateHiddenJson($refs.hidden_{field.name}, {{'start':start,'end':end}})" + ) html_out += f' ' html_out += " " @@ -429,7 +427,7 @@ def render_daterange(field: Any, val: str, merged: dict[str, Any]) -> str: label_attrs = dict(label_attrs_base) label_attrs["class"] = f"asok-daterange-label {label_class_base}".strip() - html_out += f' {esc(end_label)}' + html_out += f" {esc(end_label)}" end_attrs = { "type": "date", @@ -512,12 +510,15 @@ def render_otp(field: Any, val: str, merged: dict[str, Any]) -> str: "asok-model": f"digits[{i}]", } input_attrs["class"] = f"asok-otp-input {input_class_base}".strip() - # Auto-focus next input on keyup - next_focus = "Asok.handleOtpKeyup($event)" - html_out += f'' - - # Hidden input to store the complete OTP. Bound to the reactive 'digits' array. - html_out += f'' + # Update hidden value on input, auto-focus next on keyup + input_attrs["asok-on:input"] = f"Asok.updateHiddenValue($refs.hidden_{field.name}, digits.join(''))" + input_attrs["asok-on:keyup"] = "Asok.handleOtpKeyup($event)" + html_out += f'' + + # Hidden input to store the complete OTP. + # Value is updated imperatively via Asok.updateHiddenValue() on each input/keyup + current_otp = current_value[:length] + html_out += f'' html_out += "" return html_out @@ -595,7 +596,9 @@ def render_timerange(field: Any, val: str, merged: dict[str, Any]) -> str: input_attrs_base = _extract_nested_attrs(merged, "input") input_class_base = input_attrs_base.get("class", "") - update_hidden = f"Asok.updateHiddenJson($refs.hidden_{field.name}, {{'start':start,'end':end}})" + update_hidden = ( + f"Asok.updateHiddenJson($refs.hidden_{field.name}, {{'start':start,'end':end}})" + ) # Start time input field_attrs = dict(field_attrs_base) @@ -693,18 +696,18 @@ def render_files(field: Any, val: str, merged: dict[str, Any]) -> str: html_out += f'' if preview_enabled: - html_out += f' ' + html_out += f" " html_out += ' " html_out += "" @@ -756,7 +759,7 @@ def render_autocomplete(field: Any, val: str, merged: dict[str, Any]) -> str: # Suggestions dropdown menu_attrs["asok-show"] = "show && filtered.length > 0" menu_attrs["asok-cloak"] = True - html_out += f'' + html_out += f"" html_out += ' " html_out += "" @@ -826,7 +829,7 @@ def render_cascading(field: Any, val: str, merged: dict[str, Any]) -> str: child_opt_attrs = dict(option_attrs) child_opt_attrs["asok-bind:value"] = "option" child_opt_attrs["asok-text"] = "option" - html_out += f'' + html_out += f"" html_out += "" html_out += "" @@ -862,6 +865,7 @@ def render_phone(field: Any, val: str, merged: dict[str, Any]) -> str: # Country code select select_attrs["asok-model"] = "code" + select_attrs["asok-on:change"] = f"Asok.updateHiddenValue($refs.hidden_{field.name}, code+number)" html_out += f"" for code, dial, name, *rest in countries: selected = " selected" if code == default_country else "" @@ -878,10 +882,10 @@ def render_phone(field: Any, val: str, merged: dict[str, Any]) -> str: if "placeholder" not in input_attrs: input_attrs["placeholder"] = "Phone number" - html_out += f'' + html_out += f"" # Hidden input to store complete phone - html_out += f'' + html_out += f'' html_out += "" return html_out @@ -919,37 +923,37 @@ def render_wysiwyg(field: Any, val: str, merged: dict[str, Any]) -> str: bold_btn["class"] = f"asok-wysiwyg-btn-bold {btn_class_base}".strip() bold_btn["type"] = "button" bold_btn["asok-on:click"] = "document.execCommand('bold')" - html_out += f'B' + html_out += f"B" italic_btn = dict(btn_attrs_base) italic_btn["class"] = f"asok-wysiwyg-btn-italic {btn_class_base}".strip() italic_btn["type"] = "button" italic_btn["asok-on:click"] = "document.execCommand('italic')" - html_out += f'I' + html_out += f"I" under_btn = dict(btn_attrs_base) under_btn["class"] = f"asok-wysiwyg-btn-underline {btn_class_base}".strip() under_btn["type"] = "button" under_btn["asok-on:click"] = "document.execCommand('underline')" - html_out += f'U' + html_out += f"U" list_btn = dict(btn_attrs_base) list_btn["class"] = f"asok-wysiwyg-btn-list {btn_class_base}".strip() list_btn["type"] = "button" list_btn["asok-on:click"] = "document.execCommand('insertUnorderedList')" - html_out += f'• List' + html_out += f"• List" html_out += "" # Editor (contenteditable div) update_hidden = f"Asok.updateWysiwyg($event, $, $refs.hidden_{field.name})" editor_style = f"min-height:{height}px;border:1px solid #ddd;padding:10px;" if "style" in editor_attrs: - editor_style = f"{editor_style} {editor_attrs['style']}".strip() + editor_style = f"{editor_style} {editor_attrs['style']}".strip() editor_attrs["style"] = editor_style editor_attrs["contenteditable"] = "true" editor_attrs["asok-on:input"] = update_hidden - html_out += f'{esc(current_content)}' + html_out += f"{esc(current_content)}" # Hidden input to store HTML html_out += f'' @@ -971,7 +975,9 @@ def render_dropzone(field: Any, val: str, merged: dict[str, Any]) -> str: area_attrs["class"] = f"asok-dropzone-area {area_class}".strip() area_style = area_attrs.get("style", "") if not area_style: - area_attrs["style"] = "border:2px dashed #ccc;padding:40px;text-align:center;cursor:pointer;" + area_attrs["style"] = ( + "border:2px dashed #ccc;padding:40px;text-align:center;cursor:pointer;" + ) input_attrs = _extract_nested_attrs(merged, "input") input_class = input_attrs.get("class", "") @@ -991,13 +997,15 @@ def render_dropzone(field: Any, val: str, merged: dict[str, Any]) -> str: html_out = f'' # Drop zone div - copy exact syntax from working 'files' component - drop_handler = f"Asok.handleDropzoneDrop($event, $, {max_files}, $refs.input_{field.name})" + drop_handler = ( + f"Asok.handleDropzoneDrop($event, $, {max_files}, $refs.input_{field.name})" + ) area_attrs["asok-on:dragover.prevent"] = "dragging=true" area_attrs["asok-on:dragleave"] = "dragging=false" area_attrs["asok-on:drop.prevent"] = drop_handler area_attrs["asok-bind:class"] = "dragging?'dragging':''" - html_out += f'' + html_out += f"" html_out += f'

Drag & drop files here or

' html_out += "" @@ -1017,18 +1025,22 @@ def render_dropzone(field: Any, val: str, merged: dict[str, Any]) -> str: html_out += f'' # File list - html_out += f' ' + html_out += f" " html_out += ' " html_out += "" @@ -1053,7 +1065,9 @@ def render_signature(field: Any, val: str, merged: dict[str, Any]) -> str: canvas_attrs["class"] = canvas_class.strip() canvas_style = canvas_attrs.get("style", "") if not canvas_style: - canvas_attrs["style"] = "border:1px solid #ccc;cursor:crosshair;touch-action:none;" + canvas_attrs["style"] = ( + "border:1px solid #ccc;cursor:crosshair;touch-action:none;" + ) btn_attrs = _extract_nested_attrs(merged, "btn") btn_class = btn_attrs.get("class", "") @@ -1068,7 +1082,7 @@ def render_signature(field: Any, val: str, merged: dict[str, Any]) -> str: canvas_attrs["height"] = height canvas_attrs["asok-ref"] = f"canvas_{field.name}" - # Handlers pour le dessin + # Handlers for drawing mousedown = f"Asok.startSignatureDrawing($event, $, $refs.canvas_{field.name})" mousemove = f"Asok.drawSignature($event, $, $refs.canvas_{field.name})" mouseup = f"Asok.stopSignatureDrawing($, $refs.canvas_{field.name}, $refs.hidden_{field.name})" @@ -1082,12 +1096,12 @@ def render_signature(field: Any, val: str, merged: dict[str, Any]) -> str: html_out += f"" # Clear button - clear_handler = f"Asok.clearSignature($refs.canvas_{field.name}, $refs.hidden_{field.name})" + clear_handler = ( + f"Asok.clearSignature($refs.canvas_{field.name}, $refs.hidden_{field.name})" + ) btn_attrs["type"] = "button" btn_attrs["asok-on:click"] = clear_handler - html_out += ( - f'
Clear' - ) + html_out += f"
Clear" # Hidden input to store base64 signature html_out += f'' @@ -1210,18 +1224,14 @@ def render_treeselect(field: Any, val: str, merged: dict[str, Any]) -> str: item_wrapper_attrs["style"] = "margin:2px 0;" html_out += f" " - html_out += ( - f'
' - ) + html_out += f'
' html_out += " 0 ? (expanded.includes(item.id) ? '▾' : '▸') : '•'\">" html_out += ' ' html_out += "
" html_out += '