소스 검색

Merge pull request #967 from zauberzeug/session_data

Providing a nice interface to simplify storing general and user specific data
Falko Schindler 2 년 전
부모
커밋
e3326a6b8c

+ 1 - 1
.github/workflows/test.yml

@@ -9,7 +9,7 @@ jobs:
         python: ["3.7", "3.8", "3.9", "3.10", "3.11"]
       fail-fast: false
     runs-on: ubuntu-latest
-    timeout-minutes: 20
+    timeout-minutes: 30
     steps:
       - uses: actions/checkout@v3
       - name: set up Python

+ 1 - 2
.gitignore

@@ -5,7 +5,6 @@ dist
 /test.py
 *.pickle
 tests/screenshots/
-
-# ignore local virtual environments
 venv
 .idea
+.nicegui/

+ 1 - 0
README.md

@@ -43,6 +43,7 @@ NiceGUI is available as [PyPI package](https://pypi.org/project/nicegui/), [Dock
 - straight-forward data binding and refreshable functions to write even less code
 - notifications, dialogs and menus to provide state of the art user interaction
 - shared and individual web pages
+- easy-to-use per-user and general persistence
 - ability to add custom routes and data responses
 - capture keyboard input for global shortcuts etc.
 - customize look by defining primary, secondary and accent colors

+ 13 - 43
examples/authentication/main.py

@@ -1,72 +1,42 @@
 #!/usr/bin/env python3
-"""This is a very simple authentication example which stores session IDs in memory and does not do any password hashing.
+"""This is just a very simple authentication example.
 
 Please see the `OAuth2 example at FastAPI <https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/>`_  or
-use the great `Authlib package <https://docs.authlib.org/en/v0.13/client/starlette.html#using-fastapi>`_
-to implement a real authentication system.
-
+use the great `Authlib package <https://docs.authlib.org/en/v0.13/client/starlette.html#using-fastapi>`_ to implement a classing real authentication system.
 Here we just demonstrate the NiceGUI integration.
 """
-
-import os
-import uuid
-from typing import Dict
-
-from fastapi import Request
 from fastapi.responses import RedirectResponse
-from starlette.middleware.sessions import SessionMiddleware
 
 from nicegui import app, ui
 
-# put your your own secret key in an environment variable MY_SECRET_KEY
-app.add_middleware(SessionMiddleware, secret_key=os.environ.get('MY_SECRET_KEY', ''))
-
-# in reality users and session_info would be persistent (e.g. database, file, ...) and passwords obviously hashed
-users = [('user1', 'pass1'), ('user2', 'pass2')]
-session_info: Dict[str, Dict] = {}
-
-
-def is_authenticated(request: Request) -> bool:
-    return session_info.get(request.session.get('id'), {}).get('authenticated', False)
+# in reality users passwords would obviously need to be hashed
+passwords = {'user1': 'pass1', 'user2': 'pass2'}
 
 
 @ui.page('/')
-def main_page(request: Request) -> None:
-    if not is_authenticated(request):
+def main_page() -> None:
+    if not app.storage.user.get('authenticated', False):
         return RedirectResponse('/login')
-    session = session_info[request.session['id']]
     with ui.column().classes('absolute-center items-center'):
-        ui.label(f'Hello {session["username"]}!').classes('text-2xl')
-        # NOTE we navigate to a new page here to be able to modify the session cookie (it is only editable while a request is en-route)
-        # see https://github.com/zauberzeug/nicegui/issues/527 for more details
-        ui.button(on_click=lambda: ui.open('/logout')).props('outline round icon=logout')
+        ui.label(f'Hello {app.storage.user["username"]}!').classes('text-2xl')
+        ui.button(on_click=lambda: (app.storage.user.clear(), ui.open('/login'))).props('outline round icon=logout')
 
 
 @ui.page('/login')
-def login(request: Request) -> None:
+def login() -> None:
     def try_login() -> None:  # local function to avoid passing username and password as arguments
-        if (username.value, password.value) in users:
-            session_info[request.session['id']] = {'username': username.value, 'authenticated': True}
+        if passwords.get(username.value) == password.value:
+            app.storage.user.update({'username': username.value, 'authenticated': True})
             ui.open('/')
         else:
             ui.notify('Wrong username or password', color='negative')
 
-    if is_authenticated(request):
+    if app.storage.user.get('authenticated', False):
         return RedirectResponse('/')
-    request.session['id'] = str(uuid.uuid4())  # NOTE this stores a new session ID in the cookie of the client
     with ui.card().classes('absolute-center'):
         username = ui.input('Username').on('keydown.enter', try_login)
         password = ui.input('Password').on('keydown.enter', try_login).props('type=password')
         ui.button('Log in', on_click=try_login)
 
 
-@ui.page('/logout')
-def logout(request: Request) -> RedirectResponse:
-    if is_authenticated(request):
-        session_info.pop(request.session['id'])
-        request.session['id'] = None
-        return RedirectResponse('/login')
-    return RedirectResponse('/')
-
-
-ui.run()
+ui.run(storage_secret='THIS_NEEDS_TO_BE_CHANGED')

+ 1 - 1
main.py

@@ -352,7 +352,7 @@ async def documentation_page_more(name: str, client: Client) -> None:
     with side_menu() as menu:
         ui.markdown(f'[← back](/documentation#{create_anchor_name(back_link_target)})').classes('bold-links')
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1250px] mx-auto'):
-        section_heading('Documentation', f'ui.*{name}*')
+        section_heading('Documentation', f'ui.*{name}*' if hasattr(ui, name) else f'*{name.title()}*')
         with menu:
             ui.markdown('**Demos**' if more else '**Demo**').classes('mt-4')
         element_demo(api)(getattr(module, 'main_demo'))

+ 2 - 0
nicegui/app.py

@@ -5,6 +5,7 @@ from fastapi.staticfiles import StaticFiles
 
 from . import globals
 from .native import Native
+from .storage import Storage
 
 
 class App(FastAPI):
@@ -12,6 +13,7 @@ class App(FastAPI):
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
         self.native = Native()
+        self.storage = Storage()
 
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
         """Called every time a new client connects to NiceGUI.

+ 3 - 1
nicegui/element.py

@@ -9,7 +9,7 @@ from typing_extensions import Self
 
 from nicegui import json
 
-from . import binding, events, globals, outbox
+from . import binding, events, globals, outbox, storage
 from .elements.mixins.visibility import Visibility
 from .event_listener import EventListener
 from .slot import Slot
@@ -230,6 +230,7 @@ class Element(Visibility):
                 throttle=throttle,
                 leading_events=leading_events,
                 trailing_events=trailing_events,
+                request=storage.request_contextvar.get(),
             )
             self._event_listeners[listener.id] = listener
             self.update()
