浏览代码

Merge branch 'main' into fly-replay

# Conflicts:
#	nicegui/client.py
#	nicegui/globals.py
Falko Schindler 1 年之前
父节点
当前提交
50ed10e2f4

+ 0 - 1
.vscode/settings.json

@@ -1,7 +1,6 @@
 {
   "editor.defaultFormatter": "esbenp.prettier-vscode",
   "editor.formatOnSave": true,
-  "editor.minimap.enabled": false,
   "isort.args": ["--line-length", "120"],
   "prettier.printWidth": 120,
   "python.formatting.provider": "autopep8",

+ 6 - 1
nicegui/client.py

@@ -76,12 +76,16 @@ class Client:
         return templates.TemplateResponse('index.html', {
             'request': request,
             'version': __version__,
-            'elements': elements,
+            'elements': elements.replace('&', '&')
+                                .replace('<', '&lt;')
+                                .replace('>', '&gt;')
+                                .replace('`', '&#96;'),
             'head_html': self.head_html,
             'body_html': '<style>' + '\n'.join(vue_styles) + '</style>\n' + self.body_html + '\n' + '\n'.join(vue_html),
             'vue_scripts': '\n'.join(vue_scripts),
             'imports': json.dumps(imports),
             'js_imports': '\n'.join(js_imports),
+            'quasar_config': json.dumps(globals.quasar_config),
             'title': self.page.resolve_title(),
             'viewport': self.page.resolve_viewport(),
             'favicon_url': get_favicon_url(self.page, prefix),
@@ -89,6 +93,7 @@ class Client:
             'language': self.page.resolve_language(),
             'prefix': prefix,
             'tailwind': globals.tailwind,
+            'prod_js': globals.prod_js,
             'socket_io_js_query_params': socket_io_js_query_params,
             'socket_io_js_extra_headers': globals.socket_io_js_extra_headers,
             'socket_io_js_transports': globals.socket_io_js_transports,

+ 1 - 1
nicegui/elements/query.js

@@ -13,7 +13,7 @@ export default {
     },
     add_style(style) {
       Object.entries(style).forEach(([key, val]) =>
-        document.querySelectorAll(this.selector).forEach((e) => (e.style[key] = val))
+        document.querySelectorAll(this.selector).forEach((e) => e.style.setProperty(key, val))
       );
     },
     remove_style(keys) {

+ 11 - 1
nicegui/globals.py

@@ -43,16 +43,26 @@ dark: Optional[bool]
 language: Language
 binding_refresh_interval: float
 tailwind: bool
+prod_js: bool
+endpoint_documentation: Literal['none', 'internal', 'page', 'all'] = 'none'
 air: Optional['Air'] = None
 socket_io_js_query_params: Dict = {}
 socket_io_js_extra_headers: Dict = {}
 # NOTE we favor websocket over polling
 socket_io_js_transports: List[Literal['websocket', 'polling']] = ['websocket', 'polling']
-
 _socket_id: Optional[str] = None
 slot_stacks: Dict[int, List['Slot']] = {}
 clients: Dict[str, 'Client'] = {}
 index_client: 'Client'
+quasar_config: Dict = {
+    'brand': {
+        'primary': '#5898d4',
+    },
+    'loadingBar': {
+        'color': 'primary',
+        'skipHijack': False,
+    },
+}
 
 page_routes: Dict[Callable[..., Any], str] = {}
 

+ 3 - 0
nicegui/page.py

@@ -107,6 +107,9 @@ class page:
             parameters.insert(0, request)
         decorated.__signature__ = inspect.Signature(parameters)
 
+        if 'include_in_schema' not in self.kwargs:
+            self.kwargs['include_in_schema'] = globals.endpoint_documentation in {'page', 'all'}
+
         self.api_router.get(self._path, **self.kwargs)(decorated)
         globals.page_routes[func] = self.path
         return func

+ 12 - 0
nicegui/run.py

@@ -53,6 +53,8 @@ def run(*,
         uvicorn_reload_includes: str = '*.py',
         uvicorn_reload_excludes: str = '.*, .py[cod], .sw.*, ~*',
         tailwind: bool = True,
+        prod_js: bool = True,
+        endpoint_documentation: Literal['none', 'internal', 'page', 'all'] = 'none',
         storage_secret: Optional[str] = None,
         **kwargs: Any,
         ) -> None:
@@ -79,6 +81,8 @@ def run(*,
     :param uvicorn_reload_includes: string with comma-separated list of glob-patterns which trigger reload on modification (default: `'.py'`)
     :param uvicorn_reload_excludes: string with comma-separated list of glob-patterns which should be ignored for reload (default: `'.*, .py[cod], .sw.*, ~*'`)
     :param tailwind: whether to use Tailwind (experimental, default: `True`)
+    :param prod_js: whether to use the production version of Vue and Quasar dependencies (default: `True`)
+    :param endpoint_documentation: control what endpoints appear in the autogenerated OpenAPI docs (default: 'none', options: 'none', 'internal', 'page', 'all')
     :param storage_secret: secret key for browser based storage (default: `None`, a value is required to enable ui.storage.individual and ui.storage.browser)
     :param kwargs: additional keyword arguments are passed to `uvicorn.run`    
     '''
@@ -91,6 +95,14 @@ def run(*,
     globals.language = language
     globals.binding_refresh_interval = binding_refresh_interval
     globals.tailwind = tailwind
+    globals.prod_js = prod_js
+    globals.endpoint_documentation = endpoint_documentation
+
+    for route in globals.app.routes:
+        if route.path.startswith('/_nicegui') and hasattr(route, 'methods'):
+            route.include_in_schema = endpoint_documentation in {'internal', 'all'}
+        if route.path == '/' or route.path in globals.page_routes.values():
+            route.include_in_schema = endpoint_documentation in {'page', 'all'}
 
     if on_air:
         globals.air = Air('' if on_air is True else on_air)

+ 4 - 1
nicegui/run_with.py

@@ -18,6 +18,8 @@ def run_with(
     language: Language = 'en-US',
     binding_refresh_interval: float = 0.1,
     mount_path: str = '/',
+    tailwind: bool = True,
+    prod_js: bool = True,
     storage_secret: Optional[str] = None,
 ) -> None:
     globals.ui_run_has_been_called = True
@@ -27,7 +29,8 @@ def run_with(
     globals.dark = dark
     globals.language = language
     globals.binding_refresh_interval = binding_refresh_interval
-    globals.tailwind = True
+    globals.tailwind = tailwind
+    globals.prod_js = prod_js
 
     set_storage_secret(storage_secret)
     app.on_event('startup')(lambda: handle_startup(with_welcome_message=False))

+ 19 - 9
nicegui/templates/index.html

@@ -6,7 +6,12 @@
     <link href="{{ favicon_url }}" rel="shortcut icon" />
     <link href="{{ prefix | safe }}/_nicegui/{{version}}/static/nicegui.css" rel="stylesheet" type="text/css" />
     <link href="{{ prefix | safe }}/_nicegui/{{version}}/static/fonts.css" rel="stylesheet" type="text/css" />
+    {% if prod_js %}
     <link href="{{ prefix | safe }}/_nicegui/{{version}}/static/quasar.prod.css" rel="stylesheet" type="text/css" />
+    {% else %}
+    <link href="{{ prefix | safe }}/_nicegui/{{version}}/static/quasar.css" rel="stylesheet" type="text/css" />
+    {% endif %}
+    <!-- prevent Prettier from removing this line -->
     {{ head_html | safe }}
   </head>
   <body>
@@ -15,8 +20,15 @@
     {% if tailwind %}
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/tailwindcss.min.js"></script>
     {% endif %}
+    <!-- prevent Prettier from removing this line -->
+    {% if prod_js %}
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/vue.global.prod.js"></script>
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/quasar.umd.prod.js"></script>
+    {% else %}
+    <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/vue.global.js"></script>
+    <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/quasar.umd.js"></script>
+    {% endif %}
+
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/lang/{{ language }}.umd.prod.js"></script>
     <script type="importmap">
       {"imports": {{ imports | safe }}}
@@ -41,7 +53,12 @@
 
       const loaded_libraries = new Set();
       const loaded_components = new Set();
-      const elements = {{ elements | safe }};
+
+      const raw_elements = String.raw`{{ elements | safe }}`;
+      const elements = JSON.parse(raw_elements.replace(/&#96;/g, '`')
+                                              .replace(/&gt;/g, '>')
+                                              .replace(/&lt;/g, '<')
+                                              .replace(/&amp;/g, '&'));
 
       function stringifyEventArgs(args, event_args) {
         const result = [];
@@ -298,14 +315,7 @@
           }
         },
       }).use(Quasar, {
-        config: {
-          brand: {
-            primary: '#5898d4',
-          },
-          loadingBar: {
-            color: 'primary'
-          },
-        }
+        config: {{ quasar_config | safe }}
       });
 
       {{ js_imports | safe }}

+ 1 - 1
tests/conftest.py

@@ -6,7 +6,6 @@ import icecream
 import pytest
 from selenium import webdriver
 from selenium.webdriver.chrome.service import Service
-from webdriver_manager.chrome import ChromeDriverManager
 
 from nicegui import Client, globals
 from nicegui.elements import plotly, pyplot
@@ -36,6 +35,7 @@ def reset_globals() -> Generator[None, None, None]:
     print('!!! resetting globals !!!')
     for path in {'/'}.union(globals.page_routes.values()):
         globals.app.remove_route(path)
+    globals.app.openapi_schema = None
     globals.app.middleware_stack = None
     globals.app.user_middleware.clear()
     # NOTE favicon routes must be removed separately because they are not "pages"

+ 14 - 4
tests/test_download.py

@@ -1,3 +1,6 @@
+from typing import Generator
+
+import pytest
 from fastapi import HTTPException
 
 from nicegui import app, ui
@@ -5,19 +8,26 @@ from nicegui import app, ui
 from .screen import Screen
 
 
-def test_download(screen: Screen):
+@pytest.fixture
+def test_route() -> Generator[str, None, None]:
+    TEST_ROUTE = '/static/test.py'
+    yield TEST_ROUTE
+    app.remove_route(TEST_ROUTE)
+
+
+def test_download(screen: Screen, test_route: str):
     success = False
 
-    @app.get('/static/test.py')
+    @app.get(test_route)
     def test():
         nonlocal success
         success = True
         raise HTTPException(404, 'Not found')
 
-    ui.button('Download', on_click=lambda: ui.download('static/test.py'))
+    ui.button('Download', on_click=lambda: ui.download(test_route))
 
     screen.open('/')
     screen.click('Download')
     screen.wait(0.5)
     assert success
-    screen.assert_py_logger('WARNING', f'http://localhost:{Screen.PORT}/static/test.py not found')
+    screen.assert_py_logger('WARNING', f'http://localhost:{Screen.PORT}{test_route} not found')

+ 16 - 0
tests/test_element.py

@@ -161,3 +161,19 @@ def test_move(screen: Screen):
     screen.click('Move X to top')
     screen.wait(0.5)
     assert screen.find('X').location['y'] < screen.find('A').location['y'] < screen.find('B').location['y']
+
+
+def test_xss(screen: Screen):
+    ui.label('</script><script>alert(1)</script>')
+    ui.label('<b>Bold 1</b>, `code`, copy&paste, multi\nline')
+    ui.button('Button', on_click=lambda: (
+        ui.label('</script><script>alert(2)</script>'),
+        ui.label('<b>Bold 2</b>, `code`, copy&paste, multi\nline'),
+    ))
+
+    screen.open('/')
+    screen.click('Button')
+    screen.should_contain('</script><script>alert(1)</script>')
+    screen.should_contain('</script><script>alert(2)</script>')
+    screen.should_contain('<b>Bold 1</b>, `code`, copy&paste, multi\nline')
+    screen.should_contain('<b>Bold 2</b>, `code`, copy&paste, multi\nline')

+ 41 - 0
tests/test_endpoint_docs.py

@@ -0,0 +1,41 @@
+from typing import Set
+
+import requests
+
+from nicegui import __version__
+
+from .screen import Screen
+
+
+def get_openapi_paths() -> Set[str]:
+    return set(requests.get(f'http://localhost:{Screen.PORT}/openapi.json').json()['paths'])
+
+
+def test_endpoint_documentation_default(screen: Screen):
+    screen.open('/')
+    assert get_openapi_paths() == set()
+
+
+def test_endpoint_documentation_page_only(screen: Screen):
+    screen.ui_run_kwargs['endpoint_documentation'] = 'page'
+    screen.open('/')
+    assert get_openapi_paths() == {'/'}
+
+
+def test_endpoint_documentation_internal_only(screen: Screen):
+    screen.ui_run_kwargs['endpoint_documentation'] = 'internal'
+    screen.open('/')
+    assert get_openapi_paths() == {
+        f'/_nicegui/{__version__}/libraries/{{key}}',
+        f'/_nicegui/{__version__}/components/{{key}}',
+    }
+
+
+def test_endpoint_documentation_all(screen: Screen):
+    screen.ui_run_kwargs['endpoint_documentation'] = 'all'
+    screen.open('/')
+    assert get_openapi_paths() == {
+        '/',
+        f'/_nicegui/{__version__}/libraries/{{key}}',
+        f'/_nicegui/{__version__}/components/{{key}}',
+    }

+ 19 - 0
tests/test_prod_js.py

@@ -0,0 +1,19 @@
+from selenium.webdriver.common.by import By
+
+from nicegui import __version__
+
+from .screen import Screen
+
+
+def test_dev_mode(screen: Screen) -> None:
+    screen.ui_run_kwargs['prod_js'] = False
+    screen.open('/')
+    screen.selenium.find_element(By.XPATH, f'//script[@src="/_nicegui/{__version__}/static/vue.global.js"]')
+    screen.selenium.find_element(By.XPATH, f'//script[@src="/_nicegui/{__version__}/static/quasar.umd.js"]')
+
+
+def test_prod_mode(screen: Screen):
+    screen.ui_run_kwargs['prod_js'] = True
+    screen.open('/')
+    screen.selenium.find_element(By.XPATH, f'//script[@src="/_nicegui/{__version__}/static/vue.global.prod.js"]')
+    screen.selenium.find_element(By.XPATH, f'//script[@src="/_nicegui/{__version__}/static/quasar.umd.prod.js"]')

+ 8 - 0
tests/test_query.py

@@ -52,3 +52,11 @@ def test_query_multiple_divs(screen: Screen):
     screen.wait(0.5)
     assert screen.find('A').value_of_css_property('border') == '1px solid rgb(0, 0, 0)'
     assert screen.find('B').value_of_css_property('border') == '1px solid rgb(0, 0, 0)'
+
+
+def test_query_with_css_variables(screen: Screen):
+    ui.add_body_html('<div id="element">Test</div>')
+    ui.query('#element').style('--color: red; color: var(--color)')
+
+    screen.open('/')
+    assert screen.find('Test').value_of_css_property('color') == 'rgba(255, 0, 0, 1)'