quantum-framework 0.18.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantum/__init__.py +1 -0
- quantum/cli/__init__.py +2 -0
- quantum/cli/admin.py +131 -0
- quantum/cli/check.py +223 -0
- quantum/cli/commands/__init__.py +15 -0
- quantum/cli/commands/build.py +414 -0
- quantum/cli/commands/dev.py +185 -0
- quantum/cli/commands/docs.py +329 -0
- quantum/cli/commands/lint.py +523 -0
- quantum/cli/commands/migrate.py +548 -0
- quantum/cli/commands/new.py +622 -0
- quantum/cli/commands/serve.py +190 -0
- quantum/cli/commands/test.py +193 -0
- quantum/cli/deploy.py +810 -0
- quantum/cli/hot_reload.py +951 -0
- quantum/cli/jobs.py +356 -0
- quantum/cli/mq.py +582 -0
- quantum/cli/pkg.py +390 -0
- quantum/cli/runner.py +565 -0
- quantum/cli/server_process.py +159 -0
- quantum/cli/utils.py +334 -0
- quantum/compiler/__init__.py +30 -0
- quantum/compiler/base_generator.py +367 -0
- quantum/compiler/cli.py +295 -0
- quantum/compiler/expression_transformer.py +444 -0
- quantum/compiler/javascript/__init__.py +10 -0
- quantum/compiler/javascript/generator.py +659 -0
- quantum/compiler/optimizer.py +270 -0
- quantum/compiler/python/__init__.py +10 -0
- quantum/compiler/python/generator.py +883 -0
- quantum/compiler/python/runtime.py +863 -0
- quantum/compiler/transpiler.py +330 -0
- quantum/core/__init__.py +3 -0
- quantum/core/ast_nodes.py +2619 -0
- quantum/core/config_env.py +54 -0
- quantum/core/docs_links.py +8 -0
- quantum/core/expression_diagnostics.py +87 -0
- quantum/core/expression_stdlib.py +208 -0
- quantum/core/expressions.py +677 -0
- quantum/core/features/agents/src/__init__.py +30 -0
- quantum/core/features/agents/src/ast_node.py +540 -0
- quantum/core/features/conditionals/src/__init__.py +8 -0
- quantum/core/features/conditionals/src/ast_node.py +68 -0
- quantum/core/features/data_import/src/__init__.py +0 -0
- quantum/core/features/data_import/src/ast_node.py +292 -0
- quantum/core/features/data_import/src/runtime.py +533 -0
- quantum/core/features/dump/src/__init__.py +12 -0
- quantum/core/features/dump/src/ast_node.py +106 -0
- quantum/core/features/dump/src/parser.py +61 -0
- quantum/core/features/dump/src/runtime.py +246 -0
- quantum/core/features/functions/src/__init__.py +8 -0
- quantum/core/features/functions/src/ast_node.py +149 -0
- quantum/core/features/game_engine_2d/src/__init__.py +19 -0
- quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
- quantum/core/features/game_engine_2d/src/parser.py +983 -0
- quantum/core/features/invocation/src/__init__.py +0 -0
- quantum/core/features/invocation/src/ast_node.py +147 -0
- quantum/core/features/invocation/src/runtime.py +332 -0
- quantum/core/features/knowledge_base/src/__init__.py +6 -0
- quantum/core/features/knowledge_base/src/ast_node.py +113 -0
- quantum/core/features/knowledge_base/src/parser.py +82 -0
- quantum/core/features/logging/src/__init__.py +12 -0
- quantum/core/features/logging/src/ast_node.py +111 -0
- quantum/core/features/logging/src/parser.py +50 -0
- quantum/core/features/logging/src/runtime.py +190 -0
- quantum/core/features/loops/src/__init__.py +8 -0
- quantum/core/features/loops/src/ast_node.py +60 -0
- quantum/core/features/query/src/__init__.py +0 -0
- quantum/core/features/query/src/query_validators.py +20 -0
- quantum/core/features/state_management/src/__init__.py +11 -0
- quantum/core/features/state_management/src/ast_node.py +121 -0
- quantum/core/features/terminal_engine/src/__init__.py +21 -0
- quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
- quantum/core/features/terminal_engine/src/parser.py +361 -0
- quantum/core/features/testing_engine/src/__init__.py +41 -0
- quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
- quantum/core/features/testing_engine/src/parser.py +604 -0
- quantum/core/features/theming/src/__init__.py +48 -0
- quantum/core/features/theming/src/ast_node.py +137 -0
- quantum/core/features/theming/src/presets.py +405 -0
- quantum/core/features/ui_engine/src/__init__.py +20 -0
- quantum/core/features/ui_engine/src/ast_nodes.py +1913 -0
- quantum/core/features/ui_engine/src/parser.py +1192 -0
- quantum/core/features/websocket/src/__init__.py +24 -0
- quantum/core/features/websocket/src/ast_node.py +247 -0
- quantum/core/guards.py +53 -0
- quantum/core/html_compat.py +299 -0
- quantum/core/parser.py +1551 -0
- quantum/core/parser_registry.py +213 -0
- quantum/core/parsers/__init__.py +76 -0
- quantum/core/parsers/ai/__init__.py +12 -0
- quantum/core/parsers/ai/agent_parser.py +106 -0
- quantum/core/parsers/ai/knowledge_parser.py +86 -0
- quantum/core/parsers/ai/llm_parser.py +78 -0
- quantum/core/parsers/ai/team_parser.py +88 -0
- quantum/core/parsers/base.py +322 -0
- quantum/core/parsers/composition/__init__.py +10 -0
- quantum/core/parsers/composition/import_parser.py +50 -0
- quantum/core/parsers/composition/slot_parser.py +48 -0
- quantum/core/parsers/control_flow/__init__.py +11 -0
- quantum/core/parsers/control_flow/if_parser.py +71 -0
- quantum/core/parsers/control_flow/loop_parser.py +105 -0
- quantum/core/parsers/control_flow/set_parser.py +93 -0
- quantum/core/parsers/data/__init__.py +12 -0
- quantum/core/parsers/data/data_parser.py +229 -0
- quantum/core/parsers/data/invoke_parser.py +133 -0
- quantum/core/parsers/data/query_parser.py +213 -0
- quantum/core/parsers/data/transaction_parser.py +86 -0
- quantum/core/parsers/events/__init__.py +9 -0
- quantum/core/parsers/events/dispatch_event_parser.py +44 -0
- quantum/core/parsers/forms/__init__.py +11 -0
- quantum/core/parsers/forms/action_parser.py +79 -0
- quantum/core/parsers/forms/flash_parser.py +34 -0
- quantum/core/parsers/forms/redirect_parser.py +33 -0
- quantum/core/parsers/functions/__init__.py +11 -0
- quantum/core/parsers/functions/function_parser.py +98 -0
- quantum/core/parsers/functions/param_parser.py +73 -0
- quantum/core/parsers/functions/return_parser.py +31 -0
- quantum/core/parsers/html/__init__.py +10 -0
- quantum/core/parsers/html/component_call_parser.py +130 -0
- quantum/core/parsers/html/html_parser.py +115 -0
- quantum/core/parsers/jobs/__init__.py +11 -0
- quantum/core/parsers/jobs/job_parser.py +71 -0
- quantum/core/parsers/jobs/schedule_parser.py +62 -0
- quantum/core/parsers/jobs/thread_parser.py +57 -0
- quantum/core/parsers/messaging/__init__.py +17 -0
- quantum/core/parsers/messaging/message_ack_parser.py +30 -0
- quantum/core/parsers/messaging/message_nack_parser.py +30 -0
- quantum/core/parsers/messaging/message_parser.py +114 -0
- quantum/core/parsers/messaging/queue_parser.py +61 -0
- quantum/core/parsers/messaging/websocket_parser.py +121 -0
- quantum/core/parsers/routing/__init__.py +9 -0
- quantum/core/parsers/routing/route_parser.py +41 -0
- quantum/core/parsers/scripting/__init__.py +12 -0
- quantum/core/parsers/scripting/pyclass_parser.py +62 -0
- quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
- quantum/core/parsers/scripting/pyimport_parser.py +52 -0
- quantum/core/parsers/scripting/python_parser.py +49 -0
- quantum/core/parsers/services/__init__.py +12 -0
- quantum/core/parsers/services/dump_parser.py +52 -0
- quantum/core/parsers/services/file_parser.py +48 -0
- quantum/core/parsers/services/log_parser.py +46 -0
- quantum/core/parsers/services/mail_parser.py +65 -0
- quantum/core/tiers.py +164 -0
- quantum/core/xml_lines.py +81 -0
- quantum/packages/__init__.py +28 -0
- quantum/packages/manager.py +413 -0
- quantum/packages/manifest.py +351 -0
- quantum/packages/registry.py +399 -0
- quantum/packages/resolver.py +336 -0
- quantum/plugins/__init__.py +33 -0
- quantum/plugins/hooks.py +329 -0
- quantum/plugins/loader.py +479 -0
- quantum/plugins/manifest.py +336 -0
- quantum/plugins/registry.py +371 -0
- quantum/runtime/__init__.py +25 -0
- quantum/runtime/action_handler.py +532 -0
- quantum/runtime/adapters/__init__.py +88 -0
- quantum/runtime/adapters/memory_adapter.py +690 -0
- quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
- quantum/runtime/adapters/redis_adapter.py +582 -0
- quantum/runtime/adapters/sqlite_adapter.py +414 -0
- quantum/runtime/agent_service.py +1133 -0
- quantum/runtime/ast_cache.py +506 -0
- quantum/runtime/auth_service.py +267 -0
- quantum/runtime/component.py +1048 -0
- quantum/runtime/component_composer.py +133 -0
- quantum/runtime/component_resolver.py +174 -0
- quantum/runtime/conditions.py +54 -0
- quantum/runtime/database_service.py +595 -0
- quantum/runtime/db_schema.py +100 -0
- quantum/runtime/dev_panel.py +178 -0
- quantum/runtime/email_service.py +162 -0
- quantum/runtime/error_handler.py +295 -0
- quantum/runtime/execution_context.py +283 -0
- quantum/runtime/executor_registry.py +178 -0
- quantum/runtime/executors/__init__.py +71 -0
- quantum/runtime/executors/ai/__init__.py +12 -0
- quantum/runtime/executors/ai/agent_executor.py +217 -0
- quantum/runtime/executors/ai/knowledge_executor.py +114 -0
- quantum/runtime/executors/ai/llm_executor.py +153 -0
- quantum/runtime/executors/ai/team_executor.py +171 -0
- quantum/runtime/executors/base.py +264 -0
- quantum/runtime/executors/control_flow/__init__.py +12 -0
- quantum/runtime/executors/control_flow/if_executor.py +98 -0
- quantum/runtime/executors/control_flow/loop_executor.py +308 -0
- quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
- quantum/runtime/executors/control_flow/set_executor.py +464 -0
- quantum/runtime/executors/data/__init__.py +12 -0
- quantum/runtime/executors/data/data_executor.py +156 -0
- quantum/runtime/executors/data/invoke_executor.py +228 -0
- quantum/runtime/executors/data/query_executor.py +290 -0
- quantum/runtime/executors/data/transaction_executor.py +91 -0
- quantum/runtime/executors/jobs/__init__.py +11 -0
- quantum/runtime/executors/jobs/job_executor.py +190 -0
- quantum/runtime/executors/jobs/schedule_executor.py +132 -0
- quantum/runtime/executors/jobs/thread_executor.py +127 -0
- quantum/runtime/executors/messaging/__init__.py +17 -0
- quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
- quantum/runtime/executors/messaging/message_executor.py +174 -0
- quantum/runtime/executors/messaging/queue_executor.py +103 -0
- quantum/runtime/executors/messaging/websocket_executor.py +197 -0
- quantum/runtime/executors/scripting/__init__.py +11 -0
- quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
- quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
- quantum/runtime/executors/scripting/python_executor.py +249 -0
- quantum/runtime/executors/services/__init__.py +12 -0
- quantum/runtime/executors/services/dump_executor.py +72 -0
- quantum/runtime/executors/services/file_executor.py +89 -0
- quantum/runtime/executors/services/log_executor.py +77 -0
- quantum/runtime/executors/services/mail_executor.py +81 -0
- quantum/runtime/expression_cache.py +498 -0
- quantum/runtime/file_upload_service.py +326 -0
- quantum/runtime/function_registry.py +118 -0
- quantum/runtime/game_builder.py +166 -0
- quantum/runtime/game_code_generator.py +2371 -0
- quantum/runtime/game_templates.py +2006 -0
- quantum/runtime/godot_code_generator.py +4681 -0
- quantum/runtime/godot_templates.py +1449 -0
- quantum/runtime/job_executor.py +1599 -0
- quantum/runtime/knowledge_service.py +546 -0
- quantum/runtime/llm_cache.py +100 -0
- quantum/runtime/llm_providers.py +704 -0
- quantum/runtime/llm_service.py +287 -0
- quantum/runtime/local_server.py +28 -0
- quantum/runtime/logging_setup.py +141 -0
- quantum/runtime/message_broker.py +364 -0
- quantum/runtime/message_queue_service.py +571 -0
- quantum/runtime/param_validation.py +218 -0
- quantum/runtime/pypy_compat.py +317 -0
- quantum/runtime/python_bridge.py +698 -0
- quantum/runtime/query_validators.py +304 -0
- quantum/runtime/renderer.py +734 -0
- quantum/runtime/service_container.py +470 -0
- quantum/runtime/table_params.py +163 -0
- quantum/runtime/terminal_builder.py +76 -0
- quantum/runtime/terminal_code_generator.py +607 -0
- quantum/runtime/terminal_templates.py +243 -0
- quantum/runtime/testing_builder.py +77 -0
- quantum/runtime/testing_code_generator.py +833 -0
- quantum/runtime/testing_templates.py +85 -0
- quantum/runtime/ui_builder.py +222 -0
- quantum/runtime/ui_console.py +405 -0
- quantum/runtime/ui_desktop.py +56 -0
- quantum/runtime/ui_form_rules.py +193 -0
- quantum/runtime/ui_html_adapter.py +2567 -0
- quantum/runtime/ui_html_templates.py +2106 -0
- quantum/runtime/ui_mobile_adapter.py +1832 -0
- quantum/runtime/ui_mobile_templates.py +1003 -0
- quantum/runtime/ui_page_renderer.py +392 -0
- quantum/runtime/ui_pager.py +73 -0
- quantum/runtime/ui_table_edit.py +139 -0
- quantum/runtime/ui_textual_adapter.py +1866 -0
- quantum/runtime/ui_textual_templates.py +45 -0
- quantum/runtime/ui_tokens.py +465 -0
- quantum/runtime/ui_validator.py +365 -0
- quantum/runtime/ui_view.py +221 -0
- quantum/runtime/validators.py +256 -0
- quantum/runtime/web_server.py +2131 -0
- quantum/runtime/websocket_adapter.py +501 -0
- quantum/runtime/websocket_service.py +585 -0
- quantum/runtime/websocket_transport.py +289 -0
- quantum/runtime/wsgi.py +101 -0
- quantum/services.py +109 -0
- quantum/utils/__init__.py +1 -0
- quantum_admin/__init__.py +4 -0
- quantum_admin/assets/quantum-admin-connectors.js +123 -0
- quantum_admin/assets/quantum-admin.css +2058 -0
- quantum_admin/assets/quantum-source-highlight.js +106 -0
- quantum_admin/backend/__init__.py +4 -0
- quantum_admin/backend/audit_service.py +346 -0
- quantum_admin/backend/auth_service.py +394 -0
- quantum_admin/backend/component_discovery.py +766 -0
- quantum_admin/backend/config_generator.py +586 -0
- quantum_admin/backend/connector_service.py +1016 -0
- quantum_admin/backend/crud.py +557 -0
- quantum_admin/backend/database.py +226 -0
- quantum_admin/backend/db_setup_service.py +193 -0
- quantum_admin/backend/deploy_service.py +698 -0
- quantum_admin/backend/docker_service.py +422 -0
- quantum_admin/backend/environment_service.py +418 -0
- quantum_admin/backend/git_service.py +370 -0
- quantum_admin/backend/health_service.py +390 -0
- quantum_admin/backend/job_service.py +347 -0
- quantum_admin/backend/log_service.py +331 -0
- quantum_admin/backend/main.py +14426 -0
- quantum_admin/backend/models.py +986 -0
- quantum_admin/backend/pipeline_service.py +812 -0
- quantum_admin/backend/resource_manager.py +1118 -0
- quantum_admin/backend/schemas.py +419 -0
- quantum_admin/backend/secret_manager.py +277 -0
- quantum_admin/backend/settings_service.py +488 -0
- quantum_admin/backend/template_service.py +710 -0
- quantum_admin/backend/test_execution_service.py +364 -0
- quantum_admin/backend/test_generator.py +782 -0
- quantum_admin/backend/webhook_service.py +454 -0
- quantum_admin/backend/websocket_manager.py +360 -0
- quantum_admin/components/admin/_layout/AdminShell.q +121 -0
- quantum_admin/components/admin/agents.q +107 -0
- quantum_admin/components/admin/app/[name].q +558 -0
- quantum_admin/components/admin/applications.q +160 -0
- quantum_admin/components/admin/component/[...path].q +205 -0
- quantum_admin/components/admin/components.q +59 -0
- quantum_admin/components/admin/connectors.q +283 -0
- quantum_admin/components/admin/dashboard.q +86 -0
- quantum_admin/components/admin/database.q +95 -0
- quantum_admin/components/admin/features.q +69 -0
- quantum_admin/components/admin/index.q +12 -0
- quantum_admin/components/admin/jobs.q +75 -0
- quantum_admin/components/admin/login.q +65 -0
- quantum_admin/components/admin/logout.q +11 -0
- quantum_admin/components/admin/settings.q +174 -0
- quantum_admin/components/admin/source.q +45 -0
- quantum_admin/components/admin/tests.q +73 -0
- quantum_admin/run.py +13 -0
- quantum_admin/services/__init__.py +16 -0
- quantum_admin/services/_base.py +84 -0
- quantum_admin/services/_component_test_generator.py +205 -0
- quantum_admin/services/_yaml_editor.py +82 -0
- quantum_admin/services/apps.py +330 -0
- quantum_admin/services/auth.py +61 -0
- quantum_admin/services/components.py +210 -0
- quantum_admin/services/connectors.py +164 -0
- quantum_admin/services/projects.py +189 -0
- quantum_admin/services/repository.py +223 -0
- quantum_admin/services/settings.py +150 -0
- quantum_admin/services/yaml_import.py +118 -0
- quantum_framework-0.18.0.dist-info/METADATA +281 -0
- quantum_framework-0.18.0.dist-info/RECORD +333 -0
- quantum_framework-0.18.0.dist-info/WHEEL +5 -0
- quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
- quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
- quantum_framework-0.18.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,2131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quantum Web Server - Serves .q components as HTML pages
|
|
3
|
+
|
|
4
|
+
ColdFusion-style simplicity: Just run `quantum start` and it works! 🪄
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
from html import escape as html_escape
|
|
10
|
+
import re
|
|
11
|
+
import signal
|
|
12
|
+
import socket
|
|
13
|
+
import time
|
|
14
|
+
import hashlib
|
|
15
|
+
import yaml
|
|
16
|
+
import logging
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from flask import Flask, Response, request, send_from_directory, render_template_string, session, redirect, abort
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Dict, Any, List, Optional
|
|
21
|
+
import secrets
|
|
22
|
+
|
|
23
|
+
# Fix imports
|
|
24
|
+
|
|
25
|
+
from quantum.core.parser import QuantumParser, QuantumParseError
|
|
26
|
+
from quantum.core.ast_nodes import ActionNode
|
|
27
|
+
from werkzeug.exceptions import HTTPException
|
|
28
|
+
from quantum.runtime.component import ComponentRuntime
|
|
29
|
+
from quantum.runtime.executors.control_flow.redirect_executor import PageRedirect
|
|
30
|
+
from quantum.runtime.renderer import HTMLRenderer
|
|
31
|
+
from quantum.runtime.execution_context import ExecutionContext
|
|
32
|
+
from quantum.runtime.action_handler import ActionHandler
|
|
33
|
+
from quantum.runtime.auth_service import AuthService, AuthorizationError
|
|
34
|
+
from quantum.runtime.error_handler import ErrorHandler, QuantumError
|
|
35
|
+
from quantum.runtime.dev_panel import note as dev_note, note_scopes as dev_scopes
|
|
36
|
+
# Imported lazily in methods to avoid circular import via runtime/__init__.py
|
|
37
|
+
_logging_setup = None
|
|
38
|
+
|
|
39
|
+
def _get_logging_setup():
|
|
40
|
+
global _logging_setup
|
|
41
|
+
if _logging_setup is None:
|
|
42
|
+
from quantum.runtime import logging_setup as _mod
|
|
43
|
+
_logging_setup = _mod
|
|
44
|
+
return _logging_setup
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class DynamicRoute:
|
|
49
|
+
"""A file-based route containing [param] segments."""
|
|
50
|
+
pattern: re.Pattern
|
|
51
|
+
file_path: Path
|
|
52
|
+
param_names: List[str]
|
|
53
|
+
static_segment_count: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class UnknownActionError(Exception):
|
|
58
|
+
"""A POST named no q:action, or one that does not exist on the page."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ConfigError(Exception):
|
|
62
|
+
"""quantum.config.yaml is unreadable or invalid."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Top-level sections the engine understands. A typo like `servr:` used to be
|
|
66
|
+
# accepted in silence, so the whole section was ignored.
|
|
67
|
+
_KNOWN_SECTIONS = {
|
|
68
|
+
'server', 'paths', 'defaults', 'datasources', 'database', 'llm',
|
|
69
|
+
'performance', 'security', 'logging', 'development', 'deploy',
|
|
70
|
+
'components', 'jobs', 'messaging', 'services',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# The keys each of these sections actually has; anything else is a warning.
|
|
74
|
+
_KNOWN_KEYS = {
|
|
75
|
+
'security': {'secret_key', 'login_url', 'python_scripting', 'max_content_length',
|
|
76
|
+
'xss_protection'},
|
|
77
|
+
'performance': {'cache_templates', 'cache_ttl', 'cache_max_size'},
|
|
78
|
+
'server': {'port', 'host', 'debug', 'reload'},
|
|
79
|
+
'paths': {'components', 'static', 'logs', 'uploads', 'migrations'},
|
|
80
|
+
'logging': {'level', 'format', 'console', 'file', 'filename'},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_DATASOURCE_KEYS = {'driver', 'type', 'database', 'host', 'port', 'username', 'user', 'password'}
|
|
84
|
+
|
|
85
|
+
# (section, key) -> expected python type(s)
|
|
86
|
+
_TYPED = {
|
|
87
|
+
('server', 'port'): int,
|
|
88
|
+
('server', 'host'): str,
|
|
89
|
+
('server', 'debug'): bool,
|
|
90
|
+
('server', 'reload'): bool,
|
|
91
|
+
('security', 'python_scripting'): bool,
|
|
92
|
+
('security', 'max_content_length'): int,
|
|
93
|
+
('llm', 'timeout'): int,
|
|
94
|
+
('llm', 'base_url'): str,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _validate_config(config: dict, source: str) -> None:
|
|
99
|
+
"""Fail fast on a bad config instead of at the first request.
|
|
100
|
+
|
|
101
|
+
`port: oitenta` used to be accepted and blew up much later inside
|
|
102
|
+
app.run(); a datasource with no driver failed only when a query ran. The
|
|
103
|
+
boot is where an operator is watching, so that is where it should break.
|
|
104
|
+
"""
|
|
105
|
+
log = logging.getLogger('quantum')
|
|
106
|
+
problems = []
|
|
107
|
+
|
|
108
|
+
for section, key in _TYPED:
|
|
109
|
+
block = config.get(section)
|
|
110
|
+
if not isinstance(block, dict) or key not in block:
|
|
111
|
+
continue
|
|
112
|
+
value = block[key]
|
|
113
|
+
expected = _TYPED[(section, key)]
|
|
114
|
+
# bool is a subclass of int; check it first so True does not pass as a port
|
|
115
|
+
if expected is int and isinstance(value, bool):
|
|
116
|
+
problems.append(f"{section}.{key}: expected a number, got {value!r}")
|
|
117
|
+
elif not isinstance(value, expected):
|
|
118
|
+
problems.append(
|
|
119
|
+
f"{section}.{key}: expected {expected.__name__}, got "
|
|
120
|
+
f"{type(value).__name__} ({value!r})"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
for name, ds in (config.get('datasources') or {}).items():
|
|
124
|
+
if not isinstance(ds, dict):
|
|
125
|
+
problems.append(f"datasources.{name}: expected a mapping")
|
|
126
|
+
continue
|
|
127
|
+
if not ds.get('driver'):
|
|
128
|
+
problems.append(
|
|
129
|
+
f"datasources.{name}: missing 'driver' (sqlite, postgresql, mysql)"
|
|
130
|
+
)
|
|
131
|
+
if not ds.get('database'):
|
|
132
|
+
problems.append(f"datasources.{name}: missing 'database'")
|
|
133
|
+
|
|
134
|
+
login_url = (config.get('security') or {}).get('login_url')
|
|
135
|
+
if login_url is not None and (not isinstance(login_url, str) or not login_url.startswith('/')
|
|
136
|
+
or login_url.startswith('//')):
|
|
137
|
+
# A full URL would make every require_auth page an open redirect (AUTH-4).
|
|
138
|
+
problems.append(f"security.login_url: must be a path on this server, like /login; got {login_url!r}")
|
|
139
|
+
|
|
140
|
+
if problems:
|
|
141
|
+
bullet = "\n - "
|
|
142
|
+
raise ConfigError(
|
|
143
|
+
f"{source} has {len(problems)} problem(s):"
|
|
144
|
+
+ bullet + bullet.join(problems)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Unknown sections are a warning, not an error: a newer config read by an
|
|
148
|
+
# older engine is a real situation, and refusing to boot over it would be
|
|
149
|
+
# worse than saying so.
|
|
150
|
+
# DB-7: a datasource key nothing reads. The blog declared `sqlite_path:`
|
|
151
|
+
# next to `database:`; the engine read `database:` and the sqlite_path was
|
|
152
|
+
# the file with the tables.
|
|
153
|
+
for nome, ds in (config.get('datasources') or {}).items():
|
|
154
|
+
ignoradas = sorted(k for k in (ds or {}) if k not in _DATASOURCE_KEYS) if isinstance(ds, dict) else []
|
|
155
|
+
if ignoradas:
|
|
156
|
+
log.warning("%s: datasources.%s: %s %s not read — the keys are: %s.",
|
|
157
|
+
source, nome, ', '.join(ignoradas), 'is' if len(ignoradas) == 1 else 'are',
|
|
158
|
+
', '.join(sorted(_DATASOURCE_KEYS)))
|
|
159
|
+
|
|
160
|
+
# Keys nothing reads, in sections that promise protection or speed. A
|
|
161
|
+
# `csrf_protection: true` that does nothing is worse than none: whoever
|
|
162
|
+
# wrote it believes the app is protected.
|
|
163
|
+
for secao, conhecidas in _KNOWN_KEYS.items():
|
|
164
|
+
ignoradas = sorted(k for k in (config.get(secao) or {}) if k not in conhecidas)
|
|
165
|
+
if ignoradas:
|
|
166
|
+
log.warning(
|
|
167
|
+
"%s: %s.%s %s not implemented and ignored — nothing enforces %s.",
|
|
168
|
+
source, secao, ', '.join(ignoradas), 'is' if len(ignoradas) == 1 else 'are',
|
|
169
|
+
'it' if len(ignoradas) == 1 else 'them')
|
|
170
|
+
|
|
171
|
+
unknown = [k for k in config if k not in _KNOWN_SECTIONS]
|
|
172
|
+
if unknown:
|
|
173
|
+
log.warning(
|
|
174
|
+
"%s has unrecognised top-level section(s): %s. They are ignored — "
|
|
175
|
+
"check for a typo.", source, ', '.join(sorted(unknown))
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class QuantumWebServer:
|
|
180
|
+
"""
|
|
181
|
+
Quantum Web Server - Magic happens here!
|
|
182
|
+
|
|
183
|
+
Automatically serves .q files as HTML pages.
|
|
184
|
+
No configuration needed (but you can customize via quantum.config.yaml)
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
PID_FILE = '.quantum.pid'
|
|
188
|
+
|
|
189
|
+
def __init__(self, config_path: str = 'quantum.config.yaml', hot_reload: bool = False, hot_reload_port: int = None):
|
|
190
|
+
"""
|
|
191
|
+
Initialize Quantum Web Server.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
config_path: Path to configuration file
|
|
195
|
+
hot_reload: Enable hot reload mode (inject client script)
|
|
196
|
+
hot_reload_port: WebSocket port for hot reload
|
|
197
|
+
"""
|
|
198
|
+
self._start_time = time.monotonic()
|
|
199
|
+
self.config = self._load_config(config_path)
|
|
200
|
+
self.logger = _get_logging_setup().setup_logging(self.config)
|
|
201
|
+
|
|
202
|
+
# Disable Flask's built-in static serving - we use our own serve_static route
|
|
203
|
+
self.app = Flask(__name__, static_folder=None)
|
|
204
|
+
|
|
205
|
+
# Hot reload configuration
|
|
206
|
+
self.hot_reload_enabled = hot_reload
|
|
207
|
+
self.hot_reload_port = hot_reload_port or 35729
|
|
208
|
+
|
|
209
|
+
# Setup secret key for sessions (flash messages)
|
|
210
|
+
# Use env var or config for stable key across Gunicorn workers
|
|
211
|
+
self.app.secret_key = (
|
|
212
|
+
os.environ.get('QUANTUM_SECRET_KEY')
|
|
213
|
+
or self.config.get('security', {}).get('secret_key')
|
|
214
|
+
# DEV-2: while developing, a key that survives the reloader — which
|
|
215
|
+
# is a new process — so saving a file does not log everyone out.
|
|
216
|
+
or (self._dev_secret_key(config_path) if self.config['server'].get('debug') else None)
|
|
217
|
+
or secrets.token_hex(32)
|
|
218
|
+
)
|
|
219
|
+
# AUTH-5: the session cookie carries the login. HttpOnly keeps it from
|
|
220
|
+
# page scripts; SameSite=Lax keeps another site from submitting a form
|
|
221
|
+
# (a q:action) with it. Left to the browser's default before.
|
|
222
|
+
self.app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Lax')
|
|
223
|
+
# CFG-2: security.max_content_length (16 MB by default) was read into
|
|
224
|
+
# the config and never handed to Flask, so a request body — an upload
|
|
225
|
+
# — had no size limit at all.
|
|
226
|
+
self.app.config['MAX_CONTENT_LENGTH'] = (
|
|
227
|
+
(self.config.get('security') or {}).get('max_content_length') or 16 * 1024 * 1024)
|
|
228
|
+
|
|
229
|
+
self.parser = QuantumParser()
|
|
230
|
+
self.template_cache: Dict[str, Any] = {} # AST cache
|
|
231
|
+
# Flash helpers only (read/set the flash in the session). q:action
|
|
232
|
+
# bodies run in a runtime built for each request (ACT-10), never in a
|
|
233
|
+
# shared one.
|
|
234
|
+
self.action_handler = ActionHandler(ComponentRuntime(config=self.config))
|
|
235
|
+
|
|
236
|
+
# Phase F: Application scope (global state shared across all users)
|
|
237
|
+
self.application_scope: Dict[str, Any] = {}
|
|
238
|
+
|
|
239
|
+
# M12 (DEV-1): the /_dev panel records the last requests — only while
|
|
240
|
+
# developing. With debug off nothing is recorded and /_dev is a 404.
|
|
241
|
+
from quantum.runtime.dev_panel import DevPanel
|
|
242
|
+
self.dev_panel = DevPanel() if self.config['server'].get('debug') else None
|
|
243
|
+
|
|
244
|
+
# Setup routes
|
|
245
|
+
self._setup_routes()
|
|
246
|
+
self._setup_request_hooks()
|
|
247
|
+
|
|
248
|
+
# Build dynamic route table for [param] segments
|
|
249
|
+
self._dynamic_routes: List[DynamicRoute] = []
|
|
250
|
+
self._build_dynamic_routes()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _load_config(self, config_path: str) -> Dict[str, Any]:
|
|
254
|
+
"""
|
|
255
|
+
Load configuration from YAML file with sensible defaults.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
config_path: Path to config file
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Configuration dictionary
|
|
262
|
+
"""
|
|
263
|
+
default_config = {
|
|
264
|
+
'server': {
|
|
265
|
+
'port': 8080,
|
|
266
|
+
# Loopback and no debugger by default. The old defaults were
|
|
267
|
+
# host 0.0.0.0 + debug True, which put the Werkzeug interactive
|
|
268
|
+
# debugger — an eval console — on every network interface: RCE
|
|
269
|
+
# over the LAN from an unauthenticated browser tab, on the very
|
|
270
|
+
# first `quantum start`. Binding wider or turning the debugger
|
|
271
|
+
# on is now an explicit choice in quantum.config.yaml, and
|
|
272
|
+
# start() refuses the dangerous combination (see _guard_debug).
|
|
273
|
+
'host': '127.0.0.1',
|
|
274
|
+
'reload': True,
|
|
275
|
+
'debug': False
|
|
276
|
+
},
|
|
277
|
+
'paths': {
|
|
278
|
+
'components': './components',
|
|
279
|
+
'static': './static',
|
|
280
|
+
'logs': './logs'
|
|
281
|
+
},
|
|
282
|
+
'defaults': {
|
|
283
|
+
'component_type': 'pure',
|
|
284
|
+
'interactive': False,
|
|
285
|
+
'charset': 'utf-8',
|
|
286
|
+
'timeout': 30
|
|
287
|
+
},
|
|
288
|
+
'performance': {
|
|
289
|
+
'cache_templates': True,
|
|
290
|
+
'cache_ttl': 300,
|
|
291
|
+
'cache_max_size': 100
|
|
292
|
+
},
|
|
293
|
+
'security': {
|
|
294
|
+
'xss_protection': True,
|
|
295
|
+
'max_content_length': 16 * 1024 * 1024 # 16 MB
|
|
296
|
+
},
|
|
297
|
+
'logging': {
|
|
298
|
+
'level': 'INFO',
|
|
299
|
+
'console': True,
|
|
300
|
+
'file': True,
|
|
301
|
+
'filename': 'quantum.log'
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
# Try to load config file
|
|
306
|
+
config_file = Path(config_path)
|
|
307
|
+
if config_file.exists():
|
|
308
|
+
try:
|
|
309
|
+
with open(config_file, 'r', encoding='utf-8') as f:
|
|
310
|
+
from quantum.core.config_env import expand_env
|
|
311
|
+
user_config = expand_env(yaml.safe_load(f) or {})
|
|
312
|
+
# Deep merge user config with defaults
|
|
313
|
+
for key, value in user_config.items():
|
|
314
|
+
if key in default_config and isinstance(default_config[key], dict) and isinstance(value, dict):
|
|
315
|
+
default_config[key].update(value)
|
|
316
|
+
else:
|
|
317
|
+
default_config[key] = value
|
|
318
|
+
except Exception as e:
|
|
319
|
+
# Do NOT fall back to defaults. This used to log a warning and
|
|
320
|
+
# carry on, which meant a single typo in quantum.config.yaml
|
|
321
|
+
# silently discarded the operator's datasources, host binding
|
|
322
|
+
# and security settings and ran the server on defaults — the
|
|
323
|
+
# exact opposite of what they configured, with one WARNING
|
|
324
|
+
# line as the only sign.
|
|
325
|
+
raise ConfigError(
|
|
326
|
+
f"could not read {config_path}: {e}\n"
|
|
327
|
+
f"Fix the file, or move it aside to run on defaults "
|
|
328
|
+
f"deliberately."
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
_validate_config(default_config, config_path)
|
|
332
|
+
return default_config
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _setup_routes(self):
|
|
336
|
+
"""Setup Flask routes for serving .q components"""
|
|
337
|
+
|
|
338
|
+
@self.app.route('/', methods=['GET', 'POST', 'PUT', 'DELETE'])
|
|
339
|
+
def index():
|
|
340
|
+
"""Serve index.q or show welcome page"""
|
|
341
|
+
return self._serve_component('index')
|
|
342
|
+
|
|
343
|
+
@self.app.route('/static/<path:filepath>')
|
|
344
|
+
def serve_static(filepath):
|
|
345
|
+
"""Serve static files (CSS, JS, images)"""
|
|
346
|
+
static_dir = self.config['paths']['static']
|
|
347
|
+
# Convert relative path to absolute for send_from_directory
|
|
348
|
+
if not os.path.isabs(static_dir):
|
|
349
|
+
static_dir = os.path.abspath(static_dir)
|
|
350
|
+
return send_from_directory(static_dir, filepath)
|
|
351
|
+
|
|
352
|
+
@self.app.route('/<path:component_path>', methods=['GET', 'POST', 'PUT', 'DELETE'])
|
|
353
|
+
def dynamic_route(component_path):
|
|
354
|
+
"""Dynamically serve any .q component"""
|
|
355
|
+
# Remove .q extension if provided
|
|
356
|
+
if component_path.endswith('.q'):
|
|
357
|
+
component_path = component_path[:-2]
|
|
358
|
+
|
|
359
|
+
return self._serve_component(component_path)
|
|
360
|
+
|
|
361
|
+
if self.dev_panel is not None:
|
|
362
|
+
@self.app.route('/_dev')
|
|
363
|
+
@self.app.route('/_dev/<int:numero>')
|
|
364
|
+
def dev_panel(numero=None):
|
|
365
|
+
"""M12: what the last requests did (debug only, this machine only).
|
|
366
|
+
|
|
367
|
+
It shows sessions and query parameters. `debug` is dropped for
|
|
368
|
+
the Werkzeug console off loopback, but the config flag — and a
|
|
369
|
+
gunicorn bind — can still be public, so the panel checks who
|
|
370
|
+
asks: from any other address it does not exist."""
|
|
371
|
+
if request.remote_addr not in ('127.0.0.1', '::1'):
|
|
372
|
+
abort(404)
|
|
373
|
+
return Response(self.dev_panel.render(numero), mimetype='text/html')
|
|
374
|
+
|
|
375
|
+
@self.app.route('/health')
|
|
376
|
+
def health_check():
|
|
377
|
+
"""Health check endpoint for container orchestration."""
|
|
378
|
+
import json
|
|
379
|
+
return Response(
|
|
380
|
+
json.dumps({
|
|
381
|
+
'status': 'healthy',
|
|
382
|
+
'service': 'quantum',
|
|
383
|
+
'version': '1.0.0'
|
|
384
|
+
}),
|
|
385
|
+
status=200,
|
|
386
|
+
mimetype='application/json'
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
@self.app.route('/_partial/<path:component_path>', methods=['GET', 'POST', 'PUT', 'DELETE'])
|
|
390
|
+
def serve_partial(component_path):
|
|
391
|
+
"""
|
|
392
|
+
Serve partial component for HTMX requests (Phase B)
|
|
393
|
+
|
|
394
|
+
Partials return only the component HTML without full page wrapper.
|
|
395
|
+
Used for HTMX-style progressive enhancement.
|
|
396
|
+
"""
|
|
397
|
+
# Remove .q extension if provided
|
|
398
|
+
if component_path.endswith('.q'):
|
|
399
|
+
component_path = component_path[:-2]
|
|
400
|
+
|
|
401
|
+
return self._serve_component(component_path, partial=True)
|
|
402
|
+
|
|
403
|
+
@self.app.errorhandler(404)
|
|
404
|
+
def not_found(error):
|
|
405
|
+
"""404 handler with helpful message"""
|
|
406
|
+
return self._render_error_page(
|
|
407
|
+
title="404 - Component Not Found",
|
|
408
|
+
message=f"The component you're looking for doesn't exist.",
|
|
409
|
+
details=f"Requested path: {request.path}",
|
|
410
|
+
suggestion="Check your components directory and try again."
|
|
411
|
+
), 404
|
|
412
|
+
|
|
413
|
+
@self.app.errorhandler(500)
|
|
414
|
+
def server_error(error):
|
|
415
|
+
"""500 handler with error details"""
|
|
416
|
+
return self._render_error_page(
|
|
417
|
+
title="500 - Server Error",
|
|
418
|
+
message="An error occurred while processing your component.",
|
|
419
|
+
details=str(error),
|
|
420
|
+
suggestion="Check the component syntax and server logs."
|
|
421
|
+
), 500
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _setup_request_hooks(self):
|
|
425
|
+
"""Setup before/after request hooks for request logging."""
|
|
426
|
+
server_logger = logging.getLogger('quantum.server')
|
|
427
|
+
|
|
428
|
+
@self.app.before_request
|
|
429
|
+
def before_request_hook():
|
|
430
|
+
request._quantum_start = time.monotonic()
|
|
431
|
+
if self.dev_panel is not None and not request.path.startswith(('/_dev', '/static/', '/favicon')):
|
|
432
|
+
request._quantum_dev = self.dev_panel.start(request.method, request.full_path.rstrip('?'))
|
|
433
|
+
|
|
434
|
+
@self.app.after_request
|
|
435
|
+
def after_request_hook(response):
|
|
436
|
+
token = getattr(request, '_quantum_dev', None)
|
|
437
|
+
if token is not None:
|
|
438
|
+
self.dev_panel.finish(token, response.status_code, response.headers.get('Location'),
|
|
439
|
+
session.get('flash'))
|
|
440
|
+
start = getattr(request, '_quantum_start', None)
|
|
441
|
+
if start is not None:
|
|
442
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
443
|
+
_ls = _get_logging_setup()
|
|
444
|
+
color = _ls.get_status_color(response.status_code)
|
|
445
|
+
reset = _ls.get_reset()
|
|
446
|
+
server_logger.info(
|
|
447
|
+
f"{request.method} {request.path} "
|
|
448
|
+
f"{color}{response.status_code}{reset} "
|
|
449
|
+
f"{duration_ms:.0f}ms"
|
|
450
|
+
)
|
|
451
|
+
return response
|
|
452
|
+
|
|
453
|
+
def _build_dynamic_routes(self) -> None:
|
|
454
|
+
"""Scan components directory for files with [param] segments and build regex routes."""
|
|
455
|
+
components_dir = Path(self.config['paths']['components'])
|
|
456
|
+
if not components_dir.is_dir():
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
routes = []
|
|
460
|
+
for q_file in components_dir.rglob('*.q'):
|
|
461
|
+
rel = q_file.relative_to(components_dir)
|
|
462
|
+
rel_str = str(rel).replace('\\', '/')
|
|
463
|
+
|
|
464
|
+
# Only process files whose path contains a [param] segment
|
|
465
|
+
if '[' not in rel_str:
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
# Strip .q extension
|
|
469
|
+
route_path = rel_str[:-2] # remove '.q'
|
|
470
|
+
|
|
471
|
+
# Strip trailing /index for directory-index style dynamic routes
|
|
472
|
+
if route_path.endswith('/index'):
|
|
473
|
+
route_path = route_path[:-6]
|
|
474
|
+
|
|
475
|
+
# Extract param names and build regex
|
|
476
|
+
parts = route_path.split('/')
|
|
477
|
+
param_names = []
|
|
478
|
+
regex_parts = []
|
|
479
|
+
static_count = 0
|
|
480
|
+
|
|
481
|
+
for part in parts:
|
|
482
|
+
# Catch-all: [...param] captures remaining path segments
|
|
483
|
+
m_catchall = re.fullmatch(r'\[\.\.\.(\w+)\]', part)
|
|
484
|
+
# Single segment: [param]
|
|
485
|
+
m = re.fullmatch(r'\[(\w+)\]', part)
|
|
486
|
+
if m_catchall:
|
|
487
|
+
param_names.append(m_catchall.group(1))
|
|
488
|
+
regex_parts.append(f'(?P<{m_catchall.group(1)}>.+)')
|
|
489
|
+
elif m:
|
|
490
|
+
param_names.append(m.group(1))
|
|
491
|
+
regex_parts.append(f'(?P<{m.group(1)}>[^/]+)')
|
|
492
|
+
else:
|
|
493
|
+
static_count += 1
|
|
494
|
+
regex_parts.append(re.escape(part))
|
|
495
|
+
|
|
496
|
+
pattern = re.compile('^' + '/'.join(regex_parts) + '$')
|
|
497
|
+
routes.append(DynamicRoute(
|
|
498
|
+
pattern=pattern,
|
|
499
|
+
file_path=q_file,
|
|
500
|
+
param_names=param_names,
|
|
501
|
+
static_segment_count=static_count,
|
|
502
|
+
))
|
|
503
|
+
|
|
504
|
+
# Sort: more static segments = higher priority (checked first)
|
|
505
|
+
routes.sort(key=lambda r: -r.static_segment_count)
|
|
506
|
+
self._dynamic_routes = routes
|
|
507
|
+
|
|
508
|
+
def _match_dynamic_route(self, component_path: str):
|
|
509
|
+
"""Try to match a component path against dynamic routes.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
Tuple of (file_path, params_dict) or (None, {})
|
|
513
|
+
"""
|
|
514
|
+
for route in self._dynamic_routes:
|
|
515
|
+
m = route.pattern.match(component_path)
|
|
516
|
+
if m:
|
|
517
|
+
return route.file_path, m.groupdict()
|
|
518
|
+
return None, {}
|
|
519
|
+
|
|
520
|
+
def _serve_component(self, component_path: str, partial: bool = False) -> Response:
|
|
521
|
+
"""
|
|
522
|
+
Load, parse, execute, and render a .q component.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
component_path: Path to component (without .q extension)
|
|
526
|
+
partial: If True, return only component HTML for HTMX (Phase B)
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
Flask Response with rendered HTML
|
|
530
|
+
"""
|
|
531
|
+
|
|
532
|
+
# Build full file path
|
|
533
|
+
components_dir = self.config['paths']['components']
|
|
534
|
+
|
|
535
|
+
# Containment check BEFORE touching the filesystem. `component_path`
|
|
536
|
+
# comes straight from the URL, and Flask only normalises `..` for
|
|
537
|
+
# clients that send a normalised path — a raw socket or
|
|
538
|
+
# `curl --path-as-is` sends `GET /../../x` verbatim, and Werkzeug
|
|
539
|
+
# passes the literal `..` through. Without this, GET /../../pwned
|
|
540
|
+
# resolved to a .q two directories above the web root and executed it;
|
|
541
|
+
# since a .q can carry <q:python>, that is unauthenticated RCE on the
|
|
542
|
+
# default config (host 0.0.0.0, python_scripting on). Reproduced via
|
|
543
|
+
# raw socket before adding this. See test_path_traversal.py.
|
|
544
|
+
if not self._is_within_components(component_path):
|
|
545
|
+
abort(404)
|
|
546
|
+
|
|
547
|
+
# ROUTE-3: a file or folder whose name starts with `_` exists to be
|
|
548
|
+
# imported, not visited. A layout like admin/AdminShell.q was served
|
|
549
|
+
# at /admin/AdminShell and answered 500 (its required props missing).
|
|
550
|
+
if any(parte.startswith('_') for parte in component_path.replace('\\', '/').split('/')):
|
|
551
|
+
abort(404)
|
|
552
|
+
|
|
553
|
+
file_path = Path(components_dir) / f'{component_path}.q'
|
|
554
|
+
path_params = {}
|
|
555
|
+
|
|
556
|
+
# Resolution priority:
|
|
557
|
+
# 1. Exact match: components/admin/app/blog.q
|
|
558
|
+
# 2. Directory index: components/admin/app/blog/index.q
|
|
559
|
+
# 3. Dynamic segment: components/admin/app/[name].q
|
|
560
|
+
# 4. 404
|
|
561
|
+
if not file_path.exists():
|
|
562
|
+
# Try index.q in subdirectory
|
|
563
|
+
index_path = Path(components_dir) / component_path / 'index.q'
|
|
564
|
+
if index_path.exists():
|
|
565
|
+
file_path = index_path
|
|
566
|
+
else:
|
|
567
|
+
# Try dynamic route matching
|
|
568
|
+
dyn_file, dyn_params = self._match_dynamic_route(component_path)
|
|
569
|
+
if dyn_file:
|
|
570
|
+
file_path = dyn_file
|
|
571
|
+
path_params = dyn_params
|
|
572
|
+
else:
|
|
573
|
+
# For index route, show welcome page; for other routes, return 404
|
|
574
|
+
if component_path == 'index':
|
|
575
|
+
return self._render_welcome_page()
|
|
576
|
+
else:
|
|
577
|
+
abort(404)
|
|
578
|
+
|
|
579
|
+
dev_note(component=str(file_path))
|
|
580
|
+
try:
|
|
581
|
+
# Check cache
|
|
582
|
+
cache_enabled = self.config['performance']['cache_templates']
|
|
583
|
+
cache_key = str(file_path)
|
|
584
|
+
|
|
585
|
+
if cache_enabled and cache_key in self.template_cache:
|
|
586
|
+
ast = self.template_cache[cache_key]
|
|
587
|
+
else:
|
|
588
|
+
# Parse component
|
|
589
|
+
ast = self.parser.parse_file(str(file_path))
|
|
590
|
+
|
|
591
|
+
# Cache if enabled
|
|
592
|
+
if cache_enabled:
|
|
593
|
+
self.template_cache[cache_key] = ast
|
|
594
|
+
|
|
595
|
+
# Phase G: Authentication & Security - Check authorization
|
|
596
|
+
if 'quantum_session' not in session:
|
|
597
|
+
session['quantum_session'] = {}
|
|
598
|
+
|
|
599
|
+
session_data = session['quantum_session']
|
|
600
|
+
|
|
601
|
+
# Check if component requires authentication
|
|
602
|
+
if getattr(ast, 'require_auth', False):
|
|
603
|
+
if not AuthService.is_authenticated(session_data):
|
|
604
|
+
# Not authenticated - redirect to login
|
|
605
|
+
session['redirect_after_login'] = request.path
|
|
606
|
+
return redirect(getattr(ast, 'login_url', None) or self._login_url())
|
|
607
|
+
|
|
608
|
+
# Check session expiry
|
|
609
|
+
if AuthService.is_session_expired(session_data):
|
|
610
|
+
# Session expired - logout and redirect to login
|
|
611
|
+
AuthService.logout(session_data)
|
|
612
|
+
session.modified = True
|
|
613
|
+
return redirect((getattr(ast, 'login_url', None) or self._login_url()) + '?expired=true')
|
|
614
|
+
|
|
615
|
+
# Check role requirement
|
|
616
|
+
if ast.require_role:
|
|
617
|
+
if not AuthService.has_role(session_data, ast.require_role):
|
|
618
|
+
# Forbidden - user doesn't have required role
|
|
619
|
+
return Response(
|
|
620
|
+
f"<h1>403 Forbidden</h1><p>Required role: {ast.require_role}</p>",
|
|
621
|
+
status=403,
|
|
622
|
+
mimetype='text/html'
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
# Check permission requirement
|
|
626
|
+
if ast.require_permission:
|
|
627
|
+
if not AuthService.has_permission(session_data, ast.require_permission):
|
|
628
|
+
# Forbidden - user doesn't have required permission
|
|
629
|
+
return Response(
|
|
630
|
+
f"<h1>403 Forbidden</h1><p>Required permission: {ast.require_permission}</p>",
|
|
631
|
+
status=403,
|
|
632
|
+
mimetype='text/html'
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
# UI-13: a cell of an editable <ui:table> posts to the generated action.
|
|
636
|
+
if request.method == 'POST' and request.form.get('action') == '__edit':
|
|
637
|
+
return self._edit_cell(ast, path_params)
|
|
638
|
+
|
|
639
|
+
# Check if this is an action request (POST/PUT/DELETE)
|
|
640
|
+
if request.method in ['POST', 'PUT', 'DELETE', 'PATCH']:
|
|
641
|
+
# Look for q:action in component
|
|
642
|
+
try:
|
|
643
|
+
action_node = self._find_action_in_component(ast)
|
|
644
|
+
except UnknownActionError as e:
|
|
645
|
+
return Response(f"<h1>400 Bad Request</h1><p>{html_escape(str(e))}</p>",
|
|
646
|
+
status=400, mimetype='text/html')
|
|
647
|
+
|
|
648
|
+
if action_node:
|
|
649
|
+
dev_note(action=action_node.name)
|
|
650
|
+
# AUTH-6: the page's guards run before its action. Without
|
|
651
|
+
# this a guard protected the GET only, and the POST ran
|
|
652
|
+
# the action with no session.
|
|
653
|
+
guard_runtime = ComponentRuntime(config=self.config)
|
|
654
|
+
try:
|
|
655
|
+
guard_runtime.run_guards(ast, self._page_params(path_params, consume_flash=False))
|
|
656
|
+
except PageRedirect as r:
|
|
657
|
+
return self._page_redirect(r, guard_runtime)
|
|
658
|
+
session['quantum_session'] = guard_runtime.execution_context.session_vars
|
|
659
|
+
|
|
660
|
+
# Handle action — in THIS request's runtime (ACT-10). It
|
|
661
|
+
# used to run in one ComponentRuntime shared by every
|
|
662
|
+
# request and thread, which never registered the page's
|
|
663
|
+
# q:functions: calling one inside an action was a 500.
|
|
664
|
+
# guard_runtime has entered this component with this
|
|
665
|
+
# request's scopes.
|
|
666
|
+
try:
|
|
667
|
+
redirect_url, status_code = ActionHandler(guard_runtime).handle_action(
|
|
668
|
+
action_node, route_params=path_params)
|
|
669
|
+
except PageRedirect as r:
|
|
670
|
+
# q:redirect reached through a q:function or q:loop in the action
|
|
671
|
+
return self._page_redirect(r)
|
|
672
|
+
|
|
673
|
+
if redirect_url:
|
|
674
|
+
return redirect(redirect_url, code=status_code)
|
|
675
|
+
if status_code >= 400:
|
|
676
|
+
# A real error occurred in the action body. Report it as
|
|
677
|
+
# an actual error response instead of a redirect
|
|
678
|
+
# disguised as one (a 500 with a Location header reads
|
|
679
|
+
# as success to most clients).
|
|
680
|
+
return Response(
|
|
681
|
+
f"<h1>{status_code} Error</h1><p>The action could not be completed. Check server logs for details.</p>",
|
|
682
|
+
status=status_code,
|
|
683
|
+
mimetype='text/html'
|
|
684
|
+
)
|
|
685
|
+
# If no redirect and no error, fall through to render component
|
|
686
|
+
|
|
687
|
+
params = self._page_params(path_params)
|
|
688
|
+
|
|
689
|
+
# Execute component (runs queries, loops, functions, etc.)
|
|
690
|
+
runtime = ComponentRuntime(config=self.config)
|
|
691
|
+
try:
|
|
692
|
+
runtime.execute_component(ast, params)
|
|
693
|
+
except PageRedirect as r:
|
|
694
|
+
# ACT-7: q:redirect outside an action ends the page.
|
|
695
|
+
return self._page_redirect(r, runtime)
|
|
696
|
+
finally:
|
|
697
|
+
dev_scopes(runtime.execution_context)
|
|
698
|
+
|
|
699
|
+
# Phase F: Sync session back to Flask session
|
|
700
|
+
session['quantum_session'] = runtime.execution_context.session_vars
|
|
701
|
+
session.modified = True
|
|
702
|
+
|
|
703
|
+
# Debug: log session state after execution
|
|
704
|
+
if self.config['server']['debug'] and request.method == 'POST':
|
|
705
|
+
debug_logger = logging.getLogger('quantum.runtime')
|
|
706
|
+
debug_logger.debug(f"POST form_data: {dict(request.form)}")
|
|
707
|
+
debug_logger.debug(f"session_vars after exec: {runtime.execution_context.session_vars}")
|
|
708
|
+
debug_logger.debug(f"app_vars keys: {list(runtime.execution_context.application_vars.keys())}")
|
|
709
|
+
|
|
710
|
+
# Render to HTML using runtime's execution context
|
|
711
|
+
renderer = HTMLRenderer(runtime.execution_context,
|
|
712
|
+
components_dir=self.config['paths']['components'],
|
|
713
|
+
function_resolver=runtime._resolve_expression_function,
|
|
714
|
+
config=self.config)
|
|
715
|
+
# UI-9: after a failed validation, the form shows the values sent
|
|
716
|
+
# and each error by its field — once, like the flash.
|
|
717
|
+
renderer.form_state = session.pop('form_state', None)
|
|
718
|
+
renderer._ui_action_nodes = {s.name: s for s in ast.statements if isinstance(s, ActionNode)}
|
|
719
|
+
from quantum.runtime.ui_table_edit import query_nodes
|
|
720
|
+
renderer._query_nodes = query_nodes(ast)
|
|
721
|
+
|
|
722
|
+
# UI-3: a renderer that is not a browser (the console) asks for the
|
|
723
|
+
# page as a tree of view nodes instead of HTML. Same page, same
|
|
724
|
+
# runtime, same actions — only the drawing differs.
|
|
725
|
+
if 'application/vnd.quantum.view+json' in (request.headers.get('Accept') or ''):
|
|
726
|
+
from quantum.runtime.ui_view import UIViewBuilder, page_title
|
|
727
|
+
import json as _json
|
|
728
|
+
corpo = {
|
|
729
|
+
'title': page_title(ast, renderer),
|
|
730
|
+
'flash': params.get('flash', ''), 'flashType': params.get('flashType', ''),
|
|
731
|
+
'view': UIViewBuilder(renderer).build(
|
|
732
|
+
[s for s in ast.statements if not isinstance(s, ActionNode)]),
|
|
733
|
+
}
|
|
734
|
+
return Response(_json.dumps(corpo, ensure_ascii=False, default=str),
|
|
735
|
+
mimetype='application/vnd.quantum.view+json')
|
|
736
|
+
|
|
737
|
+
try:
|
|
738
|
+
html = renderer.render(ast)
|
|
739
|
+
except PageRedirect as r:
|
|
740
|
+
# A component called from the markup redirected.
|
|
741
|
+
return self._page_redirect(r, runtime)
|
|
742
|
+
# UI-12: a search field refreshes an element by id; one that is not
|
|
743
|
+
# on the page would make every keystroke swap nothing, silently.
|
|
744
|
+
for alvo in sorted(getattr(renderer, '_search_targets', ()) or ()):
|
|
745
|
+
if not re.search(r'(?<![\w-])id="' + re.escape(alvo) + '"', html):
|
|
746
|
+
raise ValueError(f'<ui:input search="{alvo}">: there is no element with id="{alvo}" '
|
|
747
|
+
f'on this page to refresh (UI-12)')
|
|
748
|
+
|
|
749
|
+
# Phase B: For partial requests, return only component HTML
|
|
750
|
+
if partial:
|
|
751
|
+
return Response(html, mimetype='text/html')
|
|
752
|
+
|
|
753
|
+
# For full page requests, add HTMX support
|
|
754
|
+
if '<html' in html.lower():
|
|
755
|
+
# Component already has full HTML structure - inject HTMX into it
|
|
756
|
+
full_html = self._inject_htmx(html)
|
|
757
|
+
else:
|
|
758
|
+
# Fragment component - wrap with full page + HTMX
|
|
759
|
+
from quantum.runtime.ui_view import page_title
|
|
760
|
+
full_html = self._wrap_with_htmx(html, component_path, page_title(ast, renderer))
|
|
761
|
+
|
|
762
|
+
# Extract inline CSS and JS to external files
|
|
763
|
+
full_html = self._extract_inline_assets(full_html)
|
|
764
|
+
|
|
765
|
+
# Pretty-print HTML for readable View Source
|
|
766
|
+
full_html = self._prettify_html(full_html)
|
|
767
|
+
|
|
768
|
+
return Response(full_html, mimetype='text/html')
|
|
769
|
+
|
|
770
|
+
except QuantumParseError as e:
|
|
771
|
+
dev_note(error=str(e))
|
|
772
|
+
# Enhanced parse error with context
|
|
773
|
+
enhanced_error = ErrorHandler.handle_parse_error(e, component_path)
|
|
774
|
+
|
|
775
|
+
if self.config['server']['debug']:
|
|
776
|
+
# Show enhanced error in debug mode
|
|
777
|
+
# The message says what to change; the generic XML hints of
|
|
778
|
+
# ErrorHandler were wrong for most parse errors (a statement in
|
|
779
|
+
# markup is not an XML syntax problem).
|
|
780
|
+
return self._render_error_page(
|
|
781
|
+
title="Parse Error",
|
|
782
|
+
message=f"Could not parse component: {component_path}",
|
|
783
|
+
details=str(e),
|
|
784
|
+
suggestion="",
|
|
785
|
+
location=self._error_location(e, file_path),
|
|
786
|
+
), 400
|
|
787
|
+
else:
|
|
788
|
+
return self._render_error_page(
|
|
789
|
+
title="Parse Error",
|
|
790
|
+
message="Could not parse component",
|
|
791
|
+
details="Enable debug mode for details",
|
|
792
|
+
suggestion=""
|
|
793
|
+
), 400
|
|
794
|
+
|
|
795
|
+
except QuantumError as e:
|
|
796
|
+
dev_note(error=str(e))
|
|
797
|
+
logging.getLogger('quantum.server').error("page %s failed: %s", component_path, e)
|
|
798
|
+
# Already enhanced error
|
|
799
|
+
return self._render_error_page(
|
|
800
|
+
title="Quantum Error",
|
|
801
|
+
message=e.message,
|
|
802
|
+
details=str(e),
|
|
803
|
+
suggestion=e.suggestion or "Check the error details above",
|
|
804
|
+
location=self._error_location(e, file_path) if self.config['server']['debug'] else None,
|
|
805
|
+
), 500
|
|
806
|
+
|
|
807
|
+
except HTTPException:
|
|
808
|
+
# 413 (CFG-2), 400 for a malformed form: Flask answers these with
|
|
809
|
+
# their own status. Caught below they became a 500.
|
|
810
|
+
raise
|
|
811
|
+
|
|
812
|
+
except Exception as e:
|
|
813
|
+
dev_note(error=f'{type(e).__name__}: {e}')
|
|
814
|
+
# The page says little in production; the log must say everything.
|
|
815
|
+
# This branch used to leave no trace at all: a 500 with nothing to read.
|
|
816
|
+
logging.getLogger('quantum.server').exception(
|
|
817
|
+
"page %s failed: %s", component_path, e)
|
|
818
|
+
if self.config['server']['debug']:
|
|
819
|
+
# Enhanced runtime error
|
|
820
|
+
enhanced_error = ErrorHandler.handle_runtime_error(e, str(component_path), component_path)
|
|
821
|
+
|
|
822
|
+
import traceback
|
|
823
|
+
return self._render_error_page(
|
|
824
|
+
title="Runtime Error",
|
|
825
|
+
message=f"Error in component: {component_path}",
|
|
826
|
+
details=str(e),
|
|
827
|
+
suggestion="",
|
|
828
|
+
location=self._error_location(e, file_path),
|
|
829
|
+
python_traceback=traceback.format_exc(),
|
|
830
|
+
), 500
|
|
831
|
+
else:
|
|
832
|
+
# Generic error in production
|
|
833
|
+
return self._render_error_page(
|
|
834
|
+
title="Error",
|
|
835
|
+
message="An error occurred",
|
|
836
|
+
details="Enable debug mode for more details.",
|
|
837
|
+
suggestion=""
|
|
838
|
+
), 500
|
|
839
|
+
|
|
840
|
+
def _get_static_dir(self) -> str:
|
|
841
|
+
"""Resolve and ensure the static directory exists."""
|
|
842
|
+
static_dir = self.config['paths']['static']
|
|
843
|
+
if not os.path.isabs(static_dir):
|
|
844
|
+
static_dir = os.path.abspath(static_dir)
|
|
845
|
+
os.makedirs(static_dir, exist_ok=True)
|
|
846
|
+
return static_dir
|
|
847
|
+
|
|
848
|
+
def _extract_inline_assets(self, html: str) -> str:
|
|
849
|
+
"""
|
|
850
|
+
Extract inline <style> and <script> blocks to external files.
|
|
851
|
+
|
|
852
|
+
Replaces inline CSS with <link rel="stylesheet"> and inline JS
|
|
853
|
+
(without src attribute) with <script src="...">.
|
|
854
|
+
|
|
855
|
+
Args:
|
|
856
|
+
html: Full HTML document string
|
|
857
|
+
|
|
858
|
+
Returns:
|
|
859
|
+
HTML with inline assets replaced by external file references
|
|
860
|
+
"""
|
|
861
|
+
static_dir = self._get_static_dir()
|
|
862
|
+
|
|
863
|
+
# --- Extract CSS ---
|
|
864
|
+
style_pattern = re.compile(r'<style[^>]*>(.*?)</style>', re.DOTALL | re.IGNORECASE)
|
|
865
|
+
css_matches = style_pattern.findall(html)
|
|
866
|
+
|
|
867
|
+
if css_matches:
|
|
868
|
+
all_css = '\n'.join(css_matches)
|
|
869
|
+
css_hash = hashlib.md5(all_css.encode()).hexdigest()[:10]
|
|
870
|
+
css_filename = f'styles-{css_hash}.css'
|
|
871
|
+
css_path = os.path.join(static_dir, css_filename)
|
|
872
|
+
|
|
873
|
+
if not os.path.exists(css_path):
|
|
874
|
+
with open(css_path, 'w', encoding='utf-8') as f:
|
|
875
|
+
f.write(all_css)
|
|
876
|
+
|
|
877
|
+
html = style_pattern.sub('', html)
|
|
878
|
+
|
|
879
|
+
# Root-relative on purpose. A bare "static/..." is resolved
|
|
880
|
+
# against the current path, so a page at /admin/projects asked for
|
|
881
|
+
# /admin/static/... and rendered with no CSS at all — silently,
|
|
882
|
+
# since a 404 on a stylesheet does not fail the page.
|
|
883
|
+
link_tag = f'<link rel="stylesheet" href="/static/{css_filename}">'
|
|
884
|
+
if '</head>' in html:
|
|
885
|
+
html = html.replace('</head>', f' {link_tag}\n </head>', 1)
|
|
886
|
+
elif '</HEAD>' in html:
|
|
887
|
+
html = html.replace('</HEAD>', f' {link_tag}\n </HEAD>', 1)
|
|
888
|
+
|
|
889
|
+
# --- Extract inline JS (skip <script src="..."> tags) ---
|
|
890
|
+
script_pattern = re.compile(
|
|
891
|
+
r'<script(?![^>]*\bsrc\b)[^>]*>(.*?)</script>', re.DOTALL | re.IGNORECASE
|
|
892
|
+
)
|
|
893
|
+
js_matches = script_pattern.findall(html)
|
|
894
|
+
|
|
895
|
+
if js_matches:
|
|
896
|
+
all_js = '\n'.join(m.strip() for m in js_matches)
|
|
897
|
+
js_hash = hashlib.md5(all_js.encode()).hexdigest()[:10]
|
|
898
|
+
js_filename = f'scripts-{js_hash}.js'
|
|
899
|
+
js_path = os.path.join(static_dir, js_filename)
|
|
900
|
+
|
|
901
|
+
if not os.path.exists(js_path):
|
|
902
|
+
with open(js_path, 'w', encoding='utf-8') as f:
|
|
903
|
+
f.write(all_js)
|
|
904
|
+
|
|
905
|
+
html = script_pattern.sub('', html)
|
|
906
|
+
|
|
907
|
+
# Root-relative — same reason as the stylesheet above.
|
|
908
|
+
script_tag = f'<script src="/static/{js_filename}"></script>'
|
|
909
|
+
if '</body>' in html:
|
|
910
|
+
html = html.replace('</body>', f' {script_tag}\n </body>', 1)
|
|
911
|
+
elif '</BODY>' in html:
|
|
912
|
+
html = html.replace('</BODY>', f' {script_tag}\n </BODY>', 1)
|
|
913
|
+
|
|
914
|
+
return html
|
|
915
|
+
|
|
916
|
+
def _prettify_html(self, html: str) -> str:
|
|
917
|
+
"""
|
|
918
|
+
Pretty-print HTML with indentation for readable View Source output.
|
|
919
|
+
|
|
920
|
+
Adds newlines and 2-space indentation around block-level elements
|
|
921
|
+
while preserving content inside whitespace-sensitive elements
|
|
922
|
+
(<pre>, <textarea>) verbatim.
|
|
923
|
+
|
|
924
|
+
Args:
|
|
925
|
+
html: Full HTML document string
|
|
926
|
+
|
|
927
|
+
Returns:
|
|
928
|
+
Indented HTML string
|
|
929
|
+
"""
|
|
930
|
+
BLOCK_ELEMENTS = {
|
|
931
|
+
'html', 'head', 'body', 'header', 'footer', 'main', 'section',
|
|
932
|
+
'nav', 'article', 'aside', 'div', 'ul', 'ol', 'li', 'table',
|
|
933
|
+
'thead', 'tbody', 'tr', 'th', 'td', 'form', 'fieldset',
|
|
934
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'blockquote',
|
|
935
|
+
'figure', 'figcaption', 'details', 'summary',
|
|
936
|
+
'title', 'script', 'style', 'noscript', 'template',
|
|
937
|
+
}
|
|
938
|
+
VOID_ELEMENTS = {
|
|
939
|
+
'br', 'hr', 'img', 'input', 'meta', 'link', 'col', 'area',
|
|
940
|
+
'base', 'embed', 'source', 'track', 'wbr',
|
|
941
|
+
}
|
|
942
|
+
INLINE_ELEMENTS = {
|
|
943
|
+
'a', 'span', 'strong', 'em', 'b', 'i', 'u', 'small', 'sub',
|
|
944
|
+
'sup', 'abbr', 'cite', 'code', 'label', 'button', 'select',
|
|
945
|
+
'option', 'time', 'mark',
|
|
946
|
+
}
|
|
947
|
+
PRESERVE_ELEMENTS = {'pre', 'textarea'}
|
|
948
|
+
|
|
949
|
+
# Tokenize: split into tags and text segments
|
|
950
|
+
token_re = re.compile(r'(<[^>]+>)')
|
|
951
|
+
tokens = token_re.split(html)
|
|
952
|
+
|
|
953
|
+
tag_name_re = re.compile(r'^<\s*/?\s*([a-zA-Z][a-zA-Z0-9]*)', re.IGNORECASE)
|
|
954
|
+
|
|
955
|
+
depth = 0
|
|
956
|
+
output = []
|
|
957
|
+
preserve_depth = 0
|
|
958
|
+
|
|
959
|
+
def _at_line_start():
|
|
960
|
+
"""Check if we're at the start of a line (last output ends with newline or empty)."""
|
|
961
|
+
return not output or output[-1].endswith('\n')
|
|
962
|
+
|
|
963
|
+
for token in tokens:
|
|
964
|
+
if not token:
|
|
965
|
+
continue
|
|
966
|
+
|
|
967
|
+
is_tag = token.startswith('<')
|
|
968
|
+
|
|
969
|
+
if is_tag:
|
|
970
|
+
tag_match = tag_name_re.match(token)
|
|
971
|
+
tag_name = tag_match.group(1).lower() if tag_match else ''
|
|
972
|
+
is_closing = token.startswith('</')
|
|
973
|
+
is_self_closing = token.rstrip().endswith('/>')
|
|
974
|
+
is_doctype = token.upper().startswith('<!DOCTYPE')
|
|
975
|
+
is_comment = token.startswith('<!--')
|
|
976
|
+
|
|
977
|
+
# Handle preserve elements
|
|
978
|
+
if tag_name in PRESERVE_ELEMENTS:
|
|
979
|
+
if is_closing:
|
|
980
|
+
preserve_depth = max(preserve_depth - 1, 0)
|
|
981
|
+
output.append(token)
|
|
982
|
+
output.append('\n')
|
|
983
|
+
elif not is_self_closing:
|
|
984
|
+
preserve_depth += 1
|
|
985
|
+
indent = ' ' * depth
|
|
986
|
+
if not _at_line_start():
|
|
987
|
+
output.append('\n')
|
|
988
|
+
output.append(f'{indent}{token}')
|
|
989
|
+
else:
|
|
990
|
+
indent = ' ' * depth
|
|
991
|
+
output.append(f'{indent}{token}\n')
|
|
992
|
+
continue
|
|
993
|
+
|
|
994
|
+
# Inside preserved content — output verbatim
|
|
995
|
+
if preserve_depth > 0:
|
|
996
|
+
output.append(token)
|
|
997
|
+
continue
|
|
998
|
+
|
|
999
|
+
if is_doctype or is_comment:
|
|
1000
|
+
indent = ' ' * depth
|
|
1001
|
+
if not _at_line_start():
|
|
1002
|
+
output.append('\n')
|
|
1003
|
+
output.append(f'{indent}{token}\n')
|
|
1004
|
+
elif tag_name in BLOCK_ELEMENTS:
|
|
1005
|
+
if is_closing:
|
|
1006
|
+
depth = max(depth - 1, 0)
|
|
1007
|
+
indent = ' ' * depth
|
|
1008
|
+
if not _at_line_start():
|
|
1009
|
+
output.append('\n')
|
|
1010
|
+
output.append(f'{indent}{token}\n')
|
|
1011
|
+
elif is_self_closing:
|
|
1012
|
+
indent = ' ' * depth
|
|
1013
|
+
if not _at_line_start():
|
|
1014
|
+
output.append('\n')
|
|
1015
|
+
output.append(f'{indent}{token}\n')
|
|
1016
|
+
else:
|
|
1017
|
+
indent = ' ' * depth
|
|
1018
|
+
if not _at_line_start():
|
|
1019
|
+
output.append('\n')
|
|
1020
|
+
output.append(f'{indent}{token}\n')
|
|
1021
|
+
depth += 1
|
|
1022
|
+
elif tag_name in VOID_ELEMENTS:
|
|
1023
|
+
indent = ' ' * depth
|
|
1024
|
+
if not _at_line_start():
|
|
1025
|
+
output.append('\n')
|
|
1026
|
+
output.append(f'{indent}{token}\n')
|
|
1027
|
+
elif tag_name in INLINE_ELEMENTS:
|
|
1028
|
+
# Inline elements stay on same line as surrounding content
|
|
1029
|
+
if _at_line_start():
|
|
1030
|
+
indent = ' ' * depth
|
|
1031
|
+
output.append(f'{indent}{token}')
|
|
1032
|
+
else:
|
|
1033
|
+
output.append(token)
|
|
1034
|
+
else:
|
|
1035
|
+
# Unknown tags — treat as inline
|
|
1036
|
+
output.append(token)
|
|
1037
|
+
else:
|
|
1038
|
+
# Text node
|
|
1039
|
+
if preserve_depth > 0:
|
|
1040
|
+
output.append(token)
|
|
1041
|
+
else:
|
|
1042
|
+
stripped = token.strip()
|
|
1043
|
+
if stripped:
|
|
1044
|
+
if _at_line_start():
|
|
1045
|
+
indent = ' ' * depth
|
|
1046
|
+
output.append(f'{indent}{stripped}\n')
|
|
1047
|
+
else:
|
|
1048
|
+
output.append(stripped)
|
|
1049
|
+
|
|
1050
|
+
result = ''.join(output)
|
|
1051
|
+
# Clean up multiple blank lines
|
|
1052
|
+
result = re.sub(r'\n{3,}', '\n\n', result)
|
|
1053
|
+
return result.strip() + '\n'
|
|
1054
|
+
|
|
1055
|
+
def _get_hot_reload_script(self) -> str:
|
|
1056
|
+
"""
|
|
1057
|
+
Get the hot reload client script.
|
|
1058
|
+
|
|
1059
|
+
Returns:
|
|
1060
|
+
JavaScript code for hot reload client
|
|
1061
|
+
"""
|
|
1062
|
+
if not self.hot_reload_enabled:
|
|
1063
|
+
return ''
|
|
1064
|
+
|
|
1065
|
+
return f"""
|
|
1066
|
+
<!-- Quantum Hot Reload Client -->
|
|
1067
|
+
<script>
|
|
1068
|
+
window.__QUANTUM_HOT_RELOAD_CONFIG = {{
|
|
1069
|
+
host: 'localhost',
|
|
1070
|
+
port: {self.hot_reload_port},
|
|
1071
|
+
enableLogging: true,
|
|
1072
|
+
enableToasts: true,
|
|
1073
|
+
preserveState: true
|
|
1074
|
+
}};
|
|
1075
|
+
</script>
|
|
1076
|
+
<script>
|
|
1077
|
+
(function() {{
|
|
1078
|
+
'use strict';
|
|
1079
|
+
var config = window.__QUANTUM_HOT_RELOAD_CONFIG || {{}};
|
|
1080
|
+
var WS_URL = 'ws://' + (config.host || 'localhost') + ':' + (config.port || 35729);
|
|
1081
|
+
var RECONNECT_INTERVAL = 1000;
|
|
1082
|
+
var MAX_RECONNECT_ATTEMPTS = 30;
|
|
1083
|
+
var socket = null;
|
|
1084
|
+
var reconnectAttempts = 0;
|
|
1085
|
+
var overlay = null;
|
|
1086
|
+
var isConnected = false;
|
|
1087
|
+
var preservedState = {{}};
|
|
1088
|
+
|
|
1089
|
+
function log(msg) {{
|
|
1090
|
+
if (config.enableLogging !== false) console.log('[Hot Reload] ' + msg);
|
|
1091
|
+
}}
|
|
1092
|
+
|
|
1093
|
+
function connect() {{
|
|
1094
|
+
if (socket && socket.readyState === WebSocket.OPEN) return;
|
|
1095
|
+
try {{
|
|
1096
|
+
socket = new WebSocket(WS_URL);
|
|
1097
|
+
socket.onopen = function() {{
|
|
1098
|
+
log('Connected to dev server');
|
|
1099
|
+
reconnectAttempts = 0;
|
|
1100
|
+
isConnected = true;
|
|
1101
|
+
hideOverlay();
|
|
1102
|
+
showToast('Hot reload connected', 'success');
|
|
1103
|
+
}};
|
|
1104
|
+
socket.onclose = function() {{
|
|
1105
|
+
isConnected = false;
|
|
1106
|
+
log('Disconnected from dev server');
|
|
1107
|
+
scheduleReconnect();
|
|
1108
|
+
}};
|
|
1109
|
+
socket.onerror = function() {{ log('WebSocket error'); }};
|
|
1110
|
+
socket.onmessage = function(e) {{
|
|
1111
|
+
try {{ handleMessage(JSON.parse(e.data)); }} catch (err) {{}}
|
|
1112
|
+
}};
|
|
1113
|
+
}} catch (e) {{
|
|
1114
|
+
log('Failed to connect: ' + e);
|
|
1115
|
+
scheduleReconnect();
|
|
1116
|
+
}}
|
|
1117
|
+
}}
|
|
1118
|
+
|
|
1119
|
+
function scheduleReconnect() {{
|
|
1120
|
+
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {{
|
|
1121
|
+
showOverlay({{type: 'connection_lost', message: 'Lost connection to dev server. Restart with: quantum dev'}});
|
|
1122
|
+
return;
|
|
1123
|
+
}}
|
|
1124
|
+
reconnectAttempts++;
|
|
1125
|
+
setTimeout(connect, RECONNECT_INTERVAL);
|
|
1126
|
+
}}
|
|
1127
|
+
|
|
1128
|
+
function handleMessage(data) {{
|
|
1129
|
+
if (data.type === 'reload') {{
|
|
1130
|
+
log('Reload: ' + data.reloadType);
|
|
1131
|
+
if (data.reloadType === 'css') {{
|
|
1132
|
+
reloadCSS();
|
|
1133
|
+
showToast('Styles updated', 'info');
|
|
1134
|
+
}} else {{
|
|
1135
|
+
preserveState();
|
|
1136
|
+
showToast('Reloading...', 'info');
|
|
1137
|
+
setTimeout(function() {{ location.reload(); }}, 100);
|
|
1138
|
+
}}
|
|
1139
|
+
}} else if (data.type === 'error') {{
|
|
1140
|
+
showOverlay(data.error);
|
|
1141
|
+
}} else if (data.type === 'clear_error') {{
|
|
1142
|
+
hideOverlay();
|
|
1143
|
+
}}
|
|
1144
|
+
}}
|
|
1145
|
+
|
|
1146
|
+
function reloadCSS() {{
|
|
1147
|
+
var links = document.querySelectorAll('link[rel="stylesheet"]');
|
|
1148
|
+
var ts = Date.now();
|
|
1149
|
+
links.forEach(function(l) {{ l.href = l.href.split('?')[0] + '?_hr=' + ts; }});
|
|
1150
|
+
log('CSS reloaded');
|
|
1151
|
+
}}
|
|
1152
|
+
|
|
1153
|
+
function preserveState() {{
|
|
1154
|
+
preservedState = {{}};
|
|
1155
|
+
document.querySelectorAll('form').forEach(function(f, i) {{
|
|
1156
|
+
var id = f.id || 'form_' + i;
|
|
1157
|
+
preservedState[id] = {{}};
|
|
1158
|
+
f.querySelectorAll('input,textarea,select').forEach(function(inp) {{
|
|
1159
|
+
if (inp.name) {{
|
|
1160
|
+
preservedState[id][inp.name] = (inp.type === 'checkbox' || inp.type === 'radio') ? inp.checked : inp.value;
|
|
1161
|
+
}}
|
|
1162
|
+
}});
|
|
1163
|
+
}});
|
|
1164
|
+
preservedState._scroll = {{x: window.scrollX, y: window.scrollY}};
|
|
1165
|
+
try {{ sessionStorage.setItem('__quantum_hr_state', JSON.stringify(preservedState)); }} catch(e) {{}}
|
|
1166
|
+
}}
|
|
1167
|
+
|
|
1168
|
+
function restoreState() {{
|
|
1169
|
+
try {{
|
|
1170
|
+
var s = sessionStorage.getItem('__quantum_hr_state');
|
|
1171
|
+
if (!s) return;
|
|
1172
|
+
var state = JSON.parse(s);
|
|
1173
|
+
sessionStorage.removeItem('__quantum_hr_state');
|
|
1174
|
+
Object.keys(state).forEach(function(fid) {{
|
|
1175
|
+
if (fid.startsWith('_')) return;
|
|
1176
|
+
var form = document.getElementById(fid) || document.forms[fid.replace('form_', '')];
|
|
1177
|
+
if (!form) return;
|
|
1178
|
+
Object.keys(state[fid]).forEach(function(n) {{
|
|
1179
|
+
var inp = form.querySelector('[name="' + n + '"]');
|
|
1180
|
+
if (inp) {{
|
|
1181
|
+
if (inp.type === 'checkbox' || inp.type === 'radio') inp.checked = state[fid][n];
|
|
1182
|
+
else inp.value = state[fid][n];
|
|
1183
|
+
}}
|
|
1184
|
+
}});
|
|
1185
|
+
}});
|
|
1186
|
+
if (state._scroll) setTimeout(function() {{ window.scrollTo(state._scroll.x, state._scroll.y); }}, 100);
|
|
1187
|
+
}} catch(e) {{}}
|
|
1188
|
+
}}
|
|
1189
|
+
|
|
1190
|
+
function showOverlay(err) {{
|
|
1191
|
+
hideOverlay();
|
|
1192
|
+
overlay = document.createElement('div');
|
|
1193
|
+
overlay.id = '__quantum_hr_overlay';
|
|
1194
|
+
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.9);z-index:999999;display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif;';
|
|
1195
|
+
var c = document.createElement('div');
|
|
1196
|
+
c.style.cssText = 'max-width:800px;padding:40px;background:#1e1e1e;border-radius:8px;color:#fff;';
|
|
1197
|
+
var titleColor = err.type === 'connection_lost' ? '#ffc107' : '#f44336';
|
|
1198
|
+
c.innerHTML = '<h2 style="color:' + titleColor + ';margin:0 0 16px;">' + (err.type === 'connection_lost' ? 'Connection Lost' : 'Parse Error') + '</h2>' +
|
|
1199
|
+
(err.file ? '<p style="color:#888;margin:0 0 12px;font-size:14px;">File: ' + err.file + '</p>' : '') +
|
|
1200
|
+
'<pre style="background:#2d2d2d;padding:16px;border-radius:4px;overflow:auto;max-height:400px;color:#ff6b6b;white-space:pre-wrap;">' + escapeHtml(err.message) + '</pre>' +
|
|
1201
|
+
'<p style="color:#888;margin:16px 0 0;font-size:13px;">Fix the error and save to reload.</p>';
|
|
1202
|
+
overlay.appendChild(c);
|
|
1203
|
+
document.body.appendChild(overlay);
|
|
1204
|
+
overlay.onclick = function(e) {{ if (e.target === overlay) hideOverlay(); }};
|
|
1205
|
+
}}
|
|
1206
|
+
|
|
1207
|
+
function hideOverlay() {{
|
|
1208
|
+
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
|
1209
|
+
overlay = null;
|
|
1210
|
+
}}
|
|
1211
|
+
|
|
1212
|
+
function showToast(msg, type) {{
|
|
1213
|
+
if (config.enableToasts === false) return;
|
|
1214
|
+
var old = document.getElementById('__quantum_hr_toast');
|
|
1215
|
+
if (old) old.parentNode.removeChild(old);
|
|
1216
|
+
var t = document.createElement('div');
|
|
1217
|
+
t.id = '__quantum_hr_toast';
|
|
1218
|
+
var bg = {{success:'#4caf50',error:'#f44336',warning:'#ff9800',info:'#2196f3'}}[type] || '#2196f3';
|
|
1219
|
+
t.style.cssText = 'position:fixed;bottom:20px;right:20px;padding:12px 20px;background:' + bg + ';color:#fff;border-radius:4px;font-family:system-ui;font-size:14px;z-index:999998;box-shadow:0 2px 8px rgba(0,0,0,0.2);';
|
|
1220
|
+
t.textContent = msg;
|
|
1221
|
+
document.body.appendChild(t);
|
|
1222
|
+
setTimeout(function() {{ if (t.parentNode) {{ t.style.opacity = '0'; t.style.transition = 'opacity 0.3s'; setTimeout(function() {{ if (t.parentNode) t.parentNode.removeChild(t); }}, 300); }} }}, 2000);
|
|
1223
|
+
}}
|
|
1224
|
+
|
|
1225
|
+
function escapeHtml(text) {{
|
|
1226
|
+
var d = document.createElement('div');
|
|
1227
|
+
d.textContent = text;
|
|
1228
|
+
return d.innerHTML;
|
|
1229
|
+
}}
|
|
1230
|
+
|
|
1231
|
+
setInterval(function() {{
|
|
1232
|
+
if (socket && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({{type:'ping'}}));
|
|
1233
|
+
}}, 30000);
|
|
1234
|
+
|
|
1235
|
+
if (document.readyState === 'loading') {{
|
|
1236
|
+
document.addEventListener('DOMContentLoaded', function() {{ restoreState(); connect(); }});
|
|
1237
|
+
}} else {{
|
|
1238
|
+
restoreState(); connect();
|
|
1239
|
+
}}
|
|
1240
|
+
}})();
|
|
1241
|
+
</script>"""
|
|
1242
|
+
|
|
1243
|
+
# Attributes and API calls that mean a page actually needs htmx. Kept
|
|
1244
|
+
# deliberately broad — injecting an unused script is wasteful, but failing
|
|
1245
|
+
# to inject a needed one breaks the page, so ambiguity favours injecting.
|
|
1246
|
+
_HTMX_MARKERS = ('hx-', 'data-hx-', 'htmx.')
|
|
1247
|
+
|
|
1248
|
+
def _needs_htmx(self, html: str) -> bool:
|
|
1249
|
+
"""Whether this page uses htmx at all.
|
|
1250
|
+
|
|
1251
|
+
Every served page used to get `<script src="https://unpkg.com/...">`
|
|
1252
|
+
unconditionally. Measured across the repo: 5 of 268 .q files carry an
|
|
1253
|
+
htmx marker, so 98% of pages pulled a third-party CDN dependency they
|
|
1254
|
+
never touched — a runtime network requirement for an offline or
|
|
1255
|
+
homelab deployment, which is the audience the project declares
|
|
1256
|
+
(FRAMEWORK_PLAN Fase 0.3), and a script a restrictive CSP blocks
|
|
1257
|
+
outright.
|
|
1258
|
+
|
|
1259
|
+
A handful of other components (htmx_counter.q, htmx_click.q,
|
|
1260
|
+
htmx_add_todo.q) are htmx TARGETS — fragments returned into a page
|
|
1261
|
+
that drives them — and carry no markers of their own. They are served
|
|
1262
|
+
as partials, which never got the injection either way.
|
|
1263
|
+
"""
|
|
1264
|
+
return any(marker in html for marker in self._HTMX_MARKERS)
|
|
1265
|
+
|
|
1266
|
+
def _inject_htmx(self, html: str) -> str:
|
|
1267
|
+
"""
|
|
1268
|
+
Inject HTMX scripts into an existing full HTML document.
|
|
1269
|
+
|
|
1270
|
+
Used when the component already renders a complete HTML page
|
|
1271
|
+
(e.g. type="page" components), to avoid double-wrapping.
|
|
1272
|
+
|
|
1273
|
+
Args:
|
|
1274
|
+
html: Full HTML document string
|
|
1275
|
+
|
|
1276
|
+
Returns:
|
|
1277
|
+
HTML document with HTMX script and config injected, or unchanged
|
|
1278
|
+
when the page does not use htmx.
|
|
1279
|
+
"""
|
|
1280
|
+
if not self._needs_htmx(html):
|
|
1281
|
+
# Still inject hot reload, which is independent of htmx.
|
|
1282
|
+
return self._inject_scripts_only(html, self._get_hot_reload_script())
|
|
1283
|
+
|
|
1284
|
+
htmx_head = f'\n <script src="{self._htmx_url()}"></script>'
|
|
1285
|
+
|
|
1286
|
+
# Guarded: this used to call htmx.config directly, so a CDN that was
|
|
1287
|
+
# slow, blocked or offline turned into "htmx is not defined" on every
|
|
1288
|
+
# page — an error about the framework's own injected script, in an app
|
|
1289
|
+
# that may not use htmx at all.
|
|
1290
|
+
htmx_body = """
|
|
1291
|
+
<script>
|
|
1292
|
+
if (window.htmx) {
|
|
1293
|
+
htmx.config.defaultSwapStyle = "innerHTML";
|
|
1294
|
+
htmx.config.defaultSwapDelay = 0;
|
|
1295
|
+
htmx.config.historyCacheSize = 10;
|
|
1296
|
+
if (window.location.hostname === 'localhost') {
|
|
1297
|
+
htmx.logAll();
|
|
1298
|
+
}
|
|
1299
|
+
} else {
|
|
1300
|
+
console.warn('Quantum: this page uses htmx but the library did not load.');
|
|
1301
|
+
}
|
|
1302
|
+
</script>"""
|
|
1303
|
+
|
|
1304
|
+
# Add hot reload script if enabled
|
|
1305
|
+
hot_reload_script = self._get_hot_reload_script()
|
|
1306
|
+
|
|
1307
|
+
# Inject HTMX library before </head>
|
|
1308
|
+
if '</head>' in html:
|
|
1309
|
+
html = html.replace('</head>', htmx_head + '\n </head>', 1)
|
|
1310
|
+
elif '</HEAD>' in html:
|
|
1311
|
+
html = html.replace('</HEAD>', htmx_head + '\n </HEAD>', 1)
|
|
1312
|
+
|
|
1313
|
+
# Inject HTMX config and hot reload script before </body>
|
|
1314
|
+
return self._inject_scripts_only(html, htmx_body + hot_reload_script)
|
|
1315
|
+
|
|
1316
|
+
def _htmx_url(self) -> str:
|
|
1317
|
+
"""Where to load htmx from.
|
|
1318
|
+
|
|
1319
|
+
Prefers a vendored copy at static/vendor/htmx.min.js so an offline or
|
|
1320
|
+
air-gapped deployment works; falls back to the CDN. Vendoring the file
|
|
1321
|
+
is the remaining half of this fix — see DOGFOOD_NOTES.md item 8.
|
|
1322
|
+
"""
|
|
1323
|
+
vendored = Path(self.config['paths']['static']) / 'vendor' / 'htmx.min.js'
|
|
1324
|
+
if vendored.is_file():
|
|
1325
|
+
return '/static/vendor/htmx.min.js'
|
|
1326
|
+
return self.config.get('server', {}).get(
|
|
1327
|
+
'htmx_url', 'https://unpkg.com/htmx.org@1.9.10'
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
def _inject_scripts_only(self, html: str, scripts: str) -> str:
|
|
1331
|
+
"""Append scripts before </body> and guarantee a DOCTYPE."""
|
|
1332
|
+
if scripts:
|
|
1333
|
+
if '</body>' in html:
|
|
1334
|
+
html = html.replace('</body>', scripts + '\n </body>', 1)
|
|
1335
|
+
elif '</BODY>' in html:
|
|
1336
|
+
html = html.replace('</BODY>', scripts + '\n </BODY>', 1)
|
|
1337
|
+
|
|
1338
|
+
# Ensure DOCTYPE is present. A .q cannot emit one itself — a DOCTYPE is
|
|
1339
|
+
# only valid before the root element, and the root element is
|
|
1340
|
+
# <q:component>. See DOGFOOD_NOTES.md item 4.
|
|
1341
|
+
if not html.strip().lower().startswith('<!doctype'):
|
|
1342
|
+
html = '<!DOCTYPE html>\n' + html
|
|
1343
|
+
|
|
1344
|
+
return html
|
|
1345
|
+
|
|
1346
|
+
def _wrap_with_htmx(self, html: str, component_path: str, title: str = '') -> str:
|
|
1347
|
+
"""
|
|
1348
|
+
Wrap component HTML with HTMX library support (Phase B).
|
|
1349
|
+
|
|
1350
|
+
Adds:
|
|
1351
|
+
- HTMX library from CDN
|
|
1352
|
+
- Minimal CSS reset
|
|
1353
|
+
- HTMX configuration
|
|
1354
|
+
- Hot reload client (when enabled)
|
|
1355
|
+
|
|
1356
|
+
Args:
|
|
1357
|
+
html: Rendered component HTML
|
|
1358
|
+
component_path: Component name for title
|
|
1359
|
+
|
|
1360
|
+
Returns:
|
|
1361
|
+
Full HTML page with HTMX support
|
|
1362
|
+
"""
|
|
1363
|
+
hot_reload_script = self._get_hot_reload_script()
|
|
1364
|
+
|
|
1365
|
+
# Only when the fragment actually uses htmx — see _needs_htmx().
|
|
1366
|
+
#
|
|
1367
|
+
# The config block is emitted with the tag, not independently. Making
|
|
1368
|
+
# only the <script src> conditional left `htmx.config...` running on
|
|
1369
|
+
# every page with the library absent, which turned a page that merely
|
|
1370
|
+
# did not use htmx into a page that threw "htmx is not defined" — a
|
|
1371
|
+
# regression this path introduced and the full-document path did not,
|
|
1372
|
+
# because only that one had been examined.
|
|
1373
|
+
uses_htmx = self._needs_htmx(html)
|
|
1374
|
+
htmx_tag = (f'<script src="{self._htmx_url()}"></script>'
|
|
1375
|
+
if uses_htmx else '')
|
|
1376
|
+
htmx_config = """<script>
|
|
1377
|
+
if (window.htmx) {
|
|
1378
|
+
htmx.config.defaultSwapStyle = "innerHTML";
|
|
1379
|
+
htmx.config.defaultSwapDelay = 0;
|
|
1380
|
+
htmx.config.historyCacheSize = 10;
|
|
1381
|
+
if (window.location.hostname === 'localhost') {
|
|
1382
|
+
htmx.logAll();
|
|
1383
|
+
}
|
|
1384
|
+
} else {
|
|
1385
|
+
console.warn('Quantum: this page uses htmx but the library did not load.');
|
|
1386
|
+
}
|
|
1387
|
+
</script>""" if uses_htmx else ''
|
|
1388
|
+
|
|
1389
|
+
return f"""<!DOCTYPE html>
|
|
1390
|
+
<html lang="en">
|
|
1391
|
+
<head>
|
|
1392
|
+
<meta charset="UTF-8">
|
|
1393
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1394
|
+
<title>{html_escape(title or component_path)}</title>
|
|
1395
|
+
|
|
1396
|
+
{htmx_tag}
|
|
1397
|
+
|
|
1398
|
+
<style>
|
|
1399
|
+
/* Minimal CSS reset */
|
|
1400
|
+
* {{
|
|
1401
|
+
margin: 0;
|
|
1402
|
+
padding: 0;
|
|
1403
|
+
box-sizing: border-box;
|
|
1404
|
+
}}
|
|
1405
|
+
|
|
1406
|
+
body {{
|
|
1407
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
1408
|
+
line-height: 1.6;
|
|
1409
|
+
}}
|
|
1410
|
+
|
|
1411
|
+
/* HTMX Loading indicators */
|
|
1412
|
+
.htmx-indicator {{
|
|
1413
|
+
display: none;
|
|
1414
|
+
}}
|
|
1415
|
+
|
|
1416
|
+
.htmx-request .htmx-indicator {{
|
|
1417
|
+
display: inline;
|
|
1418
|
+
}}
|
|
1419
|
+
|
|
1420
|
+
.htmx-request.htmx-indicator {{
|
|
1421
|
+
display: inline;
|
|
1422
|
+
}}
|
|
1423
|
+
|
|
1424
|
+
/* Loading spinner */
|
|
1425
|
+
.htmx-indicator:after {{
|
|
1426
|
+
content: "⏳";
|
|
1427
|
+
margin-left: 8px;
|
|
1428
|
+
}}
|
|
1429
|
+
</style>
|
|
1430
|
+
</head>
|
|
1431
|
+
<body>
|
|
1432
|
+
{html}
|
|
1433
|
+
|
|
1434
|
+
{htmx_config}
|
|
1435
|
+
{hot_reload_script}
|
|
1436
|
+
</body>
|
|
1437
|
+
</html>"""
|
|
1438
|
+
|
|
1439
|
+
def _render_welcome_page(self) -> Response:
|
|
1440
|
+
"""
|
|
1441
|
+
Render welcome page when index.q doesn't exist.
|
|
1442
|
+
|
|
1443
|
+
Returns:
|
|
1444
|
+
Flask Response with welcome HTML
|
|
1445
|
+
"""
|
|
1446
|
+
components_dir = self.config['paths']['components']
|
|
1447
|
+
|
|
1448
|
+
html = f"""
|
|
1449
|
+
<!DOCTYPE html>
|
|
1450
|
+
<html lang="en">
|
|
1451
|
+
<head>
|
|
1452
|
+
<meta charset="UTF-8">
|
|
1453
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1454
|
+
<title>Welcome to Quantum</title>
|
|
1455
|
+
<style>
|
|
1456
|
+
body {{
|
|
1457
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
1458
|
+
max-width: 800px;
|
|
1459
|
+
margin: 50px auto;
|
|
1460
|
+
padding: 20px;
|
|
1461
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
1462
|
+
color: white;
|
|
1463
|
+
}}
|
|
1464
|
+
.container {{
|
|
1465
|
+
background: rgba(255, 255, 255, 0.1);
|
|
1466
|
+
backdrop-filter: blur(10px);
|
|
1467
|
+
border-radius: 15px;
|
|
1468
|
+
padding: 40px;
|
|
1469
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
1470
|
+
}}
|
|
1471
|
+
h1 {{ margin-top: 0; font-size: 3em; }}
|
|
1472
|
+
code {{
|
|
1473
|
+
background: rgba(0, 0, 0, 0.3);
|
|
1474
|
+
padding: 2px 8px;
|
|
1475
|
+
border-radius: 4px;
|
|
1476
|
+
font-family: 'Courier New', monospace;
|
|
1477
|
+
}}
|
|
1478
|
+
.box {{
|
|
1479
|
+
background: rgba(255, 255, 255, 0.1);
|
|
1480
|
+
padding: 20px;
|
|
1481
|
+
border-radius: 10px;
|
|
1482
|
+
margin: 20px 0;
|
|
1483
|
+
}}
|
|
1484
|
+
a {{
|
|
1485
|
+
color: #fff;
|
|
1486
|
+
text-decoration: underline;
|
|
1487
|
+
}}
|
|
1488
|
+
</style>
|
|
1489
|
+
</head>
|
|
1490
|
+
<body>
|
|
1491
|
+
<div class="container">
|
|
1492
|
+
<h1>🚀 Quantum is Running!</h1>
|
|
1493
|
+
<p>Your Quantum server is successfully running and ready to serve components.</p>
|
|
1494
|
+
|
|
1495
|
+
<div class="box">
|
|
1496
|
+
<h2>🎯 Quick Start</h2>
|
|
1497
|
+
<p>Create your first component:</p>
|
|
1498
|
+
<p><code>mkdir -p {components_dir}</code></p>
|
|
1499
|
+
<p><code>nano {components_dir}/index.q</code></p>
|
|
1500
|
+
<pre style="background: rgba(0,0,0,0.3); padding: 15px; border-radius: 8px;">
|
|
1501
|
+
<q:component name="HomePage">
|
|
1502
|
+
<html>
|
|
1503
|
+
<body>
|
|
1504
|
+
<h1>Hello from Quantum!</h1>
|
|
1505
|
+
<p>This is my first component.</p>
|
|
1506
|
+
</body>
|
|
1507
|
+
</html>
|
|
1508
|
+
</q:component></pre>
|
|
1509
|
+
<p>Then refresh this page!</p>
|
|
1510
|
+
</div>
|
|
1511
|
+
|
|
1512
|
+
<div class="box">
|
|
1513
|
+
<h2>📚 Learn More</h2>
|
|
1514
|
+
<ul>
|
|
1515
|
+
<li><a href="https://github.com/danielgregorio/quantum">Documentation</a></li>
|
|
1516
|
+
<li><a href="/examples">Example Components</a></li>
|
|
1517
|
+
<li><a href="/static">Static Files</a></li>
|
|
1518
|
+
</ul>
|
|
1519
|
+
</div>
|
|
1520
|
+
|
|
1521
|
+
<div class="box">
|
|
1522
|
+
<h2>⚙️ Configuration</h2>
|
|
1523
|
+
<p>Server is running with:</p>
|
|
1524
|
+
<ul>
|
|
1525
|
+
<li>Port: <code>{self.config['server']['port']}</code></li>
|
|
1526
|
+
<li>Components directory: <code>{components_dir}</code></li>
|
|
1527
|
+
<li>Debug mode: <code>{self.config['server'].get('debug', False)}</code></li>
|
|
1528
|
+
<li>Auto-reload: <code>{self.config['server'].get('reload', False)}</code></li>
|
|
1529
|
+
</ul>
|
|
1530
|
+
<p>Edit <code>quantum.config.yaml</code> to customize.</p>
|
|
1531
|
+
</div>
|
|
1532
|
+
</div>
|
|
1533
|
+
</body>
|
|
1534
|
+
</html>
|
|
1535
|
+
"""
|
|
1536
|
+
|
|
1537
|
+
return Response(html, mimetype='text/html')
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def _edit_cell(self, ast, path_params) -> Response:
|
|
1541
|
+
"""UI-13: the generated action of an editable <ui:table>.
|
|
1542
|
+
|
|
1543
|
+
It edits only a table and column the page declares editable, runs the
|
|
1544
|
+
page's guards first (AUTH-6), validates the value with the column's
|
|
1545
|
+
rules from the schema (UI-10) and updates one row by its key through
|
|
1546
|
+
the database service — so /_dev shows it like any action."""
|
|
1547
|
+
from quantum.runtime.table_params import TableParamsError, columns_of, param_from_column
|
|
1548
|
+
from quantum.runtime.ui_table_edit import EDIT_ACTION, find_editable_table
|
|
1549
|
+
from quantum.runtime.action_handler import ValidationError
|
|
1550
|
+
|
|
1551
|
+
tabela = request.form.get('__table', '')
|
|
1552
|
+
coluna = request.form.get('__column', '')
|
|
1553
|
+
chave = request.form.get('__key', '')
|
|
1554
|
+
valor = request.form.get('value')
|
|
1555
|
+
dev_note(action=f'{EDIT_ACTION} {tabela}.{coluna}')
|
|
1556
|
+
alvo = find_editable_table(ast, tabela, coluna)
|
|
1557
|
+
if alvo is None:
|
|
1558
|
+
return Response(f'<h1>400 Bad Request</h1><p>{html_escape(tabela)}.{html_escape(coluna)} '
|
|
1559
|
+
f'is not editable on this page.</p>', status=400, mimetype='text/html')
|
|
1560
|
+
|
|
1561
|
+
guard_runtime = ComponentRuntime(config=self.config)
|
|
1562
|
+
try:
|
|
1563
|
+
guard_runtime.run_guards(ast, self._page_params(path_params, consume_flash=False))
|
|
1564
|
+
except PageRedirect as r:
|
|
1565
|
+
return self._page_redirect(r, guard_runtime)
|
|
1566
|
+
|
|
1567
|
+
volta = request.referrer or request.path
|
|
1568
|
+
try:
|
|
1569
|
+
colunas = {c.name: c for c in columns_of(self.config, alvo.edit_datasource, tabela)}
|
|
1570
|
+
except TableParamsError as exc:
|
|
1571
|
+
raise ValueError(f'<ui:table edit="{tabela}">: {exc} (UI-13)') from exc
|
|
1572
|
+
chaves = [c for c in colunas.values() if c.primary_key]
|
|
1573
|
+
if coluna not in colunas or colunas[coluna].primary_key or len(chaves) != 1:
|
|
1574
|
+
return Response('<h1>400 Bad Request</h1>', status=400, mimetype='text/html')
|
|
1575
|
+
param = param_from_column(colunas[coluna])
|
|
1576
|
+
param.name = 'value'
|
|
1577
|
+
|
|
1578
|
+
class _Acao: # what the validation reads of an action: where FKs are
|
|
1579
|
+
table_datasource = alvo.edit_datasource
|
|
1580
|
+
try:
|
|
1581
|
+
validados = ActionHandler(guard_runtime)._validate_parameters(
|
|
1582
|
+
[param], {'value': valor} if valor is not None else {}, _Acao)
|
|
1583
|
+
except ValidationError as erro:
|
|
1584
|
+
self.action_handler._set_flash_message(str(erro), 'error')
|
|
1585
|
+
session['form_state'] = {'action': EDIT_ACTION, 'cell': f'{coluna}@{chave}',
|
|
1586
|
+
'errors': {'value': erro.fields.get('value', str(erro))},
|
|
1587
|
+
'values': {'value': valor or ''}}
|
|
1588
|
+
session.modified = True
|
|
1589
|
+
return redirect(volta)
|
|
1590
|
+
novo = validados.get('value')
|
|
1591
|
+
if str(param.type) == 'boolean':
|
|
1592
|
+
novo = 1 if str(novo).lower() in ('true', '1', 'on', 'yes') else 0
|
|
1593
|
+
chave_valor = int(chave) if 'INT' in (chaves[0].type or '').upper() and chave.lstrip('-').isdigit() else chave
|
|
1594
|
+
resultado = guard_runtime.services.database.execute_query(
|
|
1595
|
+
alvo.edit_datasource,
|
|
1596
|
+
f'UPDATE "{tabela}" SET "{coluna}" = :valor WHERE "{chaves[0].name}" = :chave',
|
|
1597
|
+
{'valor': novo, 'chave': chave_valor})
|
|
1598
|
+
if not resultado.affected_rows:
|
|
1599
|
+
self.action_handler._set_flash_message(f'No row of {tabela} with {chaves[0].name} = {chave}', 'error')
|
|
1600
|
+
else:
|
|
1601
|
+
self.action_handler._set_flash_message(f'Saved: {coluna}', 'success')
|
|
1602
|
+
return redirect(volta)
|
|
1603
|
+
|
|
1604
|
+
@staticmethod
|
|
1605
|
+
def _dev_secret_key(config_path: str) -> Optional[str]:
|
|
1606
|
+
"""The development session key, kept in .quantum/dev-secret-key next to the config.
|
|
1607
|
+
|
|
1608
|
+
Only with `debug: true` and no configured key. Without it the key was
|
|
1609
|
+
random per process, and the reloader starts a new process on every
|
|
1610
|
+
save — every session (a login) was lost each time a file changed.
|
|
1611
|
+
"""
|
|
1612
|
+
arquivo = Path(config_path).resolve().parent / '.quantum' / 'dev-secret-key'
|
|
1613
|
+
try:
|
|
1614
|
+
if arquivo.exists():
|
|
1615
|
+
chave = arquivo.read_text(encoding='utf-8').strip()
|
|
1616
|
+
if chave:
|
|
1617
|
+
return chave
|
|
1618
|
+
arquivo.parent.mkdir(parents=True, exist_ok=True)
|
|
1619
|
+
chave = secrets.token_hex(32)
|
|
1620
|
+
arquivo.write_text(chave, encoding='utf-8')
|
|
1621
|
+
try:
|
|
1622
|
+
os.chmod(arquivo, 0o600)
|
|
1623
|
+
except OSError:
|
|
1624
|
+
pass
|
|
1625
|
+
return chave
|
|
1626
|
+
except OSError:
|
|
1627
|
+
return None # read-only folder: a random key, as before
|
|
1628
|
+
|
|
1629
|
+
@staticmethod
|
|
1630
|
+
def _error_location(exc, default_file):
|
|
1631
|
+
"""DEV-2: (file, line) of the .q line an error comes from, or None."""
|
|
1632
|
+
from quantum.core.xml_lines import error_location
|
|
1633
|
+
arquivo, linha = error_location(exc)
|
|
1634
|
+
if not linha:
|
|
1635
|
+
return None
|
|
1636
|
+
return (arquivo or str(default_file), linha)
|
|
1637
|
+
|
|
1638
|
+
@staticmethod
|
|
1639
|
+
def _source_snippet(arquivo: str, linha: int, contexto: int = 3) -> str:
|
|
1640
|
+
"""The lines around `linha`, the failing one marked (DEV-2)."""
|
|
1641
|
+
try:
|
|
1642
|
+
linhas = Path(arquivo).read_text(encoding='utf-8').splitlines()
|
|
1643
|
+
except OSError:
|
|
1644
|
+
return ''
|
|
1645
|
+
if not 0 < linha <= len(linhas):
|
|
1646
|
+
return ''
|
|
1647
|
+
try:
|
|
1648
|
+
arquivo = os.path.relpath(arquivo)
|
|
1649
|
+
except ValueError:
|
|
1650
|
+
pass # another drive (Windows)
|
|
1651
|
+
partes = []
|
|
1652
|
+
for n in range(max(1, linha - contexto), min(len(linhas), linha + contexto) + 1):
|
|
1653
|
+
classe = ' class="marked"' if n == linha else ''
|
|
1654
|
+
partes.append(f'<tr{classe}><td class="ln">{n}</td>'
|
|
1655
|
+
f'<td><code>{html_escape(linhas[n - 1]) or " "}</code></td></tr>')
|
|
1656
|
+
return ('<div class="source"><div class="where">' + html_escape(arquivo) + f', line {linha}</div>'
|
|
1657
|
+
'<table>' + ''.join(partes) + '</table></div>')
|
|
1658
|
+
|
|
1659
|
+
def _render_error_page(self, title: str, message: str, details: str, suggestion: str,
|
|
1660
|
+
location=None, python_traceback: str = '') -> str:
|
|
1661
|
+
"""
|
|
1662
|
+
Render error page with helpful information.
|
|
1663
|
+
|
|
1664
|
+
Everything is escaped: an error message can carry what the request
|
|
1665
|
+
sent (a query value, a form field). In debug mode, the page also shows
|
|
1666
|
+
the .q lines around the one that failed (DEV-2), links the SPEC rules
|
|
1667
|
+
the message cites, and the Python traceback, folded.
|
|
1668
|
+
"""
|
|
1669
|
+
e = html_escape
|
|
1670
|
+
debug = self.config['server'].get('debug')
|
|
1671
|
+
trecho = self._source_snippet(*location) if (location and debug) else ''
|
|
1672
|
+
regras = ''
|
|
1673
|
+
if debug:
|
|
1674
|
+
ids = sorted(set(re.findall(r'\b(?:PARSE|RET|LOOP|IF|FN|ACT|ROUTE|COMP|DB|SET|AUTH|DATA|IA|EXPR|INV'
|
|
1675
|
+
r'|RUN|SCOPE|ERR|CFG|APP|SVC|UI|EXEC|DEV)-\d+\b',
|
|
1676
|
+
f'{message}\n{details}')))
|
|
1677
|
+
if ids:
|
|
1678
|
+
regras = '<p class="rules">Rules: ' + ', '.join(
|
|
1679
|
+
f'<a href="https://github.com/danielgregorio/quantum/blob/main/SPEC.md#:~:text={i}">{i}</a>'
|
|
1680
|
+
for i in ids) + '</p>'
|
|
1681
|
+
rastro = (f'<details class="trace"><summary>Python traceback</summary>'
|
|
1682
|
+
f'<div class="details">{e(python_traceback)}</div></details>') if (python_traceback and debug) else ''
|
|
1683
|
+
html = f"""
|
|
1684
|
+
<!DOCTYPE html>
|
|
1685
|
+
<html lang="en">
|
|
1686
|
+
<head>
|
|
1687
|
+
<meta charset="UTF-8">
|
|
1688
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1689
|
+
<title>{e(title)}</title>
|
|
1690
|
+
<style>
|
|
1691
|
+
body {{
|
|
1692
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
1693
|
+
max-width: 900px;
|
|
1694
|
+
margin: 50px auto;
|
|
1695
|
+
padding: 20px;
|
|
1696
|
+
background: #f5f5f5;
|
|
1697
|
+
}}
|
|
1698
|
+
.error-container {{
|
|
1699
|
+
background: white;
|
|
1700
|
+
border-radius: 10px;
|
|
1701
|
+
padding: 40px;
|
|
1702
|
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
1703
|
+
border-left: 5px solid #e74c3c;
|
|
1704
|
+
}}
|
|
1705
|
+
h1 {{
|
|
1706
|
+
color: #e74c3c;
|
|
1707
|
+
margin-top: 0;
|
|
1708
|
+
}}
|
|
1709
|
+
.details {{
|
|
1710
|
+
background: #f9f9f9;
|
|
1711
|
+
border: 1px solid #ddd;
|
|
1712
|
+
padding: 15px;
|
|
1713
|
+
border-radius: 5px;
|
|
1714
|
+
font-family: 'Courier New', monospace;
|
|
1715
|
+
white-space: pre-wrap;
|
|
1716
|
+
overflow-x: auto;
|
|
1717
|
+
}}
|
|
1718
|
+
.suggestion {{
|
|
1719
|
+
background: #e8f5e9;
|
|
1720
|
+
border-left: 4px solid #4caf50;
|
|
1721
|
+
padding: 15px;
|
|
1722
|
+
margin-top: 20px;
|
|
1723
|
+
}}
|
|
1724
|
+
.source {{ margin: 16px 0; border: 1px solid #ddd; border-radius: 5px; overflow-x: auto; }}
|
|
1725
|
+
.source .where {{ background: #f0f0f0; padding: 6px 10px; font-size: 13px; color: #555; }}
|
|
1726
|
+
.source table {{ border-collapse: collapse; width: 100%; }}
|
|
1727
|
+
.source td {{ padding: 1px 10px; font: 13px/1.5 ui-monospace, 'Courier New', monospace; white-space: pre; }}
|
|
1728
|
+
.source td.ln {{ color: #999; text-align: right; width: 1%; user-select: none; }}
|
|
1729
|
+
.source tr.marked td {{ background: #fdecea; }}
|
|
1730
|
+
.source tr.marked td.ln {{ color: #c0392b; font-weight: bold; }}
|
|
1731
|
+
.trace {{ margin-top: 16px; }}
|
|
1732
|
+
@media (max-width: 600px) {{ body {{ margin: 0; padding: 12px; }} .error-container {{ padding: 16px; }} }}
|
|
1733
|
+
</style>
|
|
1734
|
+
</head>
|
|
1735
|
+
<body>
|
|
1736
|
+
<div class="error-container">
|
|
1737
|
+
<h1>⚠️ {e(title)}</h1>
|
|
1738
|
+
<p><strong>{e(message)}</strong></p>
|
|
1739
|
+
|
|
1740
|
+
{trecho}
|
|
1741
|
+
|
|
1742
|
+
{f'<div class="details">{e(details)}</div>' if details else ''}
|
|
1743
|
+
|
|
1744
|
+
{regras}
|
|
1745
|
+
|
|
1746
|
+
{f'<div class="suggestion"><strong>💡 Suggestion:</strong> {e(suggestion)}</div>' if suggestion else ''}
|
|
1747
|
+
|
|
1748
|
+
{rastro}
|
|
1749
|
+
|
|
1750
|
+
<p style="margin-top: 30px;">
|
|
1751
|
+
<a href="/">← Go back to home</a>
|
|
1752
|
+
</p>
|
|
1753
|
+
</div>
|
|
1754
|
+
</body>
|
|
1755
|
+
</html>
|
|
1756
|
+
"""
|
|
1757
|
+
|
|
1758
|
+
return html
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
def _page_params(self, path_params, consume_flash: bool = True) -> Dict[str, Any]:
|
|
1762
|
+
"""The variables and scopes a page runs with (render, and guards before an action)."""
|
|
1763
|
+
# Prepare request parameters for component execution
|
|
1764
|
+
params = {}
|
|
1765
|
+
|
|
1766
|
+
# Add query parameters
|
|
1767
|
+
query_params = dict(request.args)
|
|
1768
|
+
if query_params:
|
|
1769
|
+
params['query'] = query_params
|
|
1770
|
+
|
|
1771
|
+
# Add form data if POST request (for non-action processing)
|
|
1772
|
+
if request.method == 'POST':
|
|
1773
|
+
form_data = dict(request.form)
|
|
1774
|
+
params['form'] = form_data
|
|
1775
|
+
|
|
1776
|
+
# Get flash message if present
|
|
1777
|
+
# ACT-3: always defined — '' when there is no message — so a page can
|
|
1778
|
+
# pass {flash} to a layout component without failing when there is
|
|
1779
|
+
# none (a missing name in a prop is an error, COMP-2).
|
|
1780
|
+
# Guards before an action read it without taking it from the next page.
|
|
1781
|
+
flash_data = (self.action_handler.get_flash_message() if consume_flash
|
|
1782
|
+
else session.get('flash'))
|
|
1783
|
+
params['flash'] = flash_data['message'] if flash_data else ''
|
|
1784
|
+
params['flashType'] = flash_data['type'] if flash_data else ''
|
|
1785
|
+
|
|
1786
|
+
# Phase F: Setup scopes for ExecutionContext
|
|
1787
|
+
# Session scope - user-specific, persistent (from Flask session)
|
|
1788
|
+
if 'quantum_session' not in session:
|
|
1789
|
+
session['quantum_session'] = {}
|
|
1790
|
+
params['_session_scope'] = session['quantum_session']
|
|
1791
|
+
|
|
1792
|
+
# Application scope - global, shared across all users
|
|
1793
|
+
params['_application_scope'] = self.application_scope
|
|
1794
|
+
|
|
1795
|
+
# Request scope - request-specific, cleared after response
|
|
1796
|
+
params['_request_scope'] = {
|
|
1797
|
+
'method': request.method,
|
|
1798
|
+
'path': request.path,
|
|
1799
|
+
'url': request.url
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
# Inject dynamic route path parameters (e.g. [name] -> name="blog")
|
|
1803
|
+
if path_params:
|
|
1804
|
+
params['path_params'] = path_params
|
|
1805
|
+
for key, value in path_params.items():
|
|
1806
|
+
params[key] = value
|
|
1807
|
+
return params
|
|
1808
|
+
|
|
1809
|
+
def _page_redirect(self, r, runtime=None):
|
|
1810
|
+
"""Turn a q:redirect outside an action (PageRedirect) into the response."""
|
|
1811
|
+
if runtime is not None:
|
|
1812
|
+
# A logout page clears the session and redirects: keep what it set.
|
|
1813
|
+
session['quantum_session'] = runtime.execution_context.session_vars
|
|
1814
|
+
if r.flash:
|
|
1815
|
+
self.action_handler._set_flash_message(r.flash, r.flash_type)
|
|
1816
|
+
session.modified = True
|
|
1817
|
+
return redirect(r.url, code=r.status)
|
|
1818
|
+
|
|
1819
|
+
def _find_action_in_component(self, ast) -> Optional[ActionNode]:
|
|
1820
|
+
"""
|
|
1821
|
+
Find q:action statement in component AST.
|
|
1822
|
+
|
|
1823
|
+
When a form submits a hidden field named 'action' with the action name,
|
|
1824
|
+
this method matches it to the correct ActionNode. If no 'action' field
|
|
1825
|
+
is provided or no match is found, falls back to the first ActionNode.
|
|
1826
|
+
|
|
1827
|
+
Args:
|
|
1828
|
+
ast: Component AST
|
|
1829
|
+
|
|
1830
|
+
Returns:
|
|
1831
|
+
ActionNode if found, None otherwise
|
|
1832
|
+
"""
|
|
1833
|
+
# Check if component has statements
|
|
1834
|
+
if not hasattr(ast, 'statements'):
|
|
1835
|
+
return None
|
|
1836
|
+
|
|
1837
|
+
# Collect all ActionNodes
|
|
1838
|
+
action_nodes = [s for s in ast.statements if isinstance(s, ActionNode)]
|
|
1839
|
+
|
|
1840
|
+
if not action_nodes:
|
|
1841
|
+
return None
|
|
1842
|
+
|
|
1843
|
+
# If only one action, return it directly
|
|
1844
|
+
if len(action_nodes) == 1:
|
|
1845
|
+
return action_nodes[0]
|
|
1846
|
+
|
|
1847
|
+
# Multiple actions: match by 'action' form field (ACT-1)
|
|
1848
|
+
requested_action = request.form.get('action', '')
|
|
1849
|
+
for node in action_nodes:
|
|
1850
|
+
if requested_action and getattr(node, 'name', '') == requested_action:
|
|
1851
|
+
return node
|
|
1852
|
+
|
|
1853
|
+
# ACT-5: no silent fallback. This used to run the FIRST action when the
|
|
1854
|
+
# name was missing or matched nothing — a POST meant for "excluir" with
|
|
1855
|
+
# a typo ran "criar".
|
|
1856
|
+
available = ', '.join(getattr(n, 'name', '?') for n in action_nodes)
|
|
1857
|
+
if requested_action:
|
|
1858
|
+
raise UnknownActionError(
|
|
1859
|
+
f"No q:action named '{requested_action}' on this page. Available: {available}.")
|
|
1860
|
+
raise UnknownActionError(
|
|
1861
|
+
f"This page has several q:action ({available}); the form must say which one "
|
|
1862
|
+
f"with a field named 'action'.")
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
def _count_component_files(self) -> int:
|
|
1866
|
+
"""Count .q files in the components directory."""
|
|
1867
|
+
components_dir = Path(self.config['paths']['components'])
|
|
1868
|
+
if not components_dir.is_dir():
|
|
1869
|
+
return 0
|
|
1870
|
+
return sum(1 for _ in components_dir.rglob('*.q'))
|
|
1871
|
+
|
|
1872
|
+
def _count_routes(self):
|
|
1873
|
+
"""Return (static_count, dynamic_count) of registered routes."""
|
|
1874
|
+
static_count = len([r for r in self.app.url_map.iter_rules()
|
|
1875
|
+
if '<' not in str(r)])
|
|
1876
|
+
dynamic_count = len(self._dynamic_routes)
|
|
1877
|
+
return static_count, dynamic_count
|
|
1878
|
+
|
|
1879
|
+
def _print_banner(self):
|
|
1880
|
+
"""Print startup banner with server information."""
|
|
1881
|
+
port = self.config['server']['port']
|
|
1882
|
+
components_dir = self.config['paths']['components']
|
|
1883
|
+
log_config = self.config.get('logging', {})
|
|
1884
|
+
startup_ms = (time.monotonic() - self._start_time) * 1000
|
|
1885
|
+
file_count = self._count_component_files()
|
|
1886
|
+
static_routes, dynamic_routes = self._count_routes()
|
|
1887
|
+
|
|
1888
|
+
print()
|
|
1889
|
+
print("=" * 60)
|
|
1890
|
+
print(" QUANTUM WEB SERVER")
|
|
1891
|
+
print("=" * 60)
|
|
1892
|
+
print(f" URL: http://localhost:{port}")
|
|
1893
|
+
print(f" Components: {components_dir} ({file_count} files)")
|
|
1894
|
+
print(f" Routes: {static_routes} static + {dynamic_routes} dynamic")
|
|
1895
|
+
print(f" Log level: {log_config.get('level', 'INFO')}")
|
|
1896
|
+
if log_config.get('file', True):
|
|
1897
|
+
print(f" Log file: {log_config.get('filename', 'quantum.log')}")
|
|
1898
|
+
print(f" Auto-reload: {self.config['server'].get('reload', False)}")
|
|
1899
|
+
print(f" Debug mode: {self.config['server'].get('debug', False)}")
|
|
1900
|
+
if self.dev_panel is not None:
|
|
1901
|
+
print(f" Dev panel: http://localhost:{port}/_dev")
|
|
1902
|
+
if self.hot_reload_enabled:
|
|
1903
|
+
print(f" Hot Reload: ws://localhost:{self.hot_reload_port}")
|
|
1904
|
+
print(f" Startup time: {startup_ms:.0f}ms")
|
|
1905
|
+
print("=" * 60)
|
|
1906
|
+
print(" Press Ctrl+C to stop")
|
|
1907
|
+
print("=" * 60)
|
|
1908
|
+
print()
|
|
1909
|
+
|
|
1910
|
+
def _is_within_components(self, component_path: str) -> bool:
|
|
1911
|
+
"""True only if `<components_dir>/<component_path>.q` stays inside the
|
|
1912
|
+
components directory after resolving `..`, symlinks and separators.
|
|
1913
|
+
|
|
1914
|
+
Also guards the index.q and dynamic-route lookups, since both derive
|
|
1915
|
+
their path from the same untrusted `component_path`.
|
|
1916
|
+
"""
|
|
1917
|
+
# Reject the obvious traversal tokens outright — cheaper than resolving,
|
|
1918
|
+
# and covers the raw-socket `..` and backslash cases on Windows.
|
|
1919
|
+
if '..' in component_path.replace('\\', '/').split('/'):
|
|
1920
|
+
return False
|
|
1921
|
+
try:
|
|
1922
|
+
root = Path(self.config['paths']['components']).resolve()
|
|
1923
|
+
target = (root / f'{component_path}.q').resolve()
|
|
1924
|
+
except (OSError, ValueError, RuntimeError):
|
|
1925
|
+
return False
|
|
1926
|
+
# Python 3.9+: is_relative_to. The candidate must sit under the root.
|
|
1927
|
+
return target == root or root in target.parents
|
|
1928
|
+
|
|
1929
|
+
def _check_port_available(self, host: str, port: int) -> bool:
|
|
1930
|
+
"""Check if a port is available for binding."""
|
|
1931
|
+
try:
|
|
1932
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
1933
|
+
s.settimeout(1)
|
|
1934
|
+
result = s.connect_ex(('127.0.0.1' if host == '0.0.0.0' else host, port))
|
|
1935
|
+
return result != 0 # 0 means connection succeeded → port in use
|
|
1936
|
+
except OSError:
|
|
1937
|
+
return True # If we can't connect, assume port is free
|
|
1938
|
+
|
|
1939
|
+
def _login_url(self) -> str:
|
|
1940
|
+
"""AUTH-4: where require_auth sends a visitor (security.login_url, default /login)."""
|
|
1941
|
+
# Validated at startup by _validate_config (a local path only).
|
|
1942
|
+
return ((self.config.get('security') or {}).get('login_url') or '/login').strip()
|
|
1943
|
+
|
|
1944
|
+
def _write_pid_file(self, is_reloader_child: bool = False):
|
|
1945
|
+
"""Write .quantum.pid: this process, plus the parent under the reloader.
|
|
1946
|
+
|
|
1947
|
+
With `reload: true` Werkzeug serves from a CHILD process. Recording
|
|
1948
|
+
only the parent meant `quantum stop` killed the parent and left the
|
|
1949
|
+
child listening on the port. `quantum stop` reads every line.
|
|
1950
|
+
"""
|
|
1951
|
+
pids = [os.getppid(), os.getpid()] if is_reloader_child else [os.getpid()]
|
|
1952
|
+
try:
|
|
1953
|
+
Path(self.PID_FILE).write_text(
|
|
1954
|
+
'\n'.join(map(str, pids)) + '\n', encoding='utf-8')
|
|
1955
|
+
except OSError as e:
|
|
1956
|
+
self.logger.warning(f"Could not write PID file: {e}")
|
|
1957
|
+
|
|
1958
|
+
def _remove_pid_file(self):
|
|
1959
|
+
"""Remove .quantum.pid if it exists.
|
|
1960
|
+
|
|
1961
|
+
Only the process that started the server owns the file. The reloader
|
|
1962
|
+
child exits with code 3 on EVERY code change to be restarted, and its
|
|
1963
|
+
cleanup used to delete the file — so after the first hot reload
|
|
1964
|
+
`quantum stop` reported "No running server found" while it was up.
|
|
1965
|
+
"""
|
|
1966
|
+
if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
|
|
1967
|
+
return
|
|
1968
|
+
try:
|
|
1969
|
+
Path(self.PID_FILE).unlink(missing_ok=True)
|
|
1970
|
+
except OSError:
|
|
1971
|
+
pass
|
|
1972
|
+
|
|
1973
|
+
def _cleanup(self):
|
|
1974
|
+
"""Cleanup on shutdown: remove PID file, log shutdown."""
|
|
1975
|
+
self._remove_pid_file()
|
|
1976
|
+
self.logger.info("Server stopped")
|
|
1977
|
+
|
|
1978
|
+
def _register_signal_handlers(self):
|
|
1979
|
+
"""Register signal handlers for graceful shutdown."""
|
|
1980
|
+
def _handler(signum, frame):
|
|
1981
|
+
self._cleanup()
|
|
1982
|
+
sys.exit(0)
|
|
1983
|
+
|
|
1984
|
+
signal.signal(signal.SIGINT, _handler)
|
|
1985
|
+
signal.signal(signal.SIGTERM, _handler)
|
|
1986
|
+
# Windows-specific: CTRL_BREAK_EVENT
|
|
1987
|
+
if hasattr(signal, 'SIGBREAK'):
|
|
1988
|
+
signal.signal(signal.SIGBREAK, _handler)
|
|
1989
|
+
|
|
1990
|
+
def start(self):
|
|
1991
|
+
"""Start the Quantum web server."""
|
|
1992
|
+
host = self.config['server']['host']
|
|
1993
|
+
port = self.config['server']['port']
|
|
1994
|
+
debug = self.config['server'].get('debug', False)
|
|
1995
|
+
reload = self.config['server'].get('reload', False)
|
|
1996
|
+
|
|
1997
|
+
# The Werkzeug debugger is an eval console. Never expose it on a
|
|
1998
|
+
# non-loopback interface, even if the config asks for it — that is
|
|
1999
|
+
# unauthenticated RCE over the network. Drop the debugger rather than
|
|
2000
|
+
# refusing to start, so a misconfiguration degrades safely.
|
|
2001
|
+
if debug and host not in ('127.0.0.1', 'localhost', '::1'):
|
|
2002
|
+
self.logger.error(
|
|
2003
|
+
f"Refusing to run the debugger (debug: true) on {host}: the "
|
|
2004
|
+
f"Werkzeug console would be an eval prompt open to the network. "
|
|
2005
|
+
f"Serving with the debugger OFF. Use host 127.0.0.1 for a local "
|
|
2006
|
+
f"debug session."
|
|
2007
|
+
)
|
|
2008
|
+
debug = False
|
|
2009
|
+
|
|
2010
|
+
# Werkzeug's reloader re-executes this whole program in a child
|
|
2011
|
+
# process and sets WERKZEUG_RUN_MAIN in it. Without this guard the
|
|
2012
|
+
# child ran the port check, found the PARENT's own socket bound,
|
|
2013
|
+
# reported "port already in use" and returned — so `quantum start`
|
|
2014
|
+
# printed its success banner and then died, with nothing listening.
|
|
2015
|
+
# `reload: true` is the shipped default in quantum.config.yaml, which
|
|
2016
|
+
# means the documented way to start the server could not start it.
|
|
2017
|
+
is_reloader_child = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
|
|
2018
|
+
|
|
2019
|
+
if not is_reloader_child and not self._check_port_available(host, port):
|
|
2020
|
+
self.logger.error(
|
|
2021
|
+
f"Port {port} already in use. Stop the other process, or set "
|
|
2022
|
+
f"server.port in quantum.config.yaml."
|
|
2023
|
+
)
|
|
2024
|
+
# Non-zero: a supervisor, container healthcheck or CI step must be
|
|
2025
|
+
# able to tell a dead server from a live one. This used to return
|
|
2026
|
+
# None, so the CLI exited 0 while nothing was listening.
|
|
2027
|
+
return 1
|
|
2028
|
+
|
|
2029
|
+
self._write_pid_file(is_reloader_child)
|
|
2030
|
+
self._register_signal_handlers()
|
|
2031
|
+
self._print_banner()
|
|
2032
|
+
|
|
2033
|
+
try:
|
|
2034
|
+
self.app.run(
|
|
2035
|
+
host=host,
|
|
2036
|
+
port=port,
|
|
2037
|
+
debug=debug,
|
|
2038
|
+
use_reloader=reload
|
|
2039
|
+
)
|
|
2040
|
+
except KeyboardInterrupt:
|
|
2041
|
+
self.logger.info("Stopped by user")
|
|
2042
|
+
except OSError as e:
|
|
2043
|
+
# Losing the bind after the banner printed is the case most likely
|
|
2044
|
+
# to be mistaken for success.
|
|
2045
|
+
self.logger.error(f"Could not bind {host}:{port} — {e}")
|
|
2046
|
+
return 1
|
|
2047
|
+
except Exception as e:
|
|
2048
|
+
self.logger.error(f"Server error: {e}")
|
|
2049
|
+
return 1
|
|
2050
|
+
finally:
|
|
2051
|
+
self._cleanup()
|
|
2052
|
+
return 0
|
|
2053
|
+
|
|
2054
|
+
|
|
2055
|
+
# Convenience function for CLI
|
|
2056
|
+
def start_server(
|
|
2057
|
+
config_path: str = 'quantum.config.yaml',
|
|
2058
|
+
port: int = None,
|
|
2059
|
+
hot_reload: bool = False,
|
|
2060
|
+
hot_reload_port: int = None
|
|
2061
|
+
):
|
|
2062
|
+
"""
|
|
2063
|
+
Start Quantum web server.
|
|
2064
|
+
|
|
2065
|
+
Args:
|
|
2066
|
+
config_path: Path to configuration file
|
|
2067
|
+
port: Override port from config (optional)
|
|
2068
|
+
hot_reload: Enable hot reload mode
|
|
2069
|
+
hot_reload_port: WebSocket port for hot reload
|
|
2070
|
+
"""
|
|
2071
|
+
server = QuantumWebServer(
|
|
2072
|
+
config_path,
|
|
2073
|
+
hot_reload=hot_reload,
|
|
2074
|
+
hot_reload_port=hot_reload_port
|
|
2075
|
+
)
|
|
2076
|
+
|
|
2077
|
+
# Override port if specified
|
|
2078
|
+
if port is not None:
|
|
2079
|
+
server.config['server']['port'] = port
|
|
2080
|
+
|
|
2081
|
+
return server.start()
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
def create_app(config_path: str = 'quantum.config.yaml') -> Flask:
|
|
2085
|
+
"""
|
|
2086
|
+
Application factory for a real WSGI server. This is the production path.
|
|
2087
|
+
|
|
2088
|
+
gunicorn 'quantum.runtime.web_server:create_app()' \
|
|
2089
|
+
--bind 0.0.0.0:8080 --workers 4
|
|
2090
|
+
|
|
2091
|
+
(The example here used to say `src.runtime.web_server`, the layout that
|
|
2092
|
+
stopped existing when src/ became the quantum/ package — so the one
|
|
2093
|
+
documented production entry point did not import.)
|
|
2094
|
+
|
|
2095
|
+
`quantum start` runs Flask's DEVELOPMENT server: single-process, not
|
|
2096
|
+
hardened, and now bound to loopback by default. Use it while writing the
|
|
2097
|
+
app, not to serve it.
|
|
2098
|
+
|
|
2099
|
+
Two things to set when you deploy behind gunicorn:
|
|
2100
|
+
|
|
2101
|
+
- QUANTUM_SECRET_KEY, or security.secret_key in the config. Without it
|
|
2102
|
+
each worker generates its OWN random key at import, so a session cookie
|
|
2103
|
+
signed by one worker is rejected by the next and users are logged out at
|
|
2104
|
+
random.
|
|
2105
|
+
- server.host: 0.0.0.0 in the config if the container needs it. Leave
|
|
2106
|
+
server.debug false — the server refuses the debugger off loopback
|
|
2107
|
+
anyway, because it is an eval console.
|
|
2108
|
+
|
|
2109
|
+
Args:
|
|
2110
|
+
config_path: Path to configuration file
|
|
2111
|
+
|
|
2112
|
+
Returns:
|
|
2113
|
+
Flask application instance
|
|
2114
|
+
"""
|
|
2115
|
+
server = QuantumWebServer(config_path)
|
|
2116
|
+
|
|
2117
|
+
if not os.environ.get('QUANTUM_SECRET_KEY') and not (
|
|
2118
|
+
server.config.get('security', {}) or {}
|
|
2119
|
+
).get('secret_key'):
|
|
2120
|
+
server.logger.warning(
|
|
2121
|
+
"No QUANTUM_SECRET_KEY and no security.secret_key: this worker "
|
|
2122
|
+
"generated a random session key at import. With more than one "
|
|
2123
|
+
"worker, session cookies signed by one are rejected by the others "
|
|
2124
|
+
"and users are logged out at random. Set one before serving."
|
|
2125
|
+
)
|
|
2126
|
+
|
|
2127
|
+
return server.app
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
if __name__ == '__main__':
|
|
2131
|
+
start_server()
|