@@ -237,6 +238,7 @@ class Element(Visibility):
 
     def _handle_event(self, msg: Dict) -> None:
         listener = self._event_listeners[msg['listener_id']]
+        storage.request_contextvar.set(listener.request)
         events.handle_event(listener.handler, msg, sender=self)
 
     def update(self) -> None:

+ 3 - 0
nicegui/event_listener.py

@@ -2,6 +2,8 @@ import uuid
 from dataclasses import dataclass, field
 from typing import Any, Callable, Dict, List, Optional
 
+from fastapi import Request
+
 from .helpers import KWONLY_SLOTS
 
 
@@ -15,6 +17,7 @@ class EventListener:
     throttle: float
     leading_events: bool
     trailing_events: bool
+    request: Optional[Request]
 
     def __post_init__(self) -> None:
         self.id = str(uuid.uuid4())

+ 1 - 0
nicegui/nicegui.py

@@ -80,6 +80,7 @@ def handle_startup(with_welcome_message: bool = True) -> None:
             safe_invoke(t)
     background_tasks.create(binding.loop())
     background_tasks.create(outbox.loop())
+    background_tasks.create(app.storage._loop())
     background_tasks.create(prune_clients())
     background_tasks.create(prune_slot_stacks())
     globals.state = globals.State.STARTED

+ 15 - 1
nicegui/run.py

@@ -7,6 +7,8 @@ from typing import Any, List, Optional, Tuple
 
 import __main__
 import uvicorn
+from starlette.middleware import Middleware
+from starlette.middleware.sessions import SessionMiddleware
 from uvicorn.main import STARTUP_FAILURE
 from uvicorn.supervisors import ChangeReload, Multiprocess
 
@@ -14,6 +16,7 @@ from . import globals, helpers
 from . import native as native_module
 from . import native_mode
 from .language import Language
+from .storage import RequestTrackingMiddleware
 
 
 class Server(uvicorn.Server):
@@ -24,6 +27,13 @@ class Server(uvicorn.Server):
         native_module.response_queue = self.config.response_queue
         if native_module.method_queue is not None:
             globals.app.native.main_window = native_module.WindowProxy()
+
+        if any(m.cls == SessionMiddleware for m in globals.app.user_middleware):
+            # NOTE not using "add_middleware" because it would be the wrong order
+            globals.app.user_middleware.append(Middleware(RequestTrackingMiddleware))
+        elif self.config.storage_secret is not None:
+            globals.app.add_middleware(RequestTrackingMiddleware)
+            globals.app.add_middleware(SessionMiddleware, secret_key=self.config.storage_secret)
         super().run(sockets=sockets)
 
 
@@ -47,6 +57,7 @@ def run(*,
         uvicorn_reload_excludes: str = '.*, .py[cod], .sw.*, ~*',
         exclude: str = '',
         tailwind: bool = True,
+        storage_secret: Optional[str] = None,
         **kwargs: Any,
         ) -> None:
     '''ui.run
@@ -73,7 +84,8 @@ def run(*,
     :param exclude: comma-separated string to exclude elements (with corresponding JavaScript libraries) to save bandwidth
       (possible entries: aggrid, audio, chart, colors, interactive_image, joystick, keyboard, log, markdown, mermaid, plotly, scene, video)
     :param tailwind: whether to use Tailwind (experimental, default: `True`)
-    :param kwargs: additional keyword arguments are passed to `uvicorn.run`
+    :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`    
     '''
     globals.ui_run_has_been_called = True
     globals.reload = reload
@@ -129,9 +141,11 @@ def run(*,
         log_level=uvicorn_logging_level,
         **kwargs,
     )
+    config.storage_secret = storage_secret
     config.method_queue = native_module.method_queue if native else None
     config.response_queue = native_module.response_queue if native else None
     globals.server = Server(config=config)
+
     if (reload or config.workers > 1) and not isinstance(config.app, str):
         logging.warning('You must pass the application as an import string to enable "reload" or "workers".')
         sys.exit(1)

+ 155 - 0
nicegui/storage.py

@@ -0,0 +1,155 @@
+import asyncio
+import contextvars
+import json
+import threading
+import uuid
+from collections.abc import MutableMapping
+from pathlib import Path
+from typing import Any, Dict, Iterator, Optional, Union
+
+import aiofiles
+from fastapi import Request
+from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
+from starlette.responses import Response
+
+from . import globals
+
+request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None)
+
+
+class ReadOnlyDict(MutableMapping):
+
+    def __init__(self, data: Dict[Any, Any], write_error_message: str = 'Read-only dict') -> None:
+        self._data: Dict[Any, Any] = data
+        self._write_error_message: str = write_error_message
+
+    def __getitem__(self, item: Any) -> Any:
+        return self._data[item]
+
+    def __setitem__(self, key: Any, value: Any) -> None:
+        raise TypeError(self._write_error_message)
+
+    def __delitem__(self, key: Any) -> None:
+        raise TypeError(self._write_error_message)
+
+    def __iter__(self) -> Iterator:
+        return iter(self._data)
+
+    def __len__(self) -> int:
+        return len(self._data)
+
+
+class PersistentDict(dict):
+
+    def __init__(self, filepath: Path, *args: Any, **kwargs: Any) -> None:
+        self.filepath = filepath
+        self.lock = threading.Lock()
+        self.load()
+        self.update(*args, **kwargs)
+        self.modified = bool(args or kwargs)
+
+    def clear(self) -> None:
+        with self.lock:
+            super().clear()
+            self.modified = True
+
+    def __setitem__(self, key: Any, value: Any) -> None:
+        with self.lock:
+            super().__setitem__(key, value)
+            self.modified = True
+
+    def __delitem__(self, key: Any) -> None:
+        with self.lock:
+            super().__delitem__(key)
+            self.modified = True
+
+    def load(self) -> None:
+        with self.lock:
+            if self.filepath.exists():
+                with open(self.filepath, 'r') as f:
+                    try:
+                        self.update(json.load(f))
+                    except json.JSONDecodeError:
+                        pass
+
+    async def backup(self) -> None:
+        data = dict(self)
+        if self.modified:
+            async with aiofiles.open(self.filepath, 'w') as f:
+                await f.write(json.dumps(data))
+
+
+class RequestTrackingMiddleware(BaseHTTPMiddleware):
+
+    async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
+        request_contextvar.set(request)
+        if 'id' not in request.session:
+            request.session['id'] = str(uuid.uuid4())
+        request.state.responded = False
+        response = await call_next(request)
+        request.state.responded = True
+        return response
+
+
+class Storage:
+
+    def __init__(self) -> None:
+        self.storage_dir = Path('.nicegui')
+        self.storage_dir.mkdir(exist_ok=True)
+        self._general = PersistentDict(self.storage_dir / 'storage_general.json')
+        self._users: Dict[str, PersistentDict] = {}
+
+    @property
+    def browser(self) -> Union[ReadOnlyDict, Dict]:
+        """Small storage that is saved directly within the user's browser (encrypted cookie).
+
+        The data is shared between all browser tabs and can only be modified before the initial request has been submitted.
+        It is normally better to use `app.storage.user` instead to reduce payload, gain improved security and have larger storage capacity.
+        """
+        request: Optional[Request] = request_contextvar.get()
+        if request is None:
+            if globals.get_client() == globals.index_client:
+                raise RuntimeError('app.storage.browser can only be used with page builder functions '
+                                   '(https://nicegui.io/documentation/page)')
+            else:
+                raise RuntimeError('app.storage.browser needs a storage_secret passed in ui.run()')
+        if request.state.responded:
+            return ReadOnlyDict(
+                request.session,
+                'the response to the browser has already been built, so modifications cannot be sent back anymore'
+            )
+        return request.session
+
+    @property
+    def user(self) -> Dict:
+        """Individual user storage that is persisted on the server (where NiceGUI is executed).
+
+        The data is stored in a file on the server.
+        It is shared between all browser tabs by identifying the user via session cookie ID.
+        """
+        request: Optional[Request] = request_contextvar.get()
+        if request is None:
+            if globals.get_client() == globals.index_client:
+                raise RuntimeError('app.storage.user can only be used with page builder functions '
+                                   '(https://nicegui.io/documentation/page)')
+            else:
+                raise RuntimeError('app.storage.user needs a storage_secret passed in ui.run()')
+        id = request.session['id']
+        if id not in self._users:
+            self._users[id] = PersistentDict(self.storage_dir / f'storage_user_{id}.json')
+        return self._users[id]
+
+    @property
+    def general(self) -> Dict:
+        """General storage shared between all users that is persisted on the server (where NiceGUI is executed)."""
+        return self._general
+
+    async def backup(self) -> None:
+        await self._general.backup()
+        for user in self._users.values():
+            await user.backup()
+
+    async def _loop(self) -> None:
+        while True:
+            await self.backup()
+            await asyncio.sleep(1.0)

+ 14 - 2
poetry.lock

@@ -1,5 +1,17 @@
 # This file is automatically @generated by Poetry and should not be changed by hand.
 
+[[package]]
+name = "aiofiles"
+version = "23.1.0"
+description = "File support for asyncio."
+category = "main"
+optional = false
+python-versions = ">=3.7,<4.0"
+files = [
+    {file = "aiofiles-23.1.0-py3-none-any.whl", hash = "sha256:9312414ae06472eb6f1d163f555e466a23aed1c8f60c30cccf7121dba2e53eb2"},
+    {file = "aiofiles-23.1.0.tar.gz", hash = "sha256:edd247df9a19e0db16534d4baaf536d6609a43e1de5401d7a4c1c148753a1635"},
+]
+
 [[package]]
 name = "anyio"
 version = "3.6.2"
@@ -736,7 +748,7 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"]
 name = "itsdangerous"
 version = "2.1.2"
 description = "Safely pass data to untrusted environments and back."
-category = "dev"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2446,4 +2458,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.7"
-content-hash = "3f3e2c9af9620c1fee0a06a67dfb8199ca91d7f64e51dfc8bda40cf15dc39ac8"
+content-hash = "01dd4e6d62f913d2f5206dcd946dc9804767c4b57b115ef69eb56a73213ae5e4"

+ 2 - 0
pyproject.toml

@@ -28,6 +28,8 @@ plotly = "^5.13.0"
 orjson = {version = "^3.8.6", markers = "platform_machine != 'i386' and platform_machine != 'i686'"} # orjson does not support 32bit
 pywebview = "^4.0.2"
 importlib_metadata = { version = "^6.0.0", markers = "python_version ~= '3.7'" } # Python 3.7 has no importlib.metadata
+itsdangerous = "^2.1.2"
+aiofiles = "^23.1.0"
 
 [tool.poetry.group.dev.dependencies]
 icecream = "^2.1.0"

+ 5 - 1
tests/conftest.py

@@ -39,9 +39,13 @@ def selenium(selenium: webdriver.Chrome) -> webdriver.Chrome:
 def reset_globals() -> Generator[None, None, None]:
     for path in {'/'}.union(globals.page_routes.values()):
         globals.app.remove_route(path)
-    # NOTE favicon routes must be removed seperately because they are not "pages"
+    globals.app.middleware_stack = None
+    globals.app.user_middleware.clear()
+    # NOTE favicon routes must be removed separately because they are not "pages"
     [globals.app.routes.remove(r) for r in globals.app.routes if r.path.endswith('/favicon.ico')]
     importlib.reload(globals)
+    globals.app.storage.general.clear()
+    globals.app.storage._users.clear()
     globals.index_client = Client(page('/'), shared=True).__enter__()
     globals.app.get('/')(globals.index_client.build_response)
 

+ 141 - 0
tests/test_storage.py

@@ -0,0 +1,141 @@
+import asyncio
+
+from nicegui import Client, app, background_tasks, ui
+
+from .screen import Screen
+
+
+def test_browser_data_is_stored_in_the_browser(screen: Screen):
+    @ui.page('/')
+    def page():
+        app.storage.browser['count'] = app.storage.browser.get('count', 0) + 1
+        ui.label().bind_text_from(app.storage.browser, 'count')
+
+    @app.get('/count')
+    def count():
+        return 'count = ' + str(app.storage.browser['count'])
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.should_contain('1')
+    screen.open('/')
+    screen.should_contain('2')
+    screen.open('/')
+    screen.should_contain('3')
+    screen.open('/count')
+    screen.should_contain('count = 3')  # also works with FastAPI endpoints
+
+
+def test_browser_storage_supports_asyncio(screen: Screen):
+    @ui.page('/')
+    async def page():
+        app.storage.browser['count'] = app.storage.browser.get('count', 0) + 1
+        await asyncio.sleep(0.5)
+        ui.label(app.storage.browser['count'])
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.switch_to(1)
+    screen.open('/')
+    screen.should_contain('2')
+    screen.switch_to(0)
+    screen.open('/')
+    screen.should_contain('3')
+
+
+def test_browser_storage_modifications_after_page_load_are_forbidden(screen: Screen):
+    @ui.page('/')
+    async def page(client: Client):
+        await client.connected()
+        try:
+            app.storage.browser['test'] = 'data'
+        except TypeError as e:
+            ui.label(str(e))
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.should_contain('response to the browser has already been built')
+
+
+def test_user_storage_modifications(screen: Screen):
+    @ui.page('/')
+    async def page(client: Client, delayed: bool = False):
+        if delayed:
+            await client.connected()
+        app.storage.user['count'] = app.storage.user.get('count', 0) + 1
+        ui.label().bind_text_from(app.storage.user, 'count')
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.should_contain('1')
+    screen.open('/?delayed=True')
+    screen.should_contain('2')
+    screen.open('/')
+    screen.should_contain('3')
+
+
+async def test_access_user_storage_on_interaction(screen: Screen):
+    @ui.page('/')
+    async def page():
+        if 'test_switch' not in app.storage.user:
+            app.storage.user['test_switch'] = False
+        ui.switch('switch').bind_value(app.storage.user, 'test_switch')
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.click('switch')
+    screen.wait(1)
+    await app.storage.backup()
+    assert '{"test_switch": true}' in list(app.storage._users.values())[0].filepath.read_text()
+
+
+def test_access_user_storage_from_button_click_handler(screen: Screen):
+    @ui.page('/')
+    async def page():
+        async def inner():
+            app.storage.user['inner_function'] = 'works'
+            await app.storage.backup()
+
+        ui.button('test', on_click=inner)
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.click('test')
+    screen.wait(1)
+    assert '{"inner_function": "works"}' in list(app.storage._users.values())[0].filepath.read_text()
+
+
+async def test_access_user_storage_from_background_task(screen: Screen):
+    @ui.page('/')
+    def page():
+        async def subtask():
+            await asyncio.sleep(0.1)
+            app.storage.user['subtask'] = 'works'
+            await app.storage.backup()
+        background_tasks.create(subtask())
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    assert '{"subtask": "works"}' in list(app.storage._users.values())[0].filepath.read_text()
+
+
+def test_user_and_general_storage_is_persisted(screen: Screen):
+    @ui.page('/')
+    def page():
+        app.storage.user['count'] = app.storage.user.get('count', 0) + 1
+        app.storage.general['count'] = app.storage.general.get('count', 0) + 1
+        ui.label(f'user: {app.storage.user["count"]}')
+        ui.label(f'general: {app.storage.general["count"]}')
+        ui.button('backup', on_click=app.storage.backup)
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.open('/')
+    screen.open('/')
+    screen.should_contain('user: 3')
+    screen.should_contain('general: 3')
+    screen.click('backup')
+    screen.selenium.delete_all_cookies()
+    screen.open('/')
+    screen.should_contain('user: 1')
+    screen.should_contain('general: 4')

+ 17 - 26
website/documentation.py

@@ -413,33 +413,24 @@ def create_full() -> None:
     load_demo(ui.open)
     load_demo(ui.download)
 
-    @text_demo('Sessions', '''
-        The optional `request` argument provides insights about the client's URL parameters etc.
-        It also enables you to identify sessions using a [session middleware](https://www.starlette.io/middleware/#sessionmiddleware).
+    load_demo('storage')
+
+    @text_demo('Parameter injection', '''
+        Thanks to FastAPI, a page function accepts optional parameters to provide
+        [path parameters](https://fastapi.tiangolo.com/tutorial/path-params/), 
+        [query parameters](https://fastapi.tiangolo.com/tutorial/query-params/) or the whole incoming
+        [request](https://fastapi.tiangolo.com/advanced/using-request-directly/) for accessing
+        the body payload, headers, cookies and more.
     ''')
-    def sessions_demo():
-        import uuid
-        from collections import Counter
-        from datetime import datetime
-
-        from starlette.middleware.sessions import SessionMiddleware
-        from starlette.requests import Request
-
-        from nicegui import app
-
-        # app.add_middleware(SessionMiddleware, secret_key='some_random_string')
-
-        counter = Counter()
-        start = datetime.now().strftime('%H:%M, %d %B %Y')
-
-        @ui.page('/session_demo')
-        def session_demo(request: Request):
-            if 'id' not in request.session:
-                request.session['id'] = str(uuid.uuid4())
-            counter[request.session['id']] += 1
-            ui.label(f'{len(counter)} unique views ({sum(counter.values())} overall) since {start}')
-
-        ui.link('Visit session demo', session_demo)
+    def parameter_demo():
+        @ui.page('/icon/{icon}')
+        def icons(icon: str, amount: int = 1):
+            ui.label(icon).classes('text-h3')
+            with ui.row():
+                [ui.icon(icon).classes('text-h3') for _ in range(amount)]
+        ui.link('Star', '/icon/star?amount=5')
+        ui.link('Home', '/icon/home')
+        ui.link('Water', '/icon/water_drop?amount=3')
 
     load_demo(ui.run_javascript)
 

+ 2 - 1
website/documentation_tools.py

@@ -96,7 +96,8 @@ class element_demo:
         doc = self.element_class.__doc__ or self.element_class.__init__.__doc__
         title, documentation = doc.split('\n', 1)
         with ui.column().classes('w-full mb-8 gap-2'):
-            subheading(title, more_link=more_link)
+            if more_link:
+                subheading(title, more_link=more_link)
             render_docstring(documentation, with_params=more_link is None)
             result = demo(f)
             if more_link:

+ 69 - 0
website/more_documentation/storage_documentation.py

@@ -0,0 +1,69 @@
+from collections import Counter
+from datetime import datetime
+
+from nicegui import ui
+
+from ..documentation_tools import text_demo
+
+
+def main_demo() -> None:
+    """Storage
+
+    NiceGUI offers a straightforward method for data persistence within your application. 
+    It features three built-in storage types:
+
+    - `app.storage.user`:
+        Stored server-side, each dictionary is associated with a unique identifier held in a browser session cookie.
+        Unique to each user, this storage is accessible across all their browser tabs.
+    - `app.storage.general`:
+        Also stored server-side, this dictionary provides a shared storage space accessible to all users.
+    - `app.storage.browser`:
+        Unlike the previous types, this dictionary is stored directly as the browser session cookie, shared among all browser tabs for the same user.
+        However, `app.storage.user` is generally preferred due to its advantages in reducing data payload, enhancing security, and offering larger storage capacity.
+
+    To use the user or browser storage, you must pass a `storage_secret` to `ui.run()`. 
+    This is a private key used to encrypt the browser session cookie.
+    """
+    from nicegui import app
+
+    # @ui.page('/')
+    # def index():
+    #     app.storage.user['count'] = app.storage.user.get('count', 0) + 1
+    #     with ui.row():
+    #        ui.label('your own page visits:')
+    #        ui.label().bind_text_from(app.storage.user, 'count')
+    #
+    # ui.run(storage_secret='private key to secure the browser session cookie')
+    # END OF DEMO
+    app.storage.user['count'] = app.storage.user.get('count', 0) + 1
+    with ui.row():
+        ui.label('your own page visits:')
+        ui.label().bind_text_from(app.storage.user, 'count')
+
+
+counter = Counter()
+start = datetime.now().strftime('%H:%M, %d %B %Y')
+
+
+def more() -> None:
+    @text_demo('Counting page visits', '''
+        Here we are using the automatically available browser-stored session ID to count the number of unique page visits.
+    ''')
+    def page_visits():
+        from collections import Counter
+        from datetime import datetime
+
+        from nicegui import app
+
+        # counter = Counter()
+        # start = datetime.now().strftime('%H:%M, %d %B %Y')
+        #
+        # @ui.page('/')
+        # def index():
+        #     counter[app.storage.session.browser[id]] += 1
+        #     ui.label(f'{len(counter)} unique views ({sum(counter.values())} overall) since {start}')
+        #
+        # ui.run(storage_secret='private key to secure the browser session cookie')
+        # END OF DEMO
+        counter[app.storage.browser['id']] += 1
+        ui.label(f'{len(counter)} unique views ({sum(counter.values())} overall) since {start}